diff options
Diffstat (limited to '')
42 files changed, 1183 insertions, 777 deletions
| @@ -21,6 +21,8 @@ sentry-sdk = "~=0.14"  coloredlogs = "~=14.0"  colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"}  statsd = "~=3.3" +feedparser = "~=5.2" +beautifulsoup4 = "~=4.9"  [dev-packages]  coverage = "~=5.0" diff --git a/Pipfile.lock b/Pipfile.lock index 19e03bda4..4e7050a13 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@  {      "_meta": {          "hash": { -            "sha256": "10636aef5a07f17bd00608df2cc5214fcbfe3de4745cdeea7a076b871754620a" +            "sha256": "64620e7e825c74fd3010821fb30843b19f5dafb2b5a1f6eafedc0a5febd99b69"          },          "pipfile-spec": 6,          "requires": { @@ -91,6 +91,7 @@                  "sha256:a4bbe77fd30670455c5296242967a123ec28c37e9702a8a81bd2f20a4baf0368",                  "sha256:d4e96ac9b0c3a6d3f0caae2e4124e6055c5dcafde8e2f831ff194c104f0775a0"              ], +            "index": "pypi",              "version": "==4.9.0"          },          "certifi": { @@ -179,6 +180,15 @@              ],              "version": "==0.16"          }, +        "feedparser": { +            "hashes": [ +                "sha256:bd030652c2d08532c034c27fcd7c85868e7fa3cb2b17f230a44a6bbc92519bf9", +                "sha256:cd2485472e41471632ed3029d44033ee420ad0b57111db95c240c9160a85831c", +                "sha256:ce875495c90ebd74b179855449040003a1beb40cd13d5f037a0654251e260b02" +            ], +            "index": "pypi", +            "version": "==5.2.1" +        },          "fuzzywuzzy": {              "hashes": [                  "sha256:45016e92264780e58972dca1b3d939ac864b78437422beecebb3095f8efd00e8", @@ -189,10 +199,10 @@          },          "humanfriendly": {              "hashes": [ -                "sha256:25c2108a45cfd1e8fbe9cdb30b825d34ef5d5675c8e11e4775c9aedbfb0bdee2", -                "sha256:3a831920e40e55ad49adb64c9179ed50c604cabca72cd300e7bd5b51310e4ebb" +                "sha256:bf52ec91244819c780341a3438d5d7b09f431d3f113a475147ac9b7b167a3d12", +                "sha256:e78960b31198511f45fd455534ae7645a6207d33e512d2e842c766d15d9c8080"              ], -            "version": "==8.1" +            "version": "==8.2"          },          "idna": {              "hashes": [ @@ -210,10 +220,10 @@          },          "jinja2": {              "hashes": [ -                "sha256:93187ffbc7808079673ef52771baa950426fd664d3aad1d0fa3e95644360e250", -                "sha256:b0eaf100007721b5c16c1fc1eecb87409464edc10469ddc9a22a27a99123be49" +                "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", +                "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"              ], -            "version": "==2.11.1" +            "version": "==2.11.2"          },          "lxml": {              "hashes": [ @@ -527,10 +537,10 @@          },          "urllib3": {              "hashes": [ -                "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", -                "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" +                "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", +                "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"              ], -            "version": "==1.25.8" +            "version": "==1.25.9"          },          "websockets": {              "hashes": [ @@ -606,40 +616,40 @@          },          "coverage": {              "hashes": [ -                "sha256:03f630aba2b9b0d69871c2e8d23a69b7fe94a1e2f5f10df5049c0df99db639a0", -                "sha256:046a1a742e66d065d16fb564a26c2a15867f17695e7f3d358d7b1ad8a61bca30", -                "sha256:0a907199566269e1cfa304325cc3b45c72ae341fbb3253ddde19fa820ded7a8b", -                "sha256:165a48268bfb5a77e2d9dbb80de7ea917332a79c7adb747bd005b3a07ff8caf0", -                "sha256:1b60a95fc995649464e0cd48cecc8288bac5f4198f21d04b8229dc4097d76823", -                "sha256:1f66cf263ec77af5b8fe14ef14c5e46e2eb4a795ac495ad7c03adc72ae43fafe", -                "sha256:2e08c32cbede4a29e2a701822291ae2bc9b5220a971bba9d1e7615312efd3037", -                "sha256:3844c3dab800ca8536f75ae89f3cf566848a3eb2af4d9f7b1103b4f4f7a5dad6", -                "sha256:408ce64078398b2ee2ec08199ea3fcf382828d2f8a19c5a5ba2946fe5ddc6c31", -                "sha256:443be7602c790960b9514567917af538cac7807a7c0c0727c4d2bbd4014920fd", -                "sha256:4482f69e0701139d0f2c44f3c395d1d1d37abd81bfafbf9b6efbe2542679d892", -                "sha256:4a8a259bf990044351baf69d3b23e575699dd60b18460c71e81dc565f5819ac1", -                "sha256:513e6526e0082c59a984448f4104c9bf346c2da9961779ede1fc458e8e8a1f78", -                "sha256:5f587dfd83cb669933186661a351ad6fc7166273bc3e3a1531ec5c783d997aac", -                "sha256:62061e87071497951155cbccee487980524d7abea647a1b2a6eb6b9647df9006", -                "sha256:641e329e7f2c01531c45c687efcec8aeca2a78a4ff26d49184dce3d53fc35014", -                "sha256:65a7e00c00472cd0f59ae09d2fb8a8aaae7f4a0cf54b2b74f3138d9f9ceb9cb2", -                "sha256:6ad6ca45e9e92c05295f638e78cd42bfaaf8ee07878c9ed73e93190b26c125f7", -                "sha256:73aa6e86034dad9f00f4bbf5a666a889d17d79db73bc5af04abd6c20a014d9c8", -                "sha256:7c9762f80a25d8d0e4ab3cb1af5d9dffbddb3ee5d21c43e3474c84bf5ff941f7", -                "sha256:85596aa5d9aac1bf39fe39d9fa1051b0f00823982a1de5766e35d495b4a36ca9", -                "sha256:86a0ea78fd851b313b2e712266f663e13b6bc78c2fb260b079e8b67d970474b1", -                "sha256:8a620767b8209f3446197c0e29ba895d75a1e272a36af0786ec70fe7834e4307", -                "sha256:922fb9ef2c67c3ab20e22948dcfd783397e4c043a5c5fa5ff5e9df5529074b0a", -                "sha256:9fad78c13e71546a76c2f8789623eec8e499f8d2d799f4b4547162ce0a4df435", -                "sha256:a37c6233b28e5bc340054cf6170e7090a4e85069513320275a4dc929144dccf0", -                "sha256:c3fc325ce4cbf902d05a80daa47b645d07e796a80682c1c5800d6ac5045193e5", -                "sha256:cda33311cb9fb9323958a69499a667bd728a39a7aa4718d7622597a44c4f1441", -                "sha256:db1d4e38c9b15be1521722e946ee24f6db95b189d1447fa9ff18dd16ba89f732", -                "sha256:eda55e6e9ea258f5e4add23bcf33dc53b2c319e70806e180aecbff8d90ea24de", -                "sha256:f372cdbb240e09ee855735b9d85e7f50730dcfb6296b74b95a3e5dea0615c4c1" -            ], -            "index": "pypi", -            "version": "==5.0.4" +                "sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a", +                "sha256:01333e1bd22c59713ba8a79f088b3955946e293114479bbfc2e37d522be03355", +                "sha256:0cb4be7e784dcdc050fc58ef05b71aa8e89b7e6636b99967fadbdba694cf2b65", +                "sha256:0e61d9803d5851849c24f78227939c701ced6704f337cad0a91e0972c51c1ee7", +                "sha256:1601e480b9b99697a570cea7ef749e88123c04b92d84cedaa01e117436b4a0a9", +                "sha256:2742c7515b9eb368718cd091bad1a1b44135cc72468c731302b3d641895b83d1", +                "sha256:2d27a3f742c98e5c6b461ee6ef7287400a1956c11421eb574d843d9ec1f772f0", +                "sha256:402e1744733df483b93abbf209283898e9f0d67470707e3c7516d84f48524f55", +                "sha256:5c542d1e62eece33c306d66fe0a5c4f7f7b3c08fecc46ead86d7916684b36d6c", +                "sha256:5f2294dbf7875b991c381e3d5af2bcc3494d836affa52b809c91697449d0eda6", +                "sha256:6402bd2fdedabbdb63a316308142597534ea8e1895f4e7d8bf7476c5e8751fef", +                "sha256:66460ab1599d3cf894bb6baee8c684788819b71a5dc1e8fa2ecc152e5d752019", +                "sha256:782caea581a6e9ff75eccda79287daefd1d2631cc09d642b6ee2d6da21fc0a4e", +                "sha256:79a3cfd6346ce6c13145731d39db47b7a7b859c0272f02cdb89a3bdcbae233a0", +                "sha256:7a5bdad4edec57b5fb8dae7d3ee58622d626fd3a0be0dfceda162a7035885ecf", +                "sha256:8fa0cbc7ecad630e5b0f4f35b0f6ad419246b02bc750de7ac66db92667996d24", +                "sha256:a027ef0492ede1e03a8054e3c37b8def89a1e3c471482e9f046906ba4f2aafd2", +                "sha256:a3f3654d5734a3ece152636aad89f58afc9213c6520062db3978239db122f03c", +                "sha256:a82b92b04a23d3c8a581fc049228bafde988abacba397d57ce95fe95e0338ab4", +                "sha256:acf3763ed01af8410fc36afea23707d4ea58ba7e86a8ee915dfb9ceff9ef69d0", +                "sha256:adeb4c5b608574a3d647011af36f7586811a2c1197c861aedb548dd2453b41cd", +                "sha256:b83835506dfc185a319031cf853fa4bb1b3974b1f913f5bb1a0f3d98bdcded04", +                "sha256:bb28a7245de68bf29f6fb199545d072d1036a1917dca17a1e75bbb919e14ee8e", +                "sha256:bf9cb9a9fd8891e7efd2d44deb24b86d647394b9705b744ff6f8261e6f29a730", +                "sha256:c317eaf5ff46a34305b202e73404f55f7389ef834b8dbf4da09b9b9b37f76dd2", +                "sha256:dbe8c6ae7534b5b024296464f387d57c13caa942f6d8e6e0346f27e509f0f768", +                "sha256:de807ae933cfb7f0c7d9d981a053772452217df2bf38e7e6267c9cbf9545a796", +                "sha256:dead2ddede4c7ba6cb3a721870f5141c97dc7d85a079edb4bd8d88c3ad5b20c7", +                "sha256:dec5202bfe6f672d4511086e125db035a52b00f1648d6407cc8e526912c0353a", +                "sha256:e1ea316102ea1e1770724db01998d1603ed921c54a86a2efcb03428d5417e489", +                "sha256:f90bfc4ad18450c80b024036eaf91e4a246ae287701aaa88eaebebf150868052" +            ], +            "index": "pypi", +            "version": "==5.1"          },          "distlib": {              "hashes": [ @@ -671,11 +681,11 @@          },          "flake8-annotations": {              "hashes": [ -                "sha256:a38b44d01abd480586a92a02a2b0a36231ec42dcc5e114de78fa5db016d8d3f9", -                "sha256:d5b0e8704e4e7728b352fa1464e23539ff2341ba11cc153b536fa2cf921ee659" +                "sha256:9091d920406a7ff10e401e0dd1baa396d1d7d2e3d101a9beecf815f5894ad554", +                "sha256:f59fdceb8c8f380a20aed20e1ba8a57bde05935958166c52be2249f113f7ab75"              ],              "index": "pypi", -            "version": "==2.0.1" +            "version": "==2.1.0"          },          "flake8-bugbear": {              "hashes": [ @@ -836,10 +846,10 @@          },          "virtualenv": {              "hashes": [ -                "sha256:00cfe8605fb97f5a59d52baab78e6070e72c12ca64f51151695407cc0eb8a431", -                "sha256:c8364ec469084046c779c9a11ae6340094e8a0bf1d844330fc55c1cefe67c172" +                "sha256:5021396e8f03d0d002a770da90e31e61159684db2859d0ba4850fbea752aa675", +                "sha256:ac53ade75ca189bc97b6c1d9ec0f1a50efe33cbf178ae09452dcd9fd309013c1"              ], -            "version": "==20.0.17" +            "version": "==20.0.18"          }      }  } diff --git a/bot/__init__.py b/bot/__init__.py index 2dd4af225..d63086fe2 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -1,3 +1,4 @@ +import asyncio  import logging  import os  import sys @@ -58,4 +59,10 @@ coloredlogs.install(logger=root_log, stream=sys.stdout)  logging.getLogger("discord").setLevel(logging.WARNING)  logging.getLogger("websockets").setLevel(logging.WARNING) +logging.getLogger("chardet").setLevel(logging.WARNING)  logging.getLogger(__name__) + + +# On Windows, the selector event loop is required for aiodns. +if os.name == "nt": +    asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) diff --git a/bot/__main__.py b/bot/__main__.py index 3aa36bfc0..aa1d1aee8 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -51,6 +51,7 @@ bot.load_extension("bot.cogs.eval")  bot.load_extension("bot.cogs.information")  bot.load_extension("bot.cogs.jams")  bot.load_extension("bot.cogs.moderation") +bot.load_extension("bot.cogs.python_news")  bot.load_extension("bot.cogs.off_topic_names")  bot.load_extension("bot.cogs.reddit")  bot.load_extension("bot.cogs.reminders") diff --git a/bot/bot.py b/bot/bot.py index 6dd5ba896..a85a22aa9 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -7,6 +7,7 @@ from typing import Optional  import aiohttp  import discord  from discord.ext import commands +from sentry_sdk import push_scope  from bot import DEBUG_MODE, api, constants  from bot.async_stats import AsyncStatsClient @@ -75,7 +76,7 @@ class Bot(commands.Bot):              await self._resolver.close()          if self.stats._transport: -            await self.stats._transport.close() +            self.stats._transport.close()      async def login(self, *args, **kwargs) -> None:          """Re-create the connector and set up sessions before logging into Discord.""" @@ -155,3 +156,14 @@ class Bot(commands.Bot):          gateway event before giving up and thus not populating the cache for unavailable guilds.          """          await self._guild_available.wait() + +    async def on_error(self, event: str, *args, **kwargs) -> None: +        """Log errors raised in event listeners rather than printing them to stderr.""" +        self.stats.incr(f"errors.event.{event}") + +        with push_scope() as scope: +            scope.set_tag("event", event) +            scope.set_extra("args", args) +            scope.set_extra("kwargs", kwargs) + +            log.exception(f"Unhandled exception in {event}.") diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 79bf486a4..66b5073e8 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -38,6 +38,18 @@ class AntiMalware(Cog):                  "It looks like you tried to attach a Python file - "                  f"please use a code-pasting service such as {URLs.site_schema}{URLs.site_paste}"              ) +        elif ".txt" in extensions_blocked: +            # Work around Discord AutoConversion of messages longer than 2000 chars to .txt +            cmd_channel = self.bot.get_channel(Channels.bot_commands) +            embed.description = ( +                "**Uh-oh!** It looks like your message got zapped by our spam filter. " +                "We currently don't allow `.txt` attachments, so here are some tips to help you travel safely: \n\n" +                "• If you attempted to send a message longer than 2000 characters, try shortening your message " +                "to fit within the character limit or use a pasting service (see below) \n\n" +                "• If you tried to show someone your code, you can use codeblocks \n(run `!code-blocks` in " +                f"{cmd_channel.mention} for more information) or use a pasting service like: " +                f"\n\n{URLs.site_schema}{URLs.site_paste}" +            )          elif extensions_blocked:              whitelisted_types = ', '.join(AntiMalwareConfig.whitelist)              meta_channel = self.bot.get_channel(Channels.meta) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index a6929b431..f6aea51c5 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -41,7 +41,7 @@ class BotCog(Cog, name="Bot"):      @with_role(Roles.verified)      async def botinfo_group(self, ctx: Context) -> None:          """Bot informational commands.""" -        await ctx.invoke(self.bot.get_command("help"), "bot") +        await ctx.send_help(ctx.command)      @botinfo_group.command(name='about', aliases=('info',), hidden=True)      @with_role(Roles.verified) diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index 5cdf0b048..b5d9132cb 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -180,7 +180,7 @@ class Clean(Cog):      @with_role(*MODERATION_ROLES)      async def clean_group(self, ctx: Context) -> None:          """Commands for cleaning messages in channels.""" -        await ctx.invoke(self.bot.get_command("help"), "clean") +        await ctx.send_help(ctx.command)      @clean_group.command(name="user", aliases=["users"])      @with_role(*MODERATION_ROLES) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 56fca002a..25b0a6ad5 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -122,7 +122,7 @@ class Defcon(Cog):      @with_role(Roles.admins, Roles.owners)      async def defcon_group(self, ctx: Context) -> None:          """Check the DEFCON status or run a subcommand.""" -        await ctx.invoke(self.bot.get_command("help"), "defcon") +        await ctx.send_help(ctx.command)      async def _defcon_action(self, ctx: Context, days: int, action: Action) -> None:          """Providing a structured way to do an defcon action.""" diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index dae283c6a..23d1eed82 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -2,14 +2,14 @@ import contextlib  import logging  import typing as t -from discord.ext.commands import Cog, Command, Context, errors +from discord.ext.commands import Cog, Context, errors  from sentry_sdk import push_scope  from bot.api import ResponseCodeError  from bot.bot import Bot  from bot.constants import Channels  from bot.converters import TagNameConverter -from bot.decorators import InChannelCheckFailure +from bot.decorators import InWhitelistCheckFailure  log = logging.getLogger(__name__) @@ -79,19 +79,13 @@ class ErrorHandler(Cog):              f"{e.__class__.__name__}: {e}"          ) -    async def get_help_command(self, command: t.Optional[Command]) -> t.Tuple: -        """Return the help command invocation args to display help for `command`.""" -        parent = None -        if command is not None: -            parent = command.parent - -        # Retrieve the help command for the invoked command. -        if parent and command: -            return self.bot.get_command("help"), parent.name, command.name -        elif command: -            return self.bot.get_command("help"), command.name -        else: -            return self.bot.get_command("help") +    @staticmethod +    def get_help_command(ctx: Context) -> t.Coroutine: +        """Return a prepared `help` command invocation coroutine.""" +        if ctx.command: +            return ctx.send_help(ctx.command) + +        return ctx.send_help()      async def try_silence(self, ctx: Context) -> bool:          """ @@ -165,20 +159,19 @@ class ErrorHandler(Cog):          * ArgumentParsingError: send an error message          * Other: send an error message and the help command          """ -        # TODO: use ctx.send_help() once PR #519 is merged. -        help_command = await self.get_help_command(ctx.command) +        prepared_help_command = self.get_help_command(ctx)          if isinstance(e, errors.MissingRequiredArgument):              await ctx.send(f"Missing required argument `{e.param.name}`.") -            await ctx.invoke(*help_command) +            await prepared_help_command              self.bot.stats.incr("errors.missing_required_argument")          elif isinstance(e, errors.TooManyArguments):              await ctx.send(f"Too many arguments provided.") -            await ctx.invoke(*help_command) +            await prepared_help_command              self.bot.stats.incr("errors.too_many_arguments")          elif isinstance(e, errors.BadArgument):              await ctx.send(f"Bad argument: {e}\n") -            await ctx.invoke(*help_command) +            await prepared_help_command              self.bot.stats.incr("errors.bad_argument")          elif isinstance(e, errors.BadUnionArgument):              await ctx.send(f"Bad argument: {e}\n```{e.errors[-1]}```") @@ -188,7 +181,7 @@ class ErrorHandler(Cog):              self.bot.stats.incr("errors.argument_parsing_error")          else:              await ctx.send("Something about your input seems off. Check the arguments:") -            await ctx.invoke(*help_command) +            await prepared_help_command              self.bot.stats.incr("errors.other_user_input_error")      @staticmethod @@ -202,7 +195,7 @@ class ErrorHandler(Cog):          * BotMissingRole          * BotMissingAnyRole          * NoPrivateMessage -        * InChannelCheckFailure +        * InWhitelistCheckFailure          """          bot_missing_errors = (              errors.BotMissingPermissions, @@ -215,7 +208,7 @@ class ErrorHandler(Cog):              await ctx.send(                  f"Sorry, it looks like I don't have the permissions or roles I need to do that."              ) -        elif isinstance(e, (InChannelCheckFailure, errors.NoPrivateMessage)): +        elif isinstance(e, (InWhitelistCheckFailure, errors.NoPrivateMessage)):              ctx.bot.stats.incr("errors.wrong_channel_or_dm_error")              await ctx.send(e) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 52136fc8d..eb8bfb1cf 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -178,7 +178,7 @@ async def func():  # (None,) -> Any      async def internal_group(self, ctx: Context) -> None:          """Internal commands. Top secret!"""          if not ctx.invoked_subcommand: -            await ctx.invoke(self.bot.get_command("help"), "internal") +            await ctx.send_help(ctx.command)      @internal_group.command(name='eval', aliases=('e',))      @with_role(Roles.admins, Roles.owners) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index fb6cd9aa3..365f198ff 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -65,7 +65,7 @@ class Extensions(commands.Cog):      @group(name="extensions", aliases=("ext", "exts", "c", "cogs"), invoke_without_command=True)      async def extensions_group(self, ctx: Context) -> None:          """Load, unload, reload, and list loaded extensions.""" -        await ctx.invoke(self.bot.get_command("help"), "extensions") +        await ctx.send_help(ctx.command)      @extensions_group.command(name="load", aliases=("l",))      async def load_command(self, ctx: Context, *extensions: Extension) -> None: @@ -75,7 +75,7 @@ class Extensions(commands.Cog):          If '\*' or '\*\*' is given as the name, all unloaded extensions will be loaded.          """  # noqa: W605          if not extensions: -            await ctx.invoke(self.bot.get_command("help"), "extensions load") +            await ctx.send_help(ctx.command)              return          if "*" in extensions or "**" in extensions: @@ -92,7 +92,7 @@ class Extensions(commands.Cog):          If '\*' or '\*\*' is given as the name, all loaded extensions will be unloaded.          """  # noqa: W605          if not extensions: -            await ctx.invoke(self.bot.get_command("help"), "extensions unload") +            await ctx.send_help(ctx.command)              return          blacklisted = "\n".join(UNLOAD_BLACKLIST & set(extensions)) @@ -118,7 +118,7 @@ class Extensions(commands.Cog):          If '\*\*' is given as the name, all extensions, including unloaded ones, will be reloaded.          """  # noqa: W605          if not extensions: -            await ctx.invoke(self.bot.get_command("help"), "extensions reload") +            await ctx.send_help(ctx.command)              return          if "**" in extensions: diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 744722220..542f19139 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -1,34 +1,48 @@ -import asyncio  import itertools +import logging +from asyncio import TimeoutError  from collections import namedtuple  from contextlib import suppress -from typing import Union +from typing import List, Union -from discord import Colour, Embed, HTTPException, Message, Reaction, User -from discord.ext import commands -from discord.ext.commands import CheckFailure, Cog as DiscordCog, Command, Context +from discord import Colour, Embed, Member, Message, NotFound, Reaction, User +from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand  from fuzzywuzzy import fuzz, process  from bot import constants -from bot.bot import Bot  from bot.constants import Channels, Emojis, STAFF_ROLES  from bot.decorators import redirect_output -from bot.pagination import ( -    FIRST_EMOJI, LAST_EMOJI, -    LEFT_EMOJI, LinePaginator, RIGHT_EMOJI, -) +from bot.pagination import LinePaginator +log = logging.getLogger(__name__) + +COMMANDS_PER_PAGE = 8  DELETE_EMOJI = Emojis.trashcan +PREFIX = constants.Bot.prefix + +Category = namedtuple("Category", ["name", "description", "cogs"]) + + +async def help_cleanup(bot: Bot, author: Member, message: Message) -> None: +    """ +    Runs the cleanup for the help command. + +    Adds the :trashcan: reaction that, when clicked, will delete the help message. +    After a 300 second timeout, the reaction will be removed. +    """ +    def check(reaction: Reaction, user: User) -> bool: +        """Checks the reaction is :trashcan:, the author is original author and messages are the same.""" +        return str(reaction) == DELETE_EMOJI and user.id == author.id and reaction.message.id == message.id -REACTIONS = { -    FIRST_EMOJI: 'first', -    LEFT_EMOJI: 'back', -    RIGHT_EMOJI: 'next', -    LAST_EMOJI: 'end', -    DELETE_EMOJI: 'stop', -} +    await message.add_reaction(DELETE_EMOJI) -Cog = namedtuple('Cog', ['name', 'description', 'commands']) +    try: +        await bot.wait_for("reaction_add", check=check, timeout=300) +        await message.delete() +    except TimeoutError: +        await message.remove_reaction(DELETE_EMOJI, bot.user) +    except NotFound: +        pass  class HelpQueryNotFound(ValueError): @@ -46,22 +60,9 @@ class HelpQueryNotFound(ValueError):          self.possible_matches = possible_matches -class HelpSession: +class CustomHelpCommand(HelpCommand):      """ -    An interactive session for bot and command help output. - -    Expected attributes include: -        * title: str -            The title of the help message. -        * query: Union[discord.ext.commands.Bot, discord.ext.commands.Command] -        * description: str -            The description of the query. -        * pages: list[str] -            A list of the help content split into manageable pages. -        * message: `discord.Message` -            The message object that's showing the help contents. -        * destination: `discord.abc.Messageable` -            Where the help message is to be sent to. +    An interactive instance for the bot help command.      Cogs can be grouped into custom categories. All cogs with the same category will be displayed      under a single category name in the help output. Custom categories are defined inside the cogs @@ -70,499 +71,299 @@ class HelpSession:      the regular description (class docstring) of the first cog found in the category.      """ -    def __init__( -        self, -        ctx: Context, -        *command, -        cleanup: bool = False, -        only_can_run: bool = True, -        show_hidden: bool = False, -        max_lines: int = 15 -    ): -        """Creates an instance of the HelpSession class.""" -        self._ctx = ctx -        self._bot = ctx.bot -        self.title = "Command Help" - -        # set the query details for the session -        if command: -            query_str = ' '.join(command) -            self.query = self._get_query(query_str) -            self.description = self.query.description or self.query.help -        else: -            self.query = ctx.bot -            self.description = self.query.description -        self.author = ctx.author -        self.destination = ctx.channel - -        # set the config for the session -        self._cleanup = cleanup -        self._only_can_run = only_can_run -        self._show_hidden = show_hidden -        self._max_lines = max_lines - -        # init session states -        self._pages = None -        self._current_page = 0 -        self.message = None -        self._timeout_task = None -        self.reset_timeout() - -    def _get_query(self, query: str) -> Union[Command, Cog]: +    def __init__(self): +        super().__init__(command_attrs={"help": "Shows help for bot commands"}) + +    @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES) +    async def command_callback(self, ctx: Context, *, command: str = None) -> None:          """Attempts to match the provided query with a valid command or cog.""" -        command = self._bot.get_command(query) -        if command: -            return command +        # the only reason we need to tamper with this is because d.py does not support "categories", +        # so we need to deal with them ourselves. + +        bot = ctx.bot + +        if command is None: +            # quick and easy, send bot help if command is none +            mapping = self.get_bot_mapping() +            await self.send_bot_help(mapping) +            return -        # Find all cog categories that match.          cog_matches = []          description = None -        for cog in self._bot.cogs.values(): -            if hasattr(cog, "category") and cog.category == query: +        for cog in bot.cogs.values(): +            if hasattr(cog, "category") and cog.category == command:                  cog_matches.append(cog)                  if hasattr(cog, "category_description"):                      description = cog.category_description -        # Try to search by cog name if no categories match. -        if not cog_matches: -            cog = self._bot.cogs.get(query) - -            # Don't consider it a match if the cog has a category. -            if cog and not hasattr(cog, "category"): -                cog_matches = [cog] -          if cog_matches: -            cog = cog_matches[0] -            cmds = (cog.get_commands() for cog in cog_matches)  # Commands of all cogs - -            return Cog( -                name=cog.category if hasattr(cog, "category") else cog.qualified_name, -                description=description or cog.description, -                commands=tuple(itertools.chain.from_iterable(cmds))  # Flatten the list -            ) - -        self._handle_not_found(query) - -    def _handle_not_found(self, query: str) -> None: -        """ -        Handles when a query does not match a valid command or cog. - -        Will pass on possible close matches along with the `HelpQueryNotFound` exception. -        """ -        # Combine command and cog names -        choices = list(self._bot.all_commands) + list(self._bot.cogs) - -        result = process.extractBests(query, choices, scorer=fuzz.ratio, score_cutoff=90) - -        raise HelpQueryNotFound(f'Query "{query}" not found.', dict(result)) - -    async def timeout(self, seconds: int = 30) -> None: -        """Waits for a set number of seconds, then stops the help session.""" -        await asyncio.sleep(seconds) -        await self.stop() - -    def reset_timeout(self) -> None: -        """Cancels the original timeout task and sets it again from the start.""" -        # cancel original if it exists -        if self._timeout_task: -            if not self._timeout_task.cancelled(): -                self._timeout_task.cancel() - -        # recreate the timeout task -        self._timeout_task = self._bot.loop.create_task(self.timeout()) - -    async def on_reaction_add(self, reaction: Reaction, user: User) -> None: -        """Event handler for when reactions are added on the help message.""" -        # ensure it was the relevant session message -        if reaction.message.id != self.message.id: -            return - -        # ensure it was the session author who reacted -        if user.id != self.author.id: -            return - -        emoji = str(reaction.emoji) - -        # check if valid action -        if emoji not in REACTIONS: +            category = Category(name=command, description=description, cogs=cog_matches) +            await self.send_category_help(category)              return -        self.reset_timeout() - -        # Run relevant action method -        action = getattr(self, f'do_{REACTIONS[emoji]}', None) -        if action: -            await action() - -        # remove the added reaction to prep for re-use -        with suppress(HTTPException): -            await self.message.remove_reaction(reaction, user) - -    async def on_message_delete(self, message: Message) -> None: -        """Closes the help session when the help message is deleted.""" -        if message.id == self.message.id: -            await self.stop() - -    async def prepare(self) -> None: -        """Sets up the help session pages, events, message and reactions.""" -        # create paginated content -        await self.build_pages() - -        # setup listeners -        self._bot.add_listener(self.on_reaction_add) -        self._bot.add_listener(self.on_message_delete) - -        # Send the help message -        await self.update_page() -        self.add_reactions() - -    def add_reactions(self) -> None: -        """Adds the relevant reactions to the help message based on if pagination is required.""" -        # if paginating -        if len(self._pages) > 1: -            for reaction in REACTIONS: -                self._bot.loop.create_task(self.message.add_reaction(reaction)) +        # it's either a cog, group, command or subcommand; let the parent class deal with it +        await super().command_callback(ctx, command=command) -        # if single-page -        else: -            self._bot.loop.create_task(self.message.add_reaction(DELETE_EMOJI)) - -    def _category_key(self, cmd: Command) -> str: +    async def get_all_help_choices(self) -> set:          """ -        Returns a cog name of a given command for use as a key for `sorted` and `groupby`. +        Get all the possible options for getting help in the bot. -        A zero width space is used as a prefix for results with no cogs to force them last in ordering. -        """ -        if cmd.cog: -            try: -                if cmd.cog.category: -                    return f'**{cmd.cog.category}**' -            except AttributeError: -                pass - -            return f'**{cmd.cog_name}**' -        else: -            return "**\u200bNo Category:**" +        This will only display commands the author has permission to run. -    def _get_command_params(self, cmd: Command) -> str: -        """ -        Returns the command usage signature. +        These include: +        - Category names +        - Cog names +        - Group command names (and aliases) +        - Command names (and aliases) +        - Subcommand names (with parent group and aliases for subcommand, but not including aliases for group) -        This is a custom implementation of `command.signature` in order to format the command -        signature without aliases. +        Options and choices are case sensitive.          """ -        results = [] -        for name, param in cmd.clean_params.items(): - -            # if argument has a default value -            if param.default is not param.empty: - -                if isinstance(param.default, str): -                    show_default = param.default -                else: -                    show_default = param.default is not None - -                # if default is not an empty string or None -                if show_default: -                    results.append(f'[{name}={param.default}]') -                else: -                    results.append(f'[{name}]') - -            # if variable length argument -            elif param.kind == param.VAR_POSITIONAL: -                results.append(f'[{name}...]') - -            # if required +        # first get all commands including subcommands and full command name aliases +        choices = set() +        for command in await self.filter_commands(self.context.bot.walk_commands()): +            # the the command or group name +            choices.add(str(command)) + +            if isinstance(command, Command): +                # all aliases if it's just a command +                choices.update(command.aliases)              else: -                results.append(f'<{name}>') +                # otherwise we need to add the parent name in +                choices.update(f"{command.full_parent_name} {alias}" for alias in command.aliases) -        return f"{cmd.name} {' '.join(results)}" +        # all cog names +        choices.update(self.context.bot.cogs) -    async def build_pages(self) -> None: -        """Builds the list of content pages to be paginated through in the help message, as a list of str.""" -        # Use LinePaginator to restrict embed line height -        paginator = LinePaginator(prefix='', suffix='', max_lines=self._max_lines) +        # all category names +        choices.update(cog.category for cog in self.context.bot.cogs.values() if hasattr(cog, "category")) +        return choices -        prefix = constants.Bot.prefix - -        # show signature if query is a command -        if isinstance(self.query, commands.Command): -            signature = self._get_command_params(self.query) -            parent = self.query.full_parent_name + ' ' if self.query.parent else '' -            paginator.add_line(f'**```{prefix}{parent}{signature}```**') - -            # show command aliases -            aliases = ', '.join(f'`{a}`' for a in self.query.aliases) -            if aliases: -                paginator.add_line(f'**Can also use:** {aliases}\n') - -            if not await self.query.can_run(self._ctx): -                paginator.add_line('***You cannot run this command.***\n') - -        # show name if query is a cog -        if isinstance(self.query, Cog): -            paginator.add_line(f'**{self.query.name}**') +    async def command_not_found(self, string: str) -> "HelpQueryNotFound": +        """ +        Handles when a query does not match a valid command, group, cog or category. -        if self.description: -            paginator.add_line(f'*{self.description}*') +        Will return an instance of the `HelpQueryNotFound` exception with the error message and possible matches. +        """ +        choices = await self.get_all_help_choices() +        result = process.extractBests(string, choices, scorer=fuzz.ratio, score_cutoff=60) -        # list all children commands of the queried object -        if isinstance(self.query, (commands.GroupMixin, Cog)): +        return HelpQueryNotFound(f'Query "{string}" not found.', dict(result)) -            # remove hidden commands if session is not wanting hiddens -            if not self._show_hidden: -                filtered = [c for c in self.query.commands if not c.hidden] -            else: -                filtered = self.query.commands +    async def subcommand_not_found(self, command: Command, string: str) -> "HelpQueryNotFound": +        """ +        Redirects the error to `command_not_found`. -            # if after filter there are no commands, finish up -            if not filtered: -                self._pages = paginator.pages -                return +        `command_not_found` deals with searching and getting best choices for both commands and subcommands. +        """ +        return await self.command_not_found(f"{command.qualified_name} {string}") -            # set category to Commands if cog -            if isinstance(self.query, Cog): -                grouped = (('**Commands:**', self.query.commands),) +    async def send_error_message(self, error: HelpQueryNotFound) -> None: +        """Send the error message to the channel.""" +        embed = Embed(colour=Colour.red(), title=str(error)) -            # set category to Subcommands if command -            elif isinstance(self.query, commands.Command): -                grouped = (('**Subcommands:**', self.query.commands),) +        if getattr(error, "possible_matches", None): +            matches = "\n".join(f"`{match}`" for match in error.possible_matches) +            embed.description = f"**Did you mean:**\n{matches}" -                # don't show prefix for subcommands -                prefix = '' +        await self.context.send(embed=embed) -            # otherwise sort and organise all commands into categories -            else: -                cat_sort = sorted(filtered, key=self._category_key) -                grouped = itertools.groupby(cat_sort, key=self._category_key) +    async def command_formatting(self, command: Command) -> Embed: +        """ +        Takes a command and turns it into an embed. -            # process each category -            for category, cmds in grouped: -                cmds = sorted(cmds, key=lambda c: c.name) +        It will add an author, command signature + help, aliases and a note if the user can't run the command. +        """ +        embed = Embed() +        embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) -                # if there are no commands, skip category -                if len(cmds) == 0: -                    continue +        parent = command.full_parent_name -                cat_cmds = [] +        name = str(command) if not parent else f"{parent} {command.name}" +        command_details = f"**```{PREFIX}{name} {command.signature}```**\n" -                # format details for each child command -                for command in cmds: +        # show command aliases +        aliases = ", ".join(f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases) +        if aliases: +            command_details += f"**Can also use:** {aliases}\n\n" -                    # skip if hidden and hide if session is set to -                    if command.hidden and not self._show_hidden: -                        continue +        # check if the user is allowed to run this command +        if not await command.can_run(self.context): +            command_details += "***You cannot run this command.***\n\n" -                    # see if the user can run the command -                    strikeout = '' +        command_details += f"*{command.help or 'No details provided.'}*\n" +        embed.description = command_details -                    # Patch to make the !help command work outside of #bot-commands again -                    # This probably needs a proper rewrite, but this will make it work in -                    # the mean time. -                    try: -                        can_run = await command.can_run(self._ctx) -                    except CheckFailure: -                        can_run = False +        return embed -                    if not can_run: -                        # skip if we don't show commands they can't run -                        if self._only_can_run: -                            continue -                        strikeout = '~~' +    async def send_command_help(self, command: Command) -> None: +        """Send help for a single command.""" +        embed = await self.command_formatting(command) +        message = await self.context.send(embed=embed) +        await help_cleanup(self.context.bot, self.context.author, message) -                    signature = self._get_command_params(command) -                    info = f"{strikeout}**`{prefix}{signature}`**{strikeout}" +    @staticmethod +    def get_commands_brief_details(commands_: List[Command], return_as_list: bool = False) -> Union[List[str], str]: +        """ +        Formats the prefix, command name and signature, and short doc for an iterable of commands. -                    # handle if the command has no docstring -                    if command.short_doc: -                        cat_cmds.append(f'{info}\n*{command.short_doc}*') -                    else: -                        cat_cmds.append(f'{info}\n*No details provided.*') +        return_as_list is helpful for passing these command details into the paginator as a list of command details. +        """ +        details = [] +        for command in commands_: +            signature = f" {command.signature}" if command.signature else "" +            details.append( +                f"\n**`{PREFIX}{command.qualified_name}{signature}`**\n*{command.short_doc or 'No details provided'}*" +            ) +        if return_as_list: +            return details +        else: +            return "".join(details) -                # state var for if the category should be added next -                print_cat = 1 -                new_page = True +    async def send_group_help(self, group: Group) -> None: +        """Sends help for a group command.""" +        subcommands = group.commands -                for details in cat_cmds: +        if len(subcommands) == 0: +            # no subcommands, just treat it like a regular command +            await self.send_command_help(group) +            return -                    # keep details together, paginating early if it won't fit -                    lines_adding = len(details.split('\n')) + print_cat -                    if paginator._linecount + lines_adding > self._max_lines: -                        paginator._linecount = 0 -                        new_page = True -                        paginator.close_page() +        # remove commands that the user can't run and are hidden, and sort by name +        commands_ = await self.filter_commands(subcommands, sort=True) -                        # new page so print category title again -                        print_cat = 1 +        embed = await self.command_formatting(group) -                    if print_cat: -                        if new_page: -                            paginator.add_line('') -                        paginator.add_line(category) -                        print_cat = 0 +        command_details = self.get_commands_brief_details(commands_) +        if command_details: +            embed.description += f"\n**Subcommands:**\n{command_details}" -                    paginator.add_line(details) +        message = await self.context.send(embed=embed) +        await help_cleanup(self.context.bot, self.context.author, message) -        # save organised pages to session -        self._pages = paginator.pages +    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. +        commands_ = await self.filter_commands(cog.get_commands(), sort=True) -    def embed_page(self, page_number: int = 0) -> Embed: -        """Returns an Embed with the requested page formatted within."""          embed = Embed() +        embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) +        embed.description = f"**{cog.qualified_name}**\n*{cog.description}*" -        # if command or cog, add query to title for pages other than first -        if isinstance(self.query, (commands.Command, Cog)) and page_number > 0: -            title = f'Command Help | "{self.query.name}"' -        else: -            title = self.title - -        embed.set_author(name=title, icon_url=constants.Icons.questionmark) -        embed.description = self._pages[page_number] +        command_details = self.get_commands_brief_details(commands_) +        if command_details: +            embed.description += f"\n\n**Commands:**\n{command_details}" -        # add page counter to footer if paginating -        page_count = len(self._pages) -        if page_count > 1: -            embed.set_footer(text=f'Page {self._current_page+1} / {page_count}') +        message = await self.context.send(embed=embed) +        await help_cleanup(self.context.bot, self.context.author, message) -        return embed - -    async def update_page(self, page_number: int = 0) -> None: -        """Sends the intial message, or changes the existing one to the given page number.""" -        self._current_page = page_number -        embed_page = self.embed_page(page_number) +    @staticmethod +    def _category_key(command: Command) -> str: +        """ +        Returns a cog name of a given command for use as a key for `sorted` and `groupby`. -        if not self.message: -            self.message = await self.destination.send(embed=embed_page) +        A zero width space is used as a prefix for results with no cogs to force them last in ordering. +        """ +        if command.cog: +            with suppress(AttributeError): +                if command.cog.category: +                    return f"**{command.cog.category}**" +            return f"**{command.cog_name}**"          else: -            await self.message.edit(embed=embed_page) +            return "**\u200bNo Category:**" -    @classmethod -    async def start(cls, ctx: Context, *command, **options) -> "HelpSession": +    async def send_category_help(self, category: Category) -> None:          """ -        Create and begin a help session based on the given command context. - -        Available options kwargs: -            * cleanup: Optional[bool] -                Set to `True` to have the message deleted on session end. Defaults to `False`. -            * only_can_run: Optional[bool] -                Set to `True` to hide commands the user can't run. Defaults to `False`. -            * show_hidden: Optional[bool] -                Set to `True` to include hidden commands. Defaults to `False`. -            * max_lines: Optional[int] -                Sets the max number of lines the paginator will add to a single page. Defaults to 20. +        Sends help for a bot category. + +        This sends a brief help for all commands in all cogs registered to the category.          """ -        session = cls(ctx, *command, **options) -        await session.prepare() +        embed = Embed() +        embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) -        return session +        all_commands = [] +        for cog in category.cogs: +            all_commands.extend(cog.get_commands()) -    async def stop(self) -> None: -        """Stops the help session, removes event listeners and attempts to delete the help message.""" -        self._bot.remove_listener(self.on_reaction_add) -        self._bot.remove_listener(self.on_message_delete) +        filtered_commands = await self.filter_commands(all_commands, sort=True) -        # ignore if permission issue, or the message doesn't exist -        with suppress(HTTPException, AttributeError): -            if self._cleanup: -                await self.message.delete() -            else: -                await self.message.clear_reactions() +        command_detail_lines = self.get_commands_brief_details(filtered_commands, return_as_list=True) +        description = f"**{category.name}**\n*{category.description}*" -    @property -    def is_first_page(self) -> bool: -        """Check if session is currently showing the first page.""" -        return self._current_page == 0 +        if command_detail_lines: +            description += "\n\n**Commands:**" -    @property -    def is_last_page(self) -> bool: -        """Check if the session is currently showing the last page.""" -        return self._current_page == (len(self._pages)-1) +        await LinePaginator.paginate( +            command_detail_lines, +            self.context, +            embed, +            prefix=description, +            max_lines=COMMANDS_PER_PAGE, +            max_size=2040, +        ) -    async def do_first(self) -> None: -        """Event that is called when the user requests the first page.""" -        if not self.is_first_page: -            await self.update_page(0) +    async def send_bot_help(self, mapping: dict) -> None: +        """Sends help for all bot commands and cogs.""" +        bot = self.context.bot -    async def do_back(self) -> None: -        """Event that is called when the user requests the previous page.""" -        if not self.is_first_page: -            await self.update_page(self._current_page-1) - -    async def do_next(self) -> None: -        """Event that is called when the user requests the next page.""" -        if not self.is_last_page: -            await self.update_page(self._current_page+1) +        embed = Embed() +        embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) + +        filter_commands = await self.filter_commands(bot.commands, sort=True, key=self._category_key) + +        cog_or_category_pages = [] + +        for cog_or_category, _commands in itertools.groupby(filter_commands, key=self._category_key): +            sorted_commands = sorted(_commands, key=lambda c: c.name) + +            if len(sorted_commands) == 0: +                continue + +            command_detail_lines = self.get_commands_brief_details(sorted_commands, return_as_list=True) + +            # Split cogs or categories which have too many commands to fit in one page. +            # The length of commands is included for later use when aggregating into pages for the paginator. +            for index in range(0, len(sorted_commands), COMMANDS_PER_PAGE): +                truncated_lines = command_detail_lines[index:index + COMMANDS_PER_PAGE] +                joined_lines = "".join(truncated_lines) +                cog_or_category_pages.append((f"**{cog_or_category}**{joined_lines}", len(truncated_lines))) + +        pages = [] +        counter = 0 +        page = "" +        for page_details, length in cog_or_category_pages: +            counter += length +            if counter > COMMANDS_PER_PAGE: +                # force a new page on paginator even if it falls short of the max pages +                # since we still want to group categories/cogs. +                counter = length +                pages.append(page) +                page = f"{page_details}\n\n" +            else: +                page += f"{page_details}\n\n" -    async def do_end(self) -> None: -        """Event that is called when the user requests the last page.""" -        if not self.is_last_page: -            await self.update_page(len(self._pages)-1) +        if page: +            # add any remaining command help that didn't get added in the last iteration above. +            pages.append(page) -    async def do_stop(self) -> None: -        """Event that is called when the user requests to stop the help session.""" -        await self.message.delete() +        await LinePaginator.paginate(pages, self.context, embed=embed, max_lines=1, max_size=2040) -class Help(DiscordCog): +class Help(Cog):      """Custom Embed Pagination Help feature.""" -    @commands.command('help') -    @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES) -    async def new_help(self, ctx: Context, *commands) -> None: -        """Shows Command Help.""" -        try: -            await HelpSession.start(ctx, *commands) -        except HelpQueryNotFound as error: -            embed = Embed() -            embed.colour = Colour.red() -            embed.title = str(error) - -            if error.possible_matches: -                matches = '\n'.join(error.possible_matches.keys()) -                embed.description = f'**Did you mean:**\n`{matches}`' - -            await ctx.send(embed=embed) - +    def __init__(self, bot: Bot) -> None: +        self.bot = bot +        self.old_help_command = bot.help_command +        bot.help_command = CustomHelpCommand() +        bot.help_command.cog = self -def unload(bot: Bot) -> None: -    """ -    Reinstates the original help command. - -    This is run if the cog raises an exception on load, or if the extension is unloaded. -    """ -    bot.remove_command('help') -    bot.add_command(bot._old_help) +    def cog_unload(self) -> None: +        """Reset the help command when the cog is unloaded.""" +        self.bot.help_command = self.old_help_command  def setup(bot: Bot) -> None: -    """ -    The setup for the help extension. - -    This is called automatically on `bot.load_extension` being run. - -    Stores the original help command instance on the `bot._old_help` attribute for later -    reinstatement, before removing it from the command registry so the new help command can be -    loaded successfully. - -    If an exception is raised during the loading of the cog, `unload` will be called in order to -    reinstate the original help command. -    """ -    bot._old_help = bot.get_command('help') -    bot.remove_command('help') - -    try: -        bot.add_cog(Help()) -    except Exception: -        unload(bot) -        raise - - -def teardown(bot: Bot) -> None: -    """ -    The teardown for the help extension. - -    This is called automatically on `bot.unload_extension` being run. - -    Calls `unload` in order to reinstate the original help command. -    """ -    unload(bot) +    """Load the Help cog.""" +    bot.add_cog(Help(bot)) +    log.info("Cog loaded: Help") diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index e73bbdae5..1bd1f9d68 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -10,6 +10,7 @@ from datetime import datetime  from pathlib import Path  import discord +import discord.abc  from discord.ext import commands  from bot import constants @@ -21,6 +22,7 @@ log = logging.getLogger(__name__)  ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/"  MAX_CHANNELS_PER_CATEGORY = 50 +EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help,)  AVAILABLE_TOPIC = """  This channel is available. Feel free to ask a question in order to claim this channel! @@ -39,8 +41,9 @@ channels in the Help: Available category.  AVAILABLE_MSG = f"""  This help channel is now **available**, which means that you can claim it by simply typing your \  question into it. Once claimed, the channel will move into the **Python Help: Occupied** category, \ -and will be yours until it has been inactive for {constants.HelpChannels.idle_minutes} minutes. When \ -that happens, it will be set to **dormant** and moved into the **Help: Dormant** category. +and will be yours until it has been inactive for {constants.HelpChannels.idle_minutes} minutes or \ +is closed manually with `!close`. When that happens, it will be set to **dormant** and moved into \ +the **Help: Dormant** category.  You may claim a new channel once every {constants.HelpChannels.claim_minutes} minutes. If you \  currently cannot send a message in this channel, it means you are on cooldown and need to wait. @@ -62,9 +65,12 @@ through our guide for [asking a good question]({ASKING_GUIDE_URL}).  """  AVAILABLE_EMOJI = "✅" -IN_USE_EMOJI = "⌛" +IN_USE_ANSWERED_EMOJI = "⌛" +IN_USE_UNANSWERED_EMOJI = "⏳"  NAME_SEPARATOR = "|" +CoroutineFunc = t.Callable[..., t.Coroutine] +  class TaskData(t.NamedTuple):      """Data for a scheduled task.""" @@ -88,12 +94,15 @@ class HelpChannels(Scheduler, commands.Cog):          * If there are no more dormant channels, the bot will automatically create a new one          * If there are no dormant channels to move, helpers will be notified (see `notify()`)      * When a channel becomes available, the dormant embed will be edited to show `AVAILABLE_MSG` +    * User can only claim a channel at an interval `constants.HelpChannels.claim_minutes` +        * To keep track of cooldowns, user which claimed a channel will have a temporary role      In Use Category      * Contains all channels which are occupied by someone needing help      * Channel moves to dormant category after `constants.HelpChannels.idle_minutes` of being idle      * Command can prematurely mark a channel as dormant +        * Channel claimant is allowed to use the command          * Allowed roles for the command are configurable with `constants.HelpChannels.cmd_whitelist`      * When a channel becomes dormant, an embed with `DORMANT_MSG` will be sent @@ -132,7 +141,14 @@ class HelpChannels(Scheduler, commands.Cog):          self.init_task = self.bot.loop.create_task(self.init_cog())          # Stats -        self.claim_times = {} + +        # This dictionary maps a help channel to the time it was claimed +        self.claim_times: t.Dict[int, datetime] = {} + +        # This dictionary maps a help channel to whether it has had any +        # activity other than the original claimant. True being no other +        # activity and False being other activity. +        self.unanswered: t.Dict[int, bool] = {}      def cog_unload(self) -> None:          """Cancel the init task and scheduled tasks when the cog unloads.""" @@ -209,8 +225,8 @@ class HelpChannels(Scheduler, commands.Cog):          return role_check -    @commands.command(name="dormant", aliases=["close"], enabled=False) -    async def dormant_command(self, ctx: commands.Context) -> None: +    @commands.command(name="close", aliases=["dormant", "solved"], enabled=False) +    async def close_command(self, ctx: commands.Context) -> None:          """          Make the current in-use help channel dormant. @@ -218,14 +234,15 @@ class HelpChannels(Scheduler, commands.Cog):          delete the message that invoked this,          and reset the send permissions cooldown for the user who started the session.          """ -        log.trace("dormant command invoked; checking if the channel is in-use.") +        log.trace("close command invoked; checking if the channel is in-use.")          if ctx.channel.category == self.in_use_category:              if await self.dormant_check(ctx):                  with suppress(KeyError):                      del self.help_channel_claimants[ctx.channel] -                with suppress(discord.errors.HTTPException, discord.errors.NotFound): -                    await self.reset_claimant_send_permission(ctx.channel) +                await self.remove_cooldown_role(ctx.author) +                # Ignore missing task when cooldown has passed but the channel still isn't dormant. +                self.cancel_task(ctx.author.id, ignore_missing=True)                  await self.move_to_dormant(ctx.channel, "command")                  self.cancel_task(ctx.channel.id) @@ -263,19 +280,23 @@ class HelpChannels(Scheduler, commands.Cog):              log.trace(f"The clean name for `{channel}` is `{name}`")          except ValueError:              # If, for some reason, the channel name does not contain "help-" fall back gracefully -            log.info(f"Can't get clean name as `{channel}` does not follow the `{prefix}` naming convention.") +            log.info(f"Can't get clean name because `{channel}` isn't prefixed by `{prefix}`.")              name = channel.name          return name      @staticmethod -    def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: +    def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool: +        """Check if a channel should be excluded from the help channel system.""" +        return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS + +    def get_category_channels(self, category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]:          """Yield the text channels of the `category` in an unsorted manner."""          log.trace(f"Getting text channels in the category '{category}' ({category.id}).")          # This is faster than using category.channels because the latter sorts them. -        for channel in category.guild.channels: -            if channel.category_id == category.id and isinstance(channel, discord.TextChannel): +        for channel in self.bot.get_guild(constants.Guild.id).channels: +            if channel.category_id == category.id and not self.is_excluded_channel(channel):                  yield channel      @staticmethod @@ -393,7 +414,7 @@ class HelpChannels(Scheduler, commands.Cog):          # The ready event wasn't used because channels could change categories between the time          # the command is invoked and the cog is ready (e.g. if move_idle_channel wasn't called yet).          # This may confuse users. So would potentially long delays for the cog to become ready. -        self.dormant_command.enabled = True +        self.close_command.enabled = True          await self.init_available() @@ -412,6 +433,11 @@ class HelpChannels(Scheduler, commands.Cog):          self.bot.stats.gauge("help.total.available", total_available)          self.bot.stats.gauge("help.total.dormant", total_dormant) +    @staticmethod +    def is_claimant(member: discord.Member) -> bool: +        """Return True if `member` has the 'Help Cooldown' role.""" +        return any(constants.Roles.help_cooldown == role.id for role in member.roles) +      def is_dormant_message(self, message: t.Optional[discord.Message]) -> bool:          """Return True if the contents of the `message` match `DORMANT_MSG`."""          if not message or not message.embeds: @@ -459,6 +485,45 @@ class HelpChannels(Scheduler, commands.Cog):              self.schedule_task(channel.id, data) +    async def move_to_bottom_position(self, channel: discord.TextChannel, category_id: int, **options) -> None: +        """ +        Move the `channel` to the bottom position of `category` and edit channel attributes. + +        To ensure "stable sorting", we use the `bulk_channel_update` endpoint and provide the current +        positions of the other channels in the category as-is. This should make sure that the channel +        really ends up at the bottom of the category. + +        If `options` are provided, the channel will be edited after the move is completed. This is the +        same order of operations that `discord.TextChannel.edit` uses. For information on available +        options, see the documention on `discord.TextChannel.edit`. While possible, position-related +        options should be avoided, as it may interfere with the category move we perform. +        """ +        # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. +        category = await self.try_get_channel(category_id) + +        payload = [{"id": c.id, "position": c.position} for c in category.channels] + +        # Calculate the bottom position based on the current highest position in the category. If the +        # category is currently empty, we simply use the current position of the channel to avoid making +        # unnecessary changes to positions in the guild. +        bottom_position = payload[-1]["position"] + 1 if payload else channel.position + +        payload.append( +            { +                "id": channel.id, +                "position": bottom_position, +                "parent_id": category.id, +                "lock_permissions": True, +            } +        ) + +        # We use d.py's method to ensure our request is processed by d.py's rate limit manager +        await self.bot.http.bulk_channel_update(category.guild.id, payload) + +        # Now that the channel is moved, we can edit the other attributes +        if options: +            await channel.edit(**options) +      async def move_to_available(self) -> None:          """Make a channel available."""          log.trace("Making a channel available.") @@ -470,18 +535,13 @@ class HelpChannels(Scheduler, commands.Cog):          log.trace(f"Moving #{channel} ({channel.id}) to the Available category.") -        await channel.edit( +        await self.move_to_bottom_position( +            channel=channel, +            category_id=constants.Categories.help_available,              name=f"{AVAILABLE_EMOJI}{NAME_SEPARATOR}{self.get_clean_channel_name(channel)}", -            category=self.available_category, -            sync_permissions=True,              topic=AVAILABLE_TOPIC,          ) -        log.trace( -            f"Ensuring that all channels in `{self.available_category}` have " -            f"synchronized permissions after moving `{channel}` into it." -        ) -        await self.ensure_permissions_synchronization(self.available_category)          self.report_stats()      async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None: @@ -492,10 +552,10 @@ class HelpChannels(Scheduler, commands.Cog):          """          log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") -        await channel.edit( +        await self.move_to_bottom_position( +            channel=channel, +            category_id=constants.Categories.help_dormant,              name=self.get_clean_channel_name(channel), -            category=self.dormant_category, -            sync_permissions=True,              topic=DORMANT_TOPIC,          ) @@ -506,6 +566,12 @@ class HelpChannels(Scheduler, commands.Cog):              in_use_time = datetime.now() - claimed              self.bot.stats.timing("help.in_use_time", in_use_time) +        if channel.id in self.unanswered: +            if self.unanswered[channel.id]: +                self.bot.stats.incr("help.sessions.unanswered") +            else: +                self.bot.stats.incr("help.sessions.answered") +          log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.")          log.trace(f"Sending dormant message for #{channel} ({channel.id}).") @@ -520,10 +586,10 @@ class HelpChannels(Scheduler, commands.Cog):          """Make a channel in-use and schedule it to be made dormant."""          log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") -        await channel.edit( -            name=f"{IN_USE_EMOJI}{NAME_SEPARATOR}{self.get_clean_channel_name(channel)}", -            category=self.in_use_category, -            sync_permissions=True, +        await self.move_to_bottom_position( +            channel=channel, +            category_id=constants.Categories.help_in_use, +            name=f"{IN_USE_UNANSWERED_EMOJI}{NAME_SEPARATOR}{self.get_clean_channel_name(channel)}",              topic=IN_USE_TOPIC,          ) @@ -580,6 +646,28 @@ class HelpChannels(Scheduler, commands.Cog):              # Handle it here cause this feature isn't critical for the functionality of the system.              log.exception("Failed to send notification about lack of dormant channels!") +    async def check_for_answer(self, message: discord.Message) -> None: +        """Checks for whether new content in a help channel comes from non-claimants.""" +        channel = message.channel + +        # Confirm the channel is an in use help channel +        if self.is_in_category(channel, constants.Categories.help_in_use): +            log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") + +            # Check if there is an entry in unanswered (does not persist across restarts) +            if channel.id in self.unanswered: +                claimant_id = self.help_channel_claimants[channel].id + +                # Check the message did not come from the claimant +                if claimant_id != message.author.id: +                    # Mark the channel as answered +                    self.unanswered[channel.id] = False + +                    # Change the emoji in the channel name to signify activity +                    log.trace(f"#{channel} ({channel.id}) has been answered; changing its emoji") +                    name = self.get_clean_channel_name(channel) +                    await channel.edit(name=f"{IN_USE_ANSWERED_EMOJI}{NAME_SEPARATOR}{name}") +      @commands.Cog.listener()      async def on_message(self, message: discord.Message) -> None:          """Move an available channel to the In Use category and replace it with a dormant one.""" @@ -587,8 +675,11 @@ class HelpChannels(Scheduler, commands.Cog):              return  # Ignore messages sent by bots.          channel = message.channel -        if not self.is_in_category(channel, constants.Categories.help_available): -            return  # Ignore messages outside the Available category. + +        await self.check_for_answer(message) + +        if not self.is_in_category(channel, constants.Categories.help_available) or self.is_excluded_channel(channel): +            return  # Ignore messages outside the Available category or in excluded channels.          log.trace("Waiting for the cog to be ready before processing messages.")          await self.ready.wait() @@ -604,6 +695,7 @@ class HelpChannels(Scheduler, commands.Cog):                  )                  return +            log.info(f"Channel #{channel} was claimed by `{message.author.id}`.")              await self.move_to_in_use(channel)              await self.revoke_send_permissions(message.author)              # Add user with channel for dormant check. @@ -612,6 +704,7 @@ class HelpChannels(Scheduler, commands.Cog):              self.bot.stats.incr("help.claimed")              self.claim_times[channel.id] = datetime.now() +            self.unanswered[channel.id] = True              log.trace(f"Releasing on_message lock for {message.id}.") @@ -620,67 +713,49 @@ class HelpChannels(Scheduler, commands.Cog):          # be put in the queue.          await self.move_to_available() -    @staticmethod -    async def ensure_permissions_synchronization(category: discord.CategoryChannel) -> None: -        """ -        Ensure that all channels in the `category` have their permissions synchronized. - -        This method mitigates an issue we have yet to find the cause for: Every so often, a channel in the -        `Help: Available` category gets in a state in which it will no longer synchronizes its permissions -        with the category. To prevent that, we iterate over the channels in the category and edit the channels -        that are observed to be in such a state. If no "out of sync" channels are observed, this method will -        not make API calls and should be fairly inexpensive to run. -        """ -        for channel in category.channels: -            if not channel.permissions_synced: -                log.info(f"The permissions of channel `{channel}` were out of sync with category `{category}`.") -                await channel.edit(sync_permissions=True) - -    async def update_category_permissions( -        self, category: discord.CategoryChannel, member: discord.Member, **permissions -    ) -> None: -        """ -        Update the permissions of the given `member` for the given `category` with `permissions` passed. - -        After updating the permissions for the member in the category, this helper function will call the -        `ensure_permissions_synchronization` method to ensure that all channels are still synchronizing their -        permissions with the category. It's currently unknown why some channels get "out of sync", but this -        hopefully mitigates the issue. -        """ -        log.trace(f"Updating permissions for `{member}` in `{category}` with {permissions}.") -        await category.set_permissions(member, **permissions) - -        log.trace(f"Ensuring that all channels in `{category}` are synchronized after permissions update.") -        await self.ensure_permissions_synchronization(category) -      async def reset_send_permissions(self) -> None: -        """Reset send permissions for members with it set to False in the Available category.""" +        """Reset send permissions in the Available category for claimants."""          log.trace("Resetting send permissions in the Available category.") +        guild = self.bot.get_guild(constants.Guild.id) -        for member, overwrite in self.available_category.overwrites.items(): -            if isinstance(member, discord.Member) and overwrite.send_messages is False: -                log.trace(f"Resetting send permissions for {member} ({member.id}).") +        # TODO: replace with a persistent cache cause checking every member is quite slow +        for member in guild.members: +            if self.is_claimant(member): +                await self.remove_cooldown_role(member) -                # We don't use the permissions helper function here as we may have to reset multiple overwrites -                # and we don't want to enforce the permissions synchronization in each iteration. -                await self.available_category.set_permissions(member, overwrite=None) +    async def add_cooldown_role(self, member: discord.Member) -> None: +        """Add the help cooldown role to `member`.""" +        log.trace(f"Adding cooldown role for {member} ({member.id}).") +        await self._change_cooldown_role(member, member.add_roles) -        log.trace(f"Ensuring channels in `Help: Available` are synchronized after permissions reset.") -        await self.ensure_permissions_synchronization(self.available_category) +    async def remove_cooldown_role(self, member: discord.Member) -> None: +        """Remove the help cooldown role from `member`.""" +        log.trace(f"Removing cooldown role for {member} ({member.id}).") +        await self._change_cooldown_role(member, member.remove_roles) -    async def reset_claimant_send_permission(self, channel: discord.TextChannel) -> None: -        """Reset send permissions in the Available category for the help `channel` claimant.""" -        log.trace(f"Attempting to find claimant for #{channel.name} ({channel.id}).") -        try: -            member = self.help_channel_claimants[channel] -        except KeyError: -            log.trace(f"Channel #{channel.name} ({channel.id}) not in claimant cache, permissions unchanged.") +    async def _change_cooldown_role(self, member: discord.Member, coro_func: CoroutineFunc) -> None: +        """ +        Change `member`'s cooldown role via awaiting `coro_func` and handle errors. + +        `coro_func` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. +        """ +        guild = self.bot.get_guild(constants.Guild.id) +        role = guild.get_role(constants.Roles.help_cooldown) +        if role is None: +            log.warning(f"Help cooldown role ({constants.Roles.help_cooldown}) could not be found!")              return -        log.trace(f"Resetting send permissions for {member} ({member.id}).") -        await self.update_category_permissions(self.available_category, member, overwrite=None) -        # Ignore missing task when claim cooldown has passed but the channel still isn't dormant. -        self.cancel_task(member.id, ignore_missing=True) +        try: +            await coro_func(role) +        except discord.NotFound: +            log.debug(f"Failed to change role for {member} ({member.id}): member not found") +        except discord.Forbidden: +            log.debug( +                f"Forbidden to change role for {member} ({member.id}); " +                f"possibly due to role hierarchy" +            ) +        except discord.HTTPException as e: +            log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}")      async def revoke_send_permissions(self, member: discord.Member) -> None:          """ @@ -693,14 +768,14 @@ class HelpChannels(Scheduler, commands.Cog):              f"Revoking {member}'s ({member.id}) send message permissions in the Available category."          ) -        await self.update_category_permissions(self.available_category, member, send_messages=False) +        await self.add_cooldown_role(member)          # Cancel the existing task, if any.          # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner).          self.cancel_task(member.id, ignore_missing=True)          timeout = constants.HelpChannels.claim_minutes * 60 -        callback = self.update_category_permissions(self.available_category, member, overwrite=None) +        callback = self.remove_cooldown_role(member)          log.trace(f"Scheduling {member}'s ({member.id}) send message permissions to be reinstated.")          self.schedule_task(member.id, TaskData(timeout, callback)) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 7921a4932..ef2f308ca 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -12,7 +12,7 @@ from discord.utils import escape_markdown  from bot import constants  from bot.bot import Bot -from bot.decorators import InChannelCheckFailure, in_channel, with_role +from bot.decorators import InWhitelistCheckFailure, in_whitelist, with_role  from bot.pagination import LinePaginator  from bot.utils.checks import cooldown_with_role_bypass, with_role_check  from bot.utils.time import time_since @@ -152,7 +152,7 @@ class Information(Cog):          # Non-staff may only do this in #bot-commands          if not with_role_check(ctx, *constants.STAFF_ROLES):              if not ctx.channel.id == constants.Channels.bot_commands: -                raise InChannelCheckFailure(constants.Channels.bot_commands) +                raise InWhitelistCheckFailure(constants.Channels.bot_commands)          embed = await self.create_user_embed(ctx, user) @@ -206,7 +206,7 @@ class Information(Cog):              description="\n\n".join(description)          ) -        embed.set_thumbnail(url=user.avatar_url_as(format="png")) +        embed.set_thumbnail(url=user.avatar_url_as(static_format="png"))          embed.colour = user.top_role.colour if roles else Colour.blurple()          return embed @@ -331,7 +331,7 @@ class Information(Cog):      @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES)      @group(invoke_without_command=True) -    @in_channel(constants.Channels.bot_commands, bypass_roles=constants.STAFF_ROLES) +    @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_ROLES)      async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None:          """Shows information about the raw API response."""          # I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index efa19f59e..e62a36c43 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -199,7 +199,7 @@ class Infractions(InfractionScheduler, commands.Cog):      async def apply_mute(self, ctx: Context, user: Member, reason: str, **kwargs) -> None:          """Apply a mute infraction with kwargs passed to `post_infraction`.""" -        if await utils.has_active_infraction(ctx, user, "mute"): +        if await utils.get_active_infraction(ctx, user, "mute"):              return          infraction = await utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs) @@ -235,8 +235,22 @@ class Infractions(InfractionScheduler, commands.Cog):          Will also remove the banned user from the Big Brother watch list if applicable.          """ -        if await utils.has_active_infraction(ctx, user, "ban"): -            return +        # In the case of a permanent ban, we don't need get_active_infractions to tell us if one is active +        is_temporary = kwargs.get("expires_at") is not None +        active_infraction = await utils.get_active_infraction(ctx, user, "ban", is_temporary) + +        if active_infraction: +            if is_temporary: +                log.trace("Tempban ignored as it cannot overwrite an active ban.") +                return + +            if active_infraction.get('expires_at') is None: +                log.trace("Permaban already exists, notify.") +                await ctx.send(f":x: User is already permanently banned (#{active_infraction['id']}).") +                return + +            log.trace("Old tempban is being replaced by new permaban.") +            await self.pardon_infraction(ctx, "ban", user, is_temporary)          infraction = await utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs)          if infraction is None: diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 250a24247..edfdfd9e2 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -43,7 +43,7 @@ class ModManagement(commands.Cog):      @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True)      async def infraction_group(self, ctx: Context) -> None:          """Infraction manipulation commands.""" -        await ctx.invoke(self.bot.get_command("help"), "infraction") +        await ctx.send_help(ctx.command)      @infraction_group.command(name='edit')      async def infraction_edit( diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 917697be9..dc42bee2e 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -190,8 +190,19 @@ class InfractionScheduler(Scheduler):          log.info(f"Applied {infr_type} infraction #{id_} to {user}.") -    async def pardon_infraction(self, ctx: Context, infr_type: str, user: UserSnowflake) -> None: -        """Prematurely end an infraction for a user and log the action in the mod log.""" +    async def pardon_infraction( +            self, +            ctx: Context, +            infr_type: str, +            user: UserSnowflake, +            send_msg: bool = True +    ) -> None: +        """ +        Prematurely end an infraction for a user and log the action in the mod log. + +        If `send_msg` is True, then a pardoning confirmation message will be sent to +        the context channel.  Otherwise, no such message will be sent. +        """          log.trace(f"Pardoning {infr_type} infraction for {user}.")          # Check the current active infraction @@ -276,11 +287,12 @@ class InfractionScheduler(Scheduler):              log.info(f"Pardoned {infr_type} infraction #{id_} for {user}.")          # Send a confirmation message to the invoking context. -        log.trace(f"Sending infraction #{id_} pardon confirmation message.") -        await ctx.send( -            f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " -            f"{log_text.get('Failure', '')}" -        ) +        if send_msg: +            log.trace(f"Sending infraction #{id_} pardon confirmation message.") +            await ctx.send( +                f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " +                f"{log_text.get('Failure', '')}" +            )          # Send a log message to the mod log.          await self.mod_log.send_log_message( diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index ca3dc4202..29855c325 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -130,7 +130,7 @@ class Superstarify(InfractionScheduler, Cog):          An optional reason can be provided. If no reason is given, the original name will be shown          in a generated reason.          """ -        if await utils.has_active_infraction(ctx, member, "superstar"): +        if await utils.get_active_infraction(ctx, member, "superstar"):              return          # Post the infraction to the API diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 3598f3b1f..e4e0f1ec2 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -97,8 +97,19 @@ async def post_infraction(                  return -async def has_active_infraction(ctx: Context, user: UserSnowflake, infr_type: str) -> bool: -    """Checks if a user already has an active infraction of the given type.""" +async def get_active_infraction( +        ctx: Context, +        user: UserSnowflake, +        infr_type: str, +        send_msg: bool = True +) -> t.Optional[dict]: +    """ +    Retrieves an active infraction of the given type for the user. + +    If `send_msg` is True and the user has an active infraction matching the `infr_type` parameter, +    then a message for the moderator will be sent to the context channel letting them know. +    Otherwise, no message will be sent. +    """      log.trace(f"Checking if {user} has active infractions of type {infr_type}.")      active_infractions = await ctx.bot.api_client.get( @@ -110,15 +121,16 @@ async def has_active_infraction(ctx: Context, user: UserSnowflake, infr_type: st          }      )      if active_infractions: -        log.trace(f"{user} has active infractions of type {infr_type}.") -        await ctx.send( -            f":x: According to my records, this user already has a {infr_type} infraction. " -            f"See infraction **#{active_infractions[0]['id']}**." -        ) -        return True +        # Checks to see if the moderator should be told there is an active infraction +        if send_msg: +            log.trace(f"{user} has active infractions of type {infr_type}.") +            await ctx.send( +                f":x: According to my records, this user already has a {infr_type} infraction. " +                f"See infraction **#{active_infractions[0]['id']}**." +            ) +        return active_infractions[0]      else:          log.trace(f"{user} does not have active infractions of type {infr_type}.") -        return False  async def notify_infraction( diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 81511f99d..201579a0b 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -97,7 +97,7 @@ class OffTopicNames(Cog):      @with_role(*MODERATION_ROLES)      async def otname_group(self, ctx: Context) -> None:          """Add or list items from the off-topic channel name rotation.""" -        await ctx.invoke(self.bot.get_command("help"), "otname") +        await ctx.send_help(ctx.command)      @otname_group.command(name='add', aliases=('a',))      @with_role(*MODERATION_ROLES) diff --git a/bot/cogs/python_news.py b/bot/cogs/python_news.py new file mode 100644 index 000000000..57ce61638 --- /dev/null +++ b/bot/cogs/python_news.py @@ -0,0 +1,234 @@ +import logging +import typing as t +from datetime import date, datetime + +import discord +import feedparser +from bs4 import BeautifulSoup +from discord.ext.commands import Cog +from discord.ext.tasks import loop + +from bot import constants +from bot.bot import Bot + +PEPS_RSS_URL = "https://www.python.org/dev/peps/peps.rss/" + +RECENT_THREADS_TEMPLATE = "https://mail.python.org/archives/list/{name}@python.org/recent-threads" +THREAD_TEMPLATE_URL = "https://mail.python.org/archives/api/list/{name}@python.org/thread/{id}/" +MAILMAN_PROFILE_URL = "https://mail.python.org/archives/users/{id}/" +THREAD_URL = "https://mail.python.org/archives/list/{list}@python.org/thread/{id}/" + +AVATAR_URL = "https://www.python.org/static/opengraph-icon-200x200.png" + +log = logging.getLogger(__name__) + + +class PythonNews(Cog): +    """Post new PEPs and Python News to `#python-news`.""" + +    def __init__(self, bot: Bot): +        self.bot = bot +        self.webhook_names = {} +        self.webhook: t.Optional[discord.Webhook] = None + +        self.bot.loop.create_task(self.get_webhook_names()) +        self.bot.loop.create_task(self.get_webhook_and_channel()) + +    async def start_tasks(self) -> None: +        """Start the tasks for fetching new PEPs and mailing list messages.""" +        self.fetch_new_media.start() + +    @loop(minutes=20) +    async def fetch_new_media(self) -> None: +        """Fetch new mailing list messages and then new PEPs.""" +        await self.post_maillist_news() +        await self.post_pep_news() + +    async def sync_maillists(self) -> None: +        """Sync currently in-use maillists with API.""" +        # Wait until guild is available to avoid running before everything is ready +        await self.bot.wait_until_guild_available() + +        response = await self.bot.api_client.get("bot/bot-settings/news") +        for mail in constants.PythonNews.mail_lists: +            if mail not in response["data"]: +                response["data"][mail] = [] + +        # Because we are handling PEPs differently, we don't include it to mail lists +        if "pep" not in response["data"]: +            response["data"]["pep"] = [] + +        await self.bot.api_client.put("bot/bot-settings/news", json=response) + +    async def get_webhook_names(self) -> None: +        """Get webhook author names from maillist API.""" +        await self.bot.wait_until_guild_available() + +        async with self.bot.http_session.get("https://mail.python.org/archives/api/lists") as resp: +            lists = await resp.json() + +        for mail in lists: +            if mail["name"].split("@")[0] in constants.PythonNews.mail_lists: +                self.webhook_names[mail["name"].split("@")[0]] = mail["display_name"] + +    async def post_pep_news(self) -> None: +        """Fetch new PEPs and when they don't have announcement in #python-news, create it.""" +        # Wait until everything is ready and http_session available +        await self.bot.wait_until_guild_available() +        await self.sync_maillists() + +        async with self.bot.http_session.get(PEPS_RSS_URL) as resp: +            data = feedparser.parse(await resp.text("utf-8")) + +        news_listing = await self.bot.api_client.get("bot/bot-settings/news") +        payload = news_listing.copy() +        pep_numbers = news_listing["data"]["pep"] + +        # Reverse entries to send oldest first +        data["entries"].reverse() +        for new in data["entries"]: +            try: +                new_datetime = datetime.strptime(new["published"], "%a, %d %b %Y %X %Z") +            except ValueError: +                log.warning(f"Wrong datetime format passed in PEP new: {new['published']}") +                continue +            pep_nr = new["title"].split(":")[0].split()[1] +            if ( +                    pep_nr in pep_numbers +                    or new_datetime.date() < date.today() +            ): +                continue + +            msg = await self.send_webhook( +                title=new["title"], +                description=new["summary"], +                timestamp=new_datetime, +                url=new["link"], +                webhook_profile_name=data["feed"]["title"], +                footer=data["feed"]["title"] +            ) +            payload["data"]["pep"].append(pep_nr) + +            if msg.channel.is_news(): +                log.trace("Publishing PEP annnouncement because it was in a news channel") +                await msg.publish() + +        # Apply new sent news to DB to avoid duplicate sending +        await self.bot.api_client.put("bot/bot-settings/news", json=payload) + +    async def post_maillist_news(self) -> None: +        """Send new maillist threads to #python-news that is listed in configuration.""" +        await self.bot.wait_until_guild_available() +        await self.sync_maillists() +        existing_news = await self.bot.api_client.get("bot/bot-settings/news") +        payload = existing_news.copy() + +        for maillist in constants.PythonNews.mail_lists: +            async with self.bot.http_session.get(RECENT_THREADS_TEMPLATE.format(name=maillist)) as resp: +                recents = BeautifulSoup(await resp.text(), features="lxml") + +            # When a <p> element is present in the response then the mailing list +            # has not had any activity during the current month, so therefore it +            # can be ignored. +            if recents.p: +                continue + +            for thread in recents.html.body.div.find_all("a", href=True): +                # We want only these threads that have identifiers +                if "latest" in thread["href"]: +                    continue + +                thread_information, email_information = await self.get_thread_and_first_mail( +                    maillist, thread["href"].split("/")[-2] +                ) + +                try: +                    new_date = datetime.strptime(email_information["date"], "%Y-%m-%dT%X%z") +                except ValueError: +                    log.warning(f"Invalid datetime from Thread email: {email_information['date']}") +                    continue + +                if ( +                        thread_information["thread_id"] in existing_news["data"][maillist] +                        or new_date.date() < date.today() +                ): +                    continue + +                content = email_information["content"] +                link = THREAD_URL.format(id=thread["href"].split("/")[-2], list=maillist) +                msg = await self.send_webhook( +                    title=thread_information["subject"], +                    description=content[:500] + f"... [continue reading]({link})" if len(content) > 500 else content, +                    timestamp=new_date, +                    url=link, +                    author=f"{email_information['sender_name']} ({email_information['sender']['address']})", +                    author_url=MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]), +                    webhook_profile_name=self.webhook_names[maillist], +                    footer=f"Posted to {self.webhook_names[maillist]}" +                ) +                payload["data"][maillist].append(thread_information["thread_id"]) + +                if msg.channel.is_news(): +                    log.trace("Publishing mailing list message because it was in a news channel") +                    await msg.publish() + +        await self.bot.api_client.put("bot/bot-settings/news", json=payload) + +    async def send_webhook(self, +                           title: str, +                           description: str, +                           timestamp: datetime, +                           url: str, +                           webhook_profile_name: str, +                           footer: str, +                           author: t.Optional[str] = None, +                           author_url: t.Optional[str] = None, +                           ) -> discord.Message: +        """Send webhook entry and return sent message.""" +        embed = discord.Embed( +            title=title, +            description=description, +            timestamp=timestamp, +            url=url, +            colour=constants.Colours.soft_green +        ) +        if author and author_url: +            embed.set_author( +                name=author, +                url=author_url +            ) +        embed.set_footer(text=footer, icon_url=AVATAR_URL) + +        return await self.webhook.send( +            embed=embed, +            username=webhook_profile_name, +            avatar_url=AVATAR_URL, +            wait=True +        ) + +    async def get_thread_and_first_mail(self, maillist: str, thread_identifier: str) -> t.Tuple[t.Any, t.Any]: +        """Get mail thread and first mail from mail.python.org based on `maillist` and `thread_identifier`.""" +        async with self.bot.http_session.get( +                THREAD_TEMPLATE_URL.format(name=maillist, id=thread_identifier) +        ) as resp: +            thread_information = await resp.json() + +        async with self.bot.http_session.get(thread_information["starting_email"]) as resp: +            email_information = await resp.json() +        return thread_information, email_information + +    async def get_webhook_and_channel(self) -> None: +        """Storage #python-news channel Webhook and `TextChannel` to `News.webhook` and `channel`.""" +        await self.bot.wait_until_guild_available() +        self.webhook = await self.bot.fetch_webhook(constants.PythonNews.webhook) + +        await self.start_tasks() + +    def cog_unload(self) -> None: +        """Stop news posting tasks on cog unload.""" +        self.fetch_new_media.cancel() + + +def setup(bot: Bot) -> None: +    """Add `News` cog.""" +    bot.add_cog(PythonNews(bot)) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 371b65434..a300cfe0f 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -251,7 +251,7 @@ class Reddit(Cog):      @group(name="reddit", invoke_without_command=True)      async def reddit_group(self, ctx: Context) -> None:          """View the top posts from various subreddits.""" -        await ctx.invoke(self.bot.get_command("help"), "reddit") +        await ctx.send_help(ctx.command)      @reddit_group.command(name="top")      async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 24c279357..c242d2920 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -158,7 +158,7 @@ class Reminders(Scheduler, Cog):          )          await self._delete_reminder(reminder["id"]) -    @group(name="remind", aliases=("reminder", "reminders"), invoke_without_command=True) +    @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True)      async def remind_group(self, ctx: Context, expiration: Duration, *, content: str) -> None:          """Commands for managing your reminders."""          await ctx.invoke(self.new_reminder, expiration=expiration, content=content) @@ -281,7 +281,7 @@ class Reminders(Scheduler, Cog):      @remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True)      async def edit_reminder_group(self, ctx: Context) -> None:          """Commands for modifying your current reminders.""" -        await ctx.invoke(self.bot.get_command("help"), "reminders", "edit") +        await ctx.send_help(ctx.command)      @edit_reminder_group.command(name="duration", aliases=("time",))      async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: Duration) -> None: diff --git a/bot/cogs/site.py b/bot/cogs/site.py index 853e29568..7fc2a9c34 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -21,7 +21,7 @@ class Site(Cog):      @group(name="site", aliases=("s",), invoke_without_command=True)      async def site_group(self, ctx: Context) -> None:          """Commands for getting info about our website.""" -        await ctx.invoke(self.bot.get_command("help"), "site") +        await ctx.send_help(ctx.command)      @site_group.command(name="home", aliases=("about",))      async def site_main(self, ctx: Context) -> None: diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 315383b12..c2782b9c8 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -12,8 +12,8 @@ from discord import HTTPException, Message, NotFound, Reaction, User  from discord.ext.commands import Cog, Context, command, guild_only  from bot.bot import Bot -from bot.constants import Channels, Roles, URLs -from bot.decorators import in_channel +from bot.constants import Categories, Channels, Roles, URLs +from bot.decorators import in_whitelist  from bot.utils.messages import wait_for_deletion  log = logging.getLogger(__name__) @@ -38,6 +38,10 @@ RAW_CODE_REGEX = re.compile(  )  MAX_PASTE_LEN = 1000 + +# `!eval` command whitelists +EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric) +EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use)  EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners)  SIGKILL = 9 @@ -265,7 +269,7 @@ class Snekbox(Cog):      @command(name="eval", aliases=("e",))      @guild_only() -    @in_channel(Channels.bot_commands, hidden_channels=(Channels.esoteric,), bypass_roles=EVAL_ROLES) +    @in_whitelist(channels=EVAL_CHANNELS, categories=EVAL_CATEGORIES, roles=EVAL_ROLES)      async def eval_command(self, ctx: Context, *, code: str = None) -> None:          """          Run Python code and get the results. @@ -285,7 +289,7 @@ class Snekbox(Cog):              return          if not code:  # None or empty string -            await ctx.invoke(self.bot.get_command("help"), "eval") +            await ctx.send_help(ctx.command)              return          log.info(f"Received code from {ctx.author} for evaluation:\n{code}") diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 3ed471bbf..6b59d37c8 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -2,19 +2,16 @@ import difflib  import logging  import re  import unicodedata -from asyncio import TimeoutError, sleep  from email.parser import HeaderParser  from io import StringIO  from typing import Tuple, Union -from dateutil import relativedelta -from discord import Colour, Embed, Message, Role +from discord import Colour, Embed  from discord.ext.commands import BadArgument, Cog, Context, command  from bot.bot import Bot -from bot.constants import Channels, MODERATION_ROLES, Mention, STAFF_ROLES -from bot.decorators import in_channel, with_role -from bot.utils.time import humanize_delta +from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES +from bot.decorators import in_whitelist, with_role  log = logging.getLogger(__name__) @@ -58,7 +55,7 @@ class Utils(Cog):          if pep_number.isdigit():              pep_number = int(pep_number)          else: -            await ctx.invoke(self.bot.get_command("help"), "pep") +            await ctx.send_help(ctx.command)              return          # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. @@ -118,7 +115,7 @@ class Utils(Cog):          await ctx.message.channel.send(embed=pep_embed)      @command() -    @in_channel(Channels.bot_commands, bypass_roles=STAFF_ROLES) +    @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES)      async def charinfo(self, ctx: Context, *, characters: str) -> None:          """Shows you information on up to 25 unicode characters."""          match = re.match(r"<(a?):(\w+):(\d+)>", characters) @@ -162,47 +159,6 @@ class Utils(Cog):          await ctx.send(embed=embed)      @command() -    @with_role(*MODERATION_ROLES) -    async def mention(self, ctx: Context, *, role: Role) -> None: -        """Set a role to be mentionable for a limited time.""" -        if role.mentionable: -            await ctx.send(f"{role} is already mentionable!") -            return - -        await role.edit(reason=f"Role unlocked by {ctx.author}", mentionable=True) - -        human_time = humanize_delta(relativedelta.relativedelta(seconds=Mention.message_timeout)) -        await ctx.send( -            f"{role} has been made mentionable. I will reset it in {human_time}, or when someone mentions this role." -        ) - -        def check(m: Message) -> bool: -            """Checks that the message contains the role mention.""" -            return role in m.role_mentions - -        try: -            msg = await self.bot.wait_for("message", check=check, timeout=Mention.message_timeout) -        except TimeoutError: -            await role.edit(mentionable=False, reason="Automatic role lock - timeout.") -            await ctx.send(f"{ctx.author.mention}, you took too long. I have reset {role} to be unmentionable.") -            return - -        if any(r.id in MODERATION_ROLES for r in msg.author.roles): -            await sleep(Mention.reset_delay) -            await role.edit(mentionable=False, reason=f"Automatic role lock by {msg.author}") -            await ctx.send( -                f"{ctx.author.mention}, I have reset {role} to be unmentionable as " -                f"{msg.author if msg.author != ctx.author else 'you'} sent a message mentioning it." -            ) -            return - -        await role.edit(mentionable=False, reason=f"Automatic role lock - unauthorised use by {msg.author}") -        await ctx.send( -            f"{ctx.author.mention}, I have reset {role} to be unmentionable " -            f"as I detected unauthorised use by {msg.author} (ID: {msg.author.id})." -        ) - -    @command()      async def zen(self, ctx: Context, *, search_value: Union[int, str, None] = None) -> None:          """          Show the Zen of Python. diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index b0a493e68..77e8b5706 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -9,7 +9,7 @@ from discord.ext.commands import Cog, Context, command  from bot import constants  from bot.bot import Bot  from bot.cogs.moderation import ModLog -from bot.decorators import InChannelCheckFailure, in_channel, without_role +from bot.decorators import InWhitelistCheckFailure, in_whitelist, without_role  from bot.utils.checks import without_role_check  log = logging.getLogger(__name__) @@ -40,7 +40,7 @@ else:      PERIODIC_PING = (          f"@everyone To verify that you have read our rules, please type `{constants.Bot.prefix}accept`."          " If you encounter any problems during the verification process, " -        f"ping the <@&{constants.Roles.admins}> role in this channel." +        f"send a direct message to a staff member."      )  BOT_MESSAGE_DELETE_DELAY = 10 @@ -92,7 +92,6 @@ class Verification(Cog):                  text=embed_text,                  thumbnail=message.author.avatar_url_as(static_format="png"),                  channel_id=constants.Channels.mod_alerts, -                ping_everyone=constants.Filter.ping_everyone,              )          ctx: Context = await self.bot.get_context(message) @@ -122,7 +121,7 @@ class Verification(Cog):      @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True)      @without_role(constants.Roles.verified) -    @in_channel(constants.Channels.verification) +    @in_whitelist(channels=(constants.Channels.verification,))      async def accept_command(self, ctx: Context, *_) -> None:  # We don't actually care about the args          """Accept our rules and gain access to the rest of the server."""          log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.") @@ -138,7 +137,7 @@ class Verification(Cog):                  await ctx.message.delete()      @command(name='subscribe') -    @in_channel(constants.Channels.bot_commands) +    @in_whitelist(channels=(constants.Channels.bot_commands,))      async def subscribe_command(self, ctx: Context, *_) -> None:  # We don't actually care about the args          """Subscribe to announcement notifications by assigning yourself the role."""          has_role = False @@ -162,7 +161,7 @@ class Verification(Cog):          )      @command(name='unsubscribe') -    @in_channel(constants.Channels.bot_commands) +    @in_whitelist(channels=(constants.Channels.bot_commands,))      async def unsubscribe_command(self, ctx: Context, *_) -> None:  # We don't actually care about the args          """Unsubscribe from announcement notifications by removing the role from yourself."""          has_role = False @@ -187,8 +186,8 @@ class Verification(Cog):      # This cannot be static (must have a __func__ attribute).      async def cog_command_error(self, ctx: Context, error: Exception) -> None: -        """Check for & ignore any InChannelCheckFailure.""" -        if isinstance(error, InChannelCheckFailure): +        """Check for & ignore any InWhitelistCheckFailure.""" +        if isinstance(error, InWhitelistCheckFailure):              error.handled = True      @staticmethod diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 903c87f85..e4fb173e0 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -30,7 +30,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):      @with_role(*MODERATION_ROLES)      async def bigbrother_group(self, ctx: Context) -> None:          """Monitors users by relaying their messages to the Big Brother watch channel.""" -        await ctx.invoke(self.bot.get_command("help"), "bigbrother") +        await ctx.send_help(ctx.command)      @bigbrother_group.command(name='watched', aliases=('all', 'list'))      @with_role(*MODERATION_ROLES) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index ad0c51fa6..9a85c68c2 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -34,7 +34,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):      @with_role(*MODERATION_ROLES)      async def nomination_group(self, ctx: Context) -> None:          """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" -        await ctx.invoke(self.bot.get_command("help"), "talentpool") +        await ctx.send_help(ctx.command)      @nomination_group.command(name='watched', aliases=('all', 'list'))      @with_role(*MODERATION_ROLES) @@ -173,7 +173,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):      @with_role(*MODERATION_ROLES)      async def nomination_edit_group(self, ctx: Context) -> None:          """Commands to edit nominations.""" -        await ctx.invoke(self.bot.get_command("help"), "talentpool", "edit") +        await ctx.send_help(ctx.command)      @nomination_edit_group.command(name='reason')      @with_role(*MODERATION_ROLES) diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py index 5d6b4630b..e6cae3bb8 100644 --- a/bot/cogs/wolfram.py +++ b/bot/cogs/wolfram.py @@ -60,6 +60,14 @@ def custom_cooldown(*ignore: List[int]) -> Callable:      A list of roles may be provided to ignore the per-user cooldown      """      async def predicate(ctx: Context) -> bool: +        if ctx.invoked_with == 'help': +            # if the invoked command is help we don't want to increase the ratelimits since it's not actually +            # invoking the command/making a request, so instead just check if the user/guild are on cooldown. +            guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0  # if guild is on cooldown +            if not any(r.id in ignore for r in ctx.author.roles):  # check user bucket if user is not ignored +                return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0 +            return guild_cooldown +          user_bucket = usercd.get_bucket(ctx.message)          if all(role.id not in ignore for role in ctx.author.roles): diff --git a/bot/constants.py b/bot/constants.py index 2add028e7..fd280e9de 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -383,6 +383,7 @@ class Channels(metaclass=YAMLGetter):      dev_log: int      esoteric: int      helpers: int +    how_to_get_help: int      message_log: int      meta: int      mod_alerts: int @@ -421,6 +422,7 @@ class Roles(metaclass=YAMLGetter):      announcements: int      contributors: int      core_developers: int +    help_cooldown: int      helpers: int      jammers: int      moderators: int @@ -548,13 +550,6 @@ class HelpChannels(metaclass=YAMLGetter):      notify_roles: List[int] -class Mention(metaclass=YAMLGetter): -    section = 'mention' - -    message_timeout: int -    reset_delay: int - -  class RedirectOutput(metaclass=YAMLGetter):      section = 'redirect_output' @@ -569,6 +564,14 @@ class Sync(metaclass=YAMLGetter):      max_diff: int +class PythonNews(metaclass=YAMLGetter): +    section = 'python_news' + +    mail_lists: List[str] +    channel: int +    webhook: int + +  class Event(Enum):      """      Event names. This does not include every event (for example, raw diff --git a/bot/decorators.py b/bot/decorators.py index 2d18eaa6a..306f0830c 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,9 +1,9 @@  import logging  import random -from asyncio import Lock, sleep +from asyncio import Lock, create_task, sleep  from contextlib import suppress  from functools import wraps -from typing import Callable, Container, Union +from typing import Callable, Container, Optional, Union  from weakref import WeakValueDictionary  from discord import Colour, Embed, Member @@ -11,54 +11,79 @@ from discord.errors import NotFound  from discord.ext import commands  from discord.ext.commands import CheckFailure, Cog, Context -from bot.constants import ERROR_REPLIES, RedirectOutput +from bot.constants import Channels, ERROR_REPLIES, RedirectOutput  from bot.utils.checks import with_role_check, without_role_check  log = logging.getLogger(__name__) -class InChannelCheckFailure(CheckFailure): -    """Raised when a check fails for a message being sent in a whitelisted channel.""" +class InWhitelistCheckFailure(CheckFailure): +    """Raised when the `in_whitelist` check fails.""" -    def __init__(self, *channels: int): -        self.channels = channels -        channels_str = ', '.join(f"<#{c_id}>" for c_id in channels) +    def __init__(self, redirect_channel: Optional[int]) -> None: +        self.redirect_channel = redirect_channel -        super().__init__(f"Sorry, but you may only use this command within {channels_str}.") +        if redirect_channel: +            redirect_message = f" here. Please use the <#{redirect_channel}> channel instead" +        else: +            redirect_message = "" +        error_message = f"You are not allowed to use that command{redirect_message}." + +        super().__init__(error_message) + + +def in_whitelist( +    *, +    channels: Container[int] = (), +    categories: Container[int] = (), +    roles: Container[int] = (), +    redirect: Optional[int] = Channels.bot_commands, -def in_channel( -    *channels: int, -    hidden_channels: Container[int] = None, -    bypass_roles: Container[int] = None  ) -> Callable:      """ -    Checks that the message is in a whitelisted channel or optionally has a bypass role. +    Check if a command was issued in a whitelisted context. + +    The whitelists that can be provided are: + +    - `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 -    Hidden channels are channels which will not be displayed in the InChannelCheckFailure error -    message. +    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 +    told that they're not allowed to use this particular command (if `None` was passed).      """ -    hidden_channels = hidden_channels or [] -    bypass_roles = bypass_roles or [] +    if redirect and redirect not in channels: +        # It does not make sense for the channel whitelist to not contain the redirection +        # channel (if applicable). That's why we add the redirection channel to the `channels` +        # container if it's not already in it. As we allow any container type to be passed, +        # we first create a tuple in order to safely add the redirection channel. +        # +        # Note: It's possible for the redirect channel to be in a whitelisted category, but +        # there's no easy way to check that and as a channel can easily be moved in and out of +        # categories, it's probably not wise to rely on its category in any case. +        channels = tuple(channels) + (redirect,)      def predicate(ctx: Context) -> bool: -        """In-channel checker predicate.""" -        if ctx.channel.id in channels or ctx.channel.id in hidden_channels: -            log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " -                      f"The command was used in a whitelisted channel.") +        """Check if a command was issued in a whitelisted context.""" +        if channels and ctx.channel.id in channels: +            log.trace(f"{ctx.author} may use the `{ctx.command.name}` command as they are in a whitelisted channel.")              return True -        if bypass_roles: -            if any(r.id in bypass_roles for r in ctx.author.roles): -                log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " -                          f"The command was not used in a whitelisted channel, " -                          f"but the author had a role to bypass the in_channel check.") -                return True +        # Only check the category id if we have a category whitelist and the channel has a `category_id` +        if categories and hasattr(ctx.channel, "category_id") and ctx.channel.category_id in categories: +            log.trace(f"{ctx.author} may use the `{ctx.command.name}` command as they are in a whitelisted category.") +            return True -        log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " -                  f"The in_channel check failed.") +        # Only check the roles whitelist if we have one and ensure the author's roles attribute returns +        # an iterable to prevent breakage in DM channels (for if we ever decide to enable commands there). +        if roles and any(r.id in roles for r in getattr(ctx.author, "roles", ())): +            log.trace(f"{ctx.author} may use the `{ctx.command.name}` command as they have a whitelisted role.") +            return True -        raise InChannelCheckFailure(*channels) +        log.trace(f"{ctx.author} may not use the `{ctx.command.name}` command within this context.") +        raise InWhitelistCheckFailure(redirect)      return commands.check(predicate) @@ -137,13 +162,12 @@ 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}") -            await func(self, ctx, *args, **kwargs) +            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) @@ -154,6 +178,7 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non                  with suppress(NotFound):                      await ctx.message.delete()                      log.trace("Redirect output: Deleted invocation message") +          return inner      return wrap diff --git a/bot/pagination.py b/bot/pagination.py index 90c8f849c..b0c4b70e2 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -102,7 +102,7 @@ class LinePaginator(Paginator):          timeout: int = 300,          footer_text: str = None,          url: str = None, -        exception_on_empty_embed: bool = False +        exception_on_empty_embed: bool = False,      ) -> t.Optional[discord.Message]:          """          Use a paginator and set of reactions to provide pagination over a set of lines. diff --git a/bot/resources/tags/free.md b/bot/resources/tags/free.md new file mode 100644 index 000000000..582cca9da --- /dev/null +++ b/bot/resources/tags/free.md @@ -0,0 +1,5 @@ +**We have a new help channel system!** + +We recently moved to a new help channel system. You can now use any channel in the **<#691405807388196926>** category to ask your question. + +For more information, check out [our website](https://pythondiscord.com/pages/resources/guides/help-channels/). diff --git a/bot/resources/tags/mutability.md b/bot/resources/tags/mutability.md new file mode 100644 index 000000000..bde9b5e7e --- /dev/null +++ b/bot/resources/tags/mutability.md @@ -0,0 +1,37 @@ +**Mutable vs immutable objects** + +Imagine that you want to make all letters in a string upper case. Conveniently, strings have an `.upper()` method. + +You might think that this would work: +```python +>>> greeting = "hello" +>>> greeting.upper() +'HELLO' +>>> greeting +'hello' +``` + +`greeting` didn't change. Why is that so? + +That's because strings in Python are _immutable_. You can't change them, you can only pass around existing strings or create new ones. + +```python +>>> greeting = "hello" +>>> greeting = greeting.upper() +>>> greeting +'HELLO' +``` + +`greeting.upper()` creates and returns a new string which is like the old one, but with all the letters turned to upper case. + +`int`, `float`, `complex`, `tuple`, `frozenset` are other examples of immutable data types in Python. + +Mutable data types like `list`, on the other hand, can be changed in-place: +```python +>>> my_list = [1, 2, 3] +>>> my_list.append(4) +>>> my_list +[1, 2, 3, 4] +``` + +Other examples of mutable data types in Python are `dict` and `set`. Instances of user-defined classes are also mutable. diff --git a/config-default.yml b/config-default.yml index f2b0bfa9f..83ea59016 100644 --- a/config-default.yml +++ b/config-default.yml @@ -122,6 +122,7 @@ guild:      channels:          announcements:                              354619224620138496          user_event_announcements:   &USER_EVENT_A   592000283102674944 +        python_news:                &PYNEWS_CHANNEL 704372456592506880          # Development          dev_contrib:        &DEV_CONTRIB    635950537262759947 @@ -132,6 +133,9 @@ guild:          meta:               429409067623251969          python_discussion:  267624335836053506 +        # Python Help: Available +        how_to_get_help:    704250143020417084 +          # Logs          attachment_log:     &ATTACH_LOG     649243850006855680          message_log:        &MESSAGE_LOG    467752170159079424 @@ -201,6 +205,7 @@ guild:      roles:          announcements:                          463658397560995840          contributors:                           295488872404484098 +        help_cooldown:                          699189276025421825          muted:              &MUTED_ROLE         277914926603829249          partners:                               323426753857191936          python_community:   &PY_COMMUNITY_ROLE  458226413825294336 @@ -231,11 +236,12 @@ guild:          - *HELPERS_ROLE      webhooks: -        talent_pool:    569145364800602132 -        big_brother:    569133704568373283 -        reddit:         635408384794951680 -        duck_pond:      637821475327311927 -        dev_log:        680501655111729222 +        talent_pool:                    569145364800602132 +        big_brother:                    569133704568373283 +        reddit:                         635408384794951680 +        duck_pond:                      637821475327311927 +        dev_log:                        680501655111729222 +        python_news:    &PYNEWS_WEBHOOK 704381182279942324  filter: @@ -259,7 +265,8 @@ filter:      guild_invite_whitelist:          - 280033776820813825  # Functional Programming          - 267624335836053506  # Python Discord -        - 440186186024222721  # Python Discord: ModLog Emojis +        - 440186186024222721  # Python Discord: Emojis 1 +        - 578587418123304970  # Python Discord: Emojis 2          - 273944235143593984  # STEM          - 348658686962696195  # RLBot          - 531221516914917387  # Pallets @@ -276,6 +283,11 @@ filter:          - 336642139381301249  # discord.py          - 405403391410438165  # Sentdex          - 172018499005317120  # The Coding Den +        - 666560367173828639  # PyWeek +        - 702724176489873509  # Microsoft Python +        - 81384788765712384   # Discord API +        - 613425648685547541  # Discord Developers +        - 185590609631903755  # Blender Hub      domain_blacklist:          - pornhub.com @@ -503,9 +515,6 @@ free:      cooldown_rate: 1      cooldown_per: 60.0 -mention: -    message_timeout: 300 -    reset_delay: 5  help_channels:      enable: true @@ -568,5 +577,13 @@ duck_pond:          - *DUCKY_MAUL          - *DUCKY_SANTA +python_news: +    mail_lists: +        - 'python-ideas' +        - 'python-announce-list' +        - 'pypi-announce' +    channel: *PYNEWS_CHANNEL +    webhook: *PYNEWS_WEBHOOK +  config:      required_keys: ['bot.token'] diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index 39f6492cb..fdda59a8f 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -31,7 +31,7 @@ class CommandNameTests(unittest.TestCase):      def walk_modules() -> t.Iterator[ModuleType]:          """Yield imported modules from the bot.cogs subpackage."""          def on_error(name: str) -> t.NoReturn: -            raise ImportError(name=name) +            raise ImportError(name=name)  # pragma: no cover          # The mock prevents asyncio.get_event_loop() from being called.          with mock.patch("discord.ext.tasks.loop"): @@ -71,7 +71,7 @@ class CommandNameTests(unittest.TestCase):              for name in self.get_qualified_names(cmd):                  with self.subTest(cmd=func_name, name=name): -                    if name in all_names: +                    if name in all_names:  # pragma: no cover                          conflicts = ", ".join(all_names.get(name, ""))                          self.fail(                              f"Name '{name}' of the command {func_name} conflicts with {conflicts}." diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 3c26374f5..b5f928dd6 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -7,7 +7,7 @@ import discord  from bot import constants  from bot.cogs import information -from bot.decorators import InChannelCheckFailure +from bot.decorators import InWhitelistCheckFailure  from tests import helpers @@ -485,7 +485,7 @@ class UserEmbedTests(unittest.TestCase):          user.avatar_url_as.return_value = "avatar url"          embed = asyncio.run(self.cog.create_user_embed(ctx, user)) -        user.avatar_url_as.assert_called_once_with(format="png") +        user.avatar_url_as.assert_called_once_with(static_format="png")          self.assertEqual(embed.thumbnail.url, "avatar url") @@ -525,7 +525,7 @@ class UserCommandTests(unittest.TestCase):          ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=100))          msg = "Sorry, but you may only use this command within <#50>." -        with self.assertRaises(InChannelCheckFailure, msg=msg): +        with self.assertRaises(InWhitelistCheckFailure, msg=msg):              asyncio.run(self.cog.user_info.callback(self.cog, ctx))      @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 1dec0ccaf..8490b02ca 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -208,10 +208,9 @@ 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() -        ctx.invoke = AsyncMock() +        ctx = MockContext(command="sentinel")          await self.cog.eval_command.callback(self.cog, ctx=ctx, code='') -        ctx.invoke.assert_called_once_with(self.bot.get_command("help"), "eval") +        ctx.send_help.assert_called_once_with("sentinel")      async def test_send_eval(self):          """Test the send_eval function.""" diff --git a/tests/bot/test_decorators.py b/tests/bot/test_decorators.py new file mode 100644 index 000000000..a17dd3e16 --- /dev/null +++ b/tests/bot/test_decorators.py @@ -0,0 +1,147 @@ +import collections +import unittest +import unittest.mock + +from bot import constants +from bot.decorators import InWhitelistCheckFailure, in_whitelist +from tests import helpers + + +InWhitelistTestCase = collections.namedtuple("WhitelistedContextTestCase", ("kwargs", "ctx", "description")) + + +class InWhitelistTests(unittest.TestCase): +    """Tests for the `in_whitelist` check.""" + +    @classmethod +    def setUpClass(cls): +        """Set up helpers that only need to be defined once.""" +        cls.bot_commands = helpers.MockTextChannel(id=123456789, category_id=123456) +        cls.help_channel = helpers.MockTextChannel(id=987654321, category_id=987654) +        cls.non_whitelisted_channel = helpers.MockTextChannel(id=666666) +        cls.dm_channel = helpers.MockDMChannel() + +        cls.non_staff_member = helpers.MockMember() +        cls.staff_role = helpers.MockRole(id=121212) +        cls.staff_member = helpers.MockMember(roles=(cls.staff_role,)) + +        cls.channels = (cls.bot_commands.id,) +        cls.categories = (cls.help_channel.category_id,) +        cls.roles = (cls.staff_role.id,) + +    def test_predicate_returns_true_for_whitelisted_context(self): +        """The predicate should return `True` if a whitelisted context was passed to it.""" +        test_cases = ( +            InWhitelistTestCase( +                kwargs={"channels": self.channels}, +                ctx=helpers.MockContext(channel=self.bot_commands, author=self.non_staff_member), +                description="In whitelisted channels by members without whitelisted roles", +            ), +            InWhitelistTestCase( +                kwargs={"redirect": self.bot_commands.id}, +                ctx=helpers.MockContext(channel=self.bot_commands, author=self.non_staff_member), +                description="`redirect` should be implicitly added to `channels`", +            ), +            InWhitelistTestCase( +                kwargs={"categories": self.categories}, +                ctx=helpers.MockContext(channel=self.help_channel, author=self.non_staff_member), +                description="Whitelisted category without whitelisted role", +            ), +            InWhitelistTestCase( +                kwargs={"roles": self.roles}, +                ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.staff_member), +                description="Whitelisted role outside of whitelisted channel/category" +            ), +            InWhitelistTestCase( +                kwargs={ +                    "channels": self.channels, +                    "categories": self.categories, +                    "roles": self.roles, +                    "redirect": self.bot_commands, +                }, +                ctx=helpers.MockContext(channel=self.help_channel, author=self.staff_member), +                description="Case with all whitelist kwargs used", +            ), +        ) + +        for test_case in test_cases: +            # patch `commands.check` with a no-op lambda that just returns the predicate passed to it +            # so we can test the predicate that was generated from the specified kwargs. +            with unittest.mock.patch("bot.decorators.commands.check", new=lambda predicate: predicate): +                predicate = in_whitelist(**test_case.kwargs) + +            with self.subTest(test_description=test_case.description): +                self.assertTrue(predicate(test_case.ctx)) + +    def test_predicate_raises_exception_for_non_whitelisted_context(self): +        """The predicate should raise `InWhitelistCheckFailure` for a non-whitelisted context.""" +        test_cases = ( +            # Failing check with explicit `redirect` +            InWhitelistTestCase( +                kwargs={ +                    "categories": self.categories, +                    "channels": self.channels, +                    "roles": self.roles, +                    "redirect": self.bot_commands.id, +                }, +                ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.non_staff_member), +                description="Failing check with an explicit redirect channel", +            ), + +            # Failing check with implicit `redirect` +            InWhitelistTestCase( +                kwargs={ +                    "categories": self.categories, +                    "channels": self.channels, +                    "roles": self.roles, +                }, +                ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.non_staff_member), +                description="Failing check with an implicit redirect channel", +            ), + +            # Failing check without `redirect` +            InWhitelistTestCase( +                kwargs={ +                    "categories": self.categories, +                    "channels": self.channels, +                    "roles": self.roles, +                    "redirect": None, +                }, +                ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.non_staff_member), +                description="Failing check without a redirect channel", +            ), + +            # Command issued in DM channel +            InWhitelistTestCase( +                kwargs={ +                    "categories": self.categories, +                    "channels": self.channels, +                    "roles": self.roles, +                    "redirect": None, +                }, +                ctx=helpers.MockContext(channel=self.dm_channel, author=self.dm_channel.me), +                description="Commands issued in DM channel should be rejected", +            ), +        ) + +        for test_case in test_cases: +            if "redirect" not in test_case.kwargs or test_case.kwargs["redirect"] is not None: +                # There are two cases in which we have a redirect channel: +                #   1. No redirect channel was passed; the default value of `bot_commands` is used +                #   2. An explicit `redirect` is set that is "not None" +                redirect_channel = test_case.kwargs.get("redirect", constants.Channels.bot_commands) +                redirect_message = f" here. Please use the <#{redirect_channel}> channel instead" +            else: +                # If an explicit `None` was passed for `redirect`, there is no redirect channel +                redirect_message = "" + +            exception_message = f"You are not allowed to use that command{redirect_message}." + +            # patch `commands.check` with a no-op lambda that just returns the predicate passed to it +            # so we can test the predicate that was generated from the specified kwargs. +            with unittest.mock.patch("bot.decorators.commands.check", new=lambda predicate: predicate): +                predicate = in_whitelist(**test_case.kwargs) + +            with self.subTest(test_description=test_case.description): +                with self.assertRaisesRegex(InWhitelistCheckFailure, exception_message): +                    predicate(test_case.ctx) diff --git a/tests/helpers.py b/tests/helpers.py index 8e13f0f28..2b79a6c2a 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -315,7 +315,7 @@ class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin):      """      spec_set = channel_instance -    def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None: +    def __init__(self, **kwargs) -> None:          default_kwargs = {'id': next(self.discord_id), 'name': 'channel', 'guild': MockGuild()}          super().__init__(**collections.ChainMap(kwargs, default_kwargs)) @@ -323,6 +323,27 @@ class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin):              self.mention = f"#{self.name}" +# Create data for the DMChannel instance +state = unittest.mock.MagicMock() +me = unittest.mock.MagicMock() +dm_channel_data = {"id": 1, "recipients": [unittest.mock.MagicMock()]} +dm_channel_instance = discord.DMChannel(me=me, state=state, data=dm_channel_data) + + +class MockDMChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): +    """ +    A MagicMock subclass to mock TextChannel objects. + +    Instances of this class will follow the specifications of `discord.TextChannel` instances. For +    more information, see the `MockGuild` docstring. +    """ +    spec_set = dm_channel_instance + +    def __init__(self, **kwargs) -> None: +        default_kwargs = {'id': next(self.discord_id), 'recipient': MockUser(), "me": MockUser()} +        super().__init__(**collections.ChainMap(kwargs, default_kwargs)) + +  # Create a Message instance to get a realistic MagicMock of `discord.Message`  message_data = {      'id': 1, | 
