aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/constants.py1
-rw-r--r--bot/decorators.py45
-rw-r--r--bot/exts/backend/error_handler.py4
-rw-r--r--bot/exts/moderation/infraction/superstarify.py8
-rw-r--r--bot/exts/moderation/modlog.py9
-rw-r--r--bot/exts/moderation/modpings.py136
-rw-r--r--bot/exts/moderation/stream.py50
-rw-r--r--bot/exts/utils/reminders.py13
-rw-r--r--bot/exts/utils/snekbox.py10
-rw-r--r--bot/exts/utils/utils.py2
-rw-r--r--bot/resources/tags/customchecks.md21
-rw-r--r--bot/utils/checks.py8
-rw-r--r--config-default.yml6
-rw-r--r--tests/README.md2
14 files changed, 285 insertions, 30 deletions
diff --git a/bot/constants.py b/bot/constants.py
index 14400700f..7f270e5bd 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -492,6 +492,7 @@ class Roles(metaclass=YAMLGetter):
domain_leads: int
helpers: int
moderators: int
+ mod_team: int
owners: int
project_leads: int
diff --git a/bot/decorators.py b/bot/decorators.py
index 1d30317ef..e971a5bd3 100644
--- a/bot/decorators.py
+++ b/bot/decorators.py
@@ -11,7 +11,7 @@ from discord.ext.commands import Cog, Context
from bot.constants import Channels, DEBUG_MODE, RedirectOutput
from bot.utils import function
-from bot.utils.checks import in_whitelist_check
+from bot.utils.checks import ContextCheckFailure, in_whitelist_check
from bot.utils.function import command_wraps
log = logging.getLogger(__name__)
@@ -45,6 +45,49 @@ def in_whitelist(
return commands.check(predicate)
+class NotInBlacklistCheckFailure(ContextCheckFailure):
+ """Raised when the 'not_in_blacklist' check fails."""
+
+
+def not_in_blacklist(
+ *,
+ channels: t.Container[int] = (),
+ categories: t.Container[int] = (),
+ roles: t.Container[int] = (),
+ override_roles: t.Container[int] = (),
+ redirect: t.Optional[int] = Channels.bot_commands,
+ fail_silently: bool = False,
+) -> t.Callable:
+ """
+ Check if a command was not issued in a blacklisted context.
+
+ The blacklists that can be provided are:
+
+ - `channels`: a container with channel ids for blacklisted channels
+ - `categories`: a container with category ids for blacklisted categories
+ - `roles`: a container with role ids for blacklisted roles
+
+ If the command was invoked in a context that was blacklisted, 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).
+
+ The blacklist can be overridden through the roles specified in `override_roles`.
+ """
+ def predicate(ctx: Context) -> bool:
+ """Check if command was issued in a blacklisted context."""
+ not_blacklisted = not in_whitelist_check(ctx, channels, categories, roles, fail_silently=True)
+ overridden = in_whitelist_check(ctx, roles=override_roles, fail_silently=True)
+
+ success = not_blacklisted or overridden
+
+ if not success and not fail_silently:
+ raise NotInBlacklistCheckFailure(redirect)
+
+ return success
+
+ return commands.check(predicate)
+
+
def has_no_roles(*roles: t.Union[str, int]) -> t.Callable:
"""
Returns True if the user does not have any of the roles specified.
diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py
index 76ab7dfc2..da0e94a7e 100644
--- a/bot/exts/backend/error_handler.py
+++ b/bot/exts/backend/error_handler.py
@@ -12,7 +12,7 @@ from bot.bot import Bot
from bot.constants import Colours, Icons, MODERATION_ROLES
from bot.converters import TagNameConverter
from bot.errors import InvalidInfractedUser, LockedResourceError
-from bot.utils.checks import InWhitelistCheckFailure
+from bot.utils.checks import ContextCheckFailure
log = logging.getLogger(__name__)
@@ -274,7 +274,7 @@ class ErrorHandler(Cog):
await ctx.send(
"Sorry, it looks like I don't have the permissions or roles I need to do that."
)
- elif isinstance(e, (InWhitelistCheckFailure, errors.NoPrivateMessage)):
+ elif isinstance(e, (ContextCheckFailure, errors.NoPrivateMessage)):
ctx.bot.stats.incr("errors.wrong_channel_or_dm_error")
await ctx.send(e)
diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py
index 704dddf9c..07e79b9fe 100644
--- a/bot/exts/moderation/infraction/superstarify.py
+++ b/bot/exts/moderation/infraction/superstarify.py
@@ -11,7 +11,7 @@ from discord.utils import escape_markdown
from bot import constants
from bot.bot import Bot
-from bot.converters import Expiry
+from bot.converters import Duration, Expiry
from bot.exts.moderation.infraction import _utils
from bot.exts.moderation.infraction._scheduler import InfractionScheduler
from bot.utils.messages import format_user
@@ -19,6 +19,7 @@ from bot.utils.time import format_infraction
log = logging.getLogger(__name__)
NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy"
+SUPERSTARIFY_DEFAULT_DURATION = "1h"
with Path("bot/resources/stars.json").open(encoding="utf-8") as stars_file:
STAR_NAMES = json.load(stars_file)
@@ -109,7 +110,7 @@ class Superstarify(InfractionScheduler, Cog):
self,
ctx: Context,
member: Member,
- duration: Expiry,
+ duration: t.Optional[Expiry],
*,
reason: str = '',
) -> None:
@@ -134,6 +135,9 @@ class Superstarify(InfractionScheduler, Cog):
if await _utils.get_active_infraction(ctx, member, "superstar"):
return
+ # Set to default duration if none was provided.
+ duration = duration or await Duration().convert(ctx, SUPERSTARIFY_DEFAULT_DURATION)
+
# Post the infraction to the API
old_nick = member.display_name
infraction_reason = f'Old nickname: {old_nick}. {reason}'
diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py
index 2dae9d268..e92f76c9a 100644
--- a/bot/exts/moderation/modlog.py
+++ b/bot/exts/moderation/modlog.py
@@ -14,7 +14,7 @@ from discord.abc import GuildChannel
from discord.ext.commands import Cog, Context
from bot.bot import Bot
-from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs
+from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, Roles, URLs
from bot.utils.messages import format_user
from bot.utils.time import humanize_delta
@@ -115,9 +115,9 @@ class ModLog(Cog, name="ModLog"):
if ping_everyone:
if content:
- content = f"@everyone\n{content}"
+ content = f"<@&{Roles.moderators}>\n{content}"
else:
- content = "@everyone"
+ content = f"<@&{Roles.moderators}>"
# Truncate content to 2000 characters and append an ellipsis.
if content and len(content) > 2000:
@@ -127,8 +127,7 @@ class ModLog(Cog, name="ModLog"):
log_message = await channel.send(
content=content,
embed=embed,
- files=files,
- allowed_mentions=discord.AllowedMentions(everyone=True)
+ files=files
)
if additional_embeds:
diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py
new file mode 100644
index 000000000..2f180e594
--- /dev/null
+++ b/bot/exts/moderation/modpings.py
@@ -0,0 +1,136 @@
+import datetime
+import logging
+
+from async_rediscache import RedisCache
+from dateutil.parser import isoparse
+from discord import Member
+from discord.ext.commands import Cog, Context, group, has_any_role
+
+from bot.bot import Bot
+from bot.constants import Emojis, Guild, MODERATION_ROLES, Roles
+from bot.converters import Expiry
+from bot.utils.scheduling import Scheduler
+
+log = logging.getLogger(__name__)
+
+
+class ModPings(Cog):
+ """Commands for a moderator to turn moderator pings on and off."""
+
+ # RedisCache[discord.Member.id, 'Naïve ISO 8601 string']
+ # The cache's keys are mods who have pings off.
+ # The cache's values are the times when the role should be re-applied to them, stored in ISO format.
+ pings_off_mods = RedisCache()
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self._role_scheduler = Scheduler(self.__class__.__name__)
+
+ self.guild = None
+ self.moderators_role = None
+
+ self.reschedule_task = self.bot.loop.create_task(self.reschedule_roles(), name="mod-pings-reschedule")
+
+ async def reschedule_roles(self) -> None:
+ """Reschedule moderators role re-apply times."""
+ await self.bot.wait_until_guild_available()
+ self.guild = self.bot.get_guild(Guild.id)
+ self.moderators_role = self.guild.get_role(Roles.moderators)
+
+ mod_team = self.guild.get_role(Roles.mod_team)
+ pings_on = self.moderators_role.members
+ pings_off = await self.pings_off_mods.to_dict()
+
+ log.trace("Applying the moderators role to the mod team where necessary.")
+ for mod in mod_team.members:
+ if mod in pings_on: # Make sure that on-duty mods aren't in the cache.
+ if mod in pings_off:
+ await self.pings_off_mods.delete(mod.id)
+ continue
+
+ # Keep the role off only for those in the cache.
+ if mod.id not in pings_off:
+ await self.reapply_role(mod)
+ else:
+ expiry = isoparse(pings_off[mod.id]).replace(tzinfo=None)
+ self._role_scheduler.schedule_at(expiry, mod.id, self.reapply_role(mod))
+
+ async def reapply_role(self, mod: Member) -> None:
+ """Reapply the moderator's role to the given moderator."""
+ log.trace(f"Re-applying role to mod with ID {mod.id}.")
+ await mod.add_roles(self.moderators_role, reason="Pings off period expired.")
+
+ @group(name='modpings', aliases=('modping',), invoke_without_command=True)
+ @has_any_role(*MODERATION_ROLES)
+ async def modpings_group(self, ctx: Context) -> None:
+ """Allow the removal and re-addition of the pingable moderators role."""
+ await ctx.send_help(ctx.command)
+
+ @modpings_group.command(name='off')
+ @has_any_role(*MODERATION_ROLES)
+ async def off_command(self, ctx: Context, duration: Expiry) -> None:
+ """
+ Temporarily removes the pingable moderators role for a set amount of time.
+
+ A unit of time should be appended to the duration.
+ Units (∗case-sensitive):
+ \u2003`y` - years
+ \u2003`m` - months∗
+ \u2003`w` - weeks
+ \u2003`d` - days
+ \u2003`h` - hours
+ \u2003`M` - minutes∗
+ \u2003`s` - seconds
+
+ Alternatively, an ISO 8601 timestamp can be provided for the duration.
+
+ The duration cannot be longer than 30 days.
+ """
+ duration: datetime.datetime
+ delta = duration - datetime.datetime.utcnow()
+ if delta > datetime.timedelta(days=30):
+ await ctx.send(":x: Cannot remove the role for longer than 30 days.")
+ return
+
+ mod = ctx.author
+
+ until_date = duration.replace(microsecond=0).isoformat() # Looks noisy with microseconds.
+ await mod.remove_roles(self.moderators_role, reason=f"Turned pings off until {until_date}.")
+
+ await self.pings_off_mods.set(mod.id, duration.isoformat())
+
+ # Allow rescheduling the task without cancelling it separately via the `on` command.
+ if mod.id in self._role_scheduler:
+ self._role_scheduler.cancel(mod.id)
+ self._role_scheduler.schedule_at(duration, mod.id, self.reapply_role(mod))
+
+ await ctx.send(f"{Emojis.check_mark} Moderators role has been removed until {until_date}.")
+
+ @modpings_group.command(name='on')
+ @has_any_role(*MODERATION_ROLES)
+ async def on_command(self, ctx: Context) -> None:
+ """Re-apply the pingable moderators role."""
+ mod = ctx.author
+ if mod in self.moderators_role.members:
+ await ctx.send(":question: You already have the role.")
+ return
+
+ await mod.add_roles(self.moderators_role, reason="Pings off period canceled.")
+
+ await self.pings_off_mods.delete(mod.id)
+
+ # We assume the task exists. Lack of it may indicate a bug.
+ self._role_scheduler.cancel(mod.id)
+
+ await ctx.send(f"{Emojis.check_mark} Moderators role has been re-applied.")
+
+ def cog_unload(self) -> None:
+ """Cancel role tasks when the cog unloads."""
+ log.trace("Cog unload: canceling role tasks.")
+ self.reschedule_task.cancel()
+ self._role_scheduler.cancel_all()
+
+
+def setup(bot: Bot) -> None:
+ """Load the ModPings cog."""
+ bot.add_cog(ModPings(bot))
diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py
index 12e195172..1dbb2a46b 100644
--- a/bot/exts/moderation/stream.py
+++ b/bot/exts/moderation/stream.py
@@ -1,5 +1,6 @@
import logging
from datetime import timedelta, timezone
+from operator import itemgetter
import arrow
import discord
@@ -8,8 +9,9 @@ from async_rediscache import RedisCache
from discord.ext import commands
from bot.bot import Bot
-from bot.constants import Colours, Emojis, Guild, Roles, STAFF_ROLES, VideoPermission
+from bot.constants import Colours, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES, VideoPermission
from bot.converters import Expiry
+from bot.pagination import LinePaginator
from bot.utils.scheduling import Scheduler
from bot.utils.time import format_infraction_with_duration
@@ -69,7 +71,7 @@ class Stream(commands.Cog):
)
@commands.command(aliases=("streaming",))
- @commands.has_any_role(*STAFF_ROLES)
+ @commands.has_any_role(*MODERATION_ROLES)
async def stream(self, ctx: commands.Context, member: discord.Member, duration: Expiry = None) -> None:
"""
Temporarily grant streaming permissions to a member for a given duration.
@@ -126,7 +128,7 @@ class Stream(commands.Cog):
log.debug(f"Successfully gave {member} ({member.id}) permission to stream until {revoke_time}.")
@commands.command(aliases=("pstream",))
- @commands.has_any_role(*STAFF_ROLES)
+ @commands.has_any_role(*MODERATION_ROLES)
async def permanentstream(self, ctx: commands.Context, member: discord.Member) -> None:
"""Permanently grants the given member the permission to stream."""
log.trace(f"Attempting to give permanent streaming permission to {member} ({member.id}).")
@@ -153,7 +155,7 @@ class Stream(commands.Cog):
log.debug(f"Successfully gave {member} ({member.id}) permanent streaming permission.")
@commands.command(aliases=("unstream", "rstream"))
- @commands.has_any_role(*STAFF_ROLES)
+ @commands.has_any_role(*MODERATION_ROLES)
async def revokestream(self, ctx: commands.Context, member: discord.Member) -> None:
"""Revoke the permission to stream from the given member."""
log.trace(f"Attempting to remove streaming permission from {member} ({member.id}).")
@@ -173,6 +175,46 @@ class Stream(commands.Cog):
await ctx.send(f"{Emojis.cross_mark} This member doesn't have video permissions to remove!")
log.debug(f"{member} ({member.id}) didn't have the streaming permission to remove!")
+ @commands.command(aliases=('lstream',))
+ @commands.has_any_role(*MODERATION_ROLES)
+ async def liststream(self, ctx: commands.Context) -> None:
+ """Lists all non-staff users who have permission to stream."""
+ non_staff_members_with_stream = [
+ member
+ for member in ctx.guild.get_role(Roles.video).members
+ if not any(role.id in STAFF_ROLES for role in member.roles)
+ ]
+
+ # List of tuples (UtcPosixTimestamp, str)
+ # So that the list can be sorted on the UtcPosixTimestamp before the message is passed to the paginator.
+ streamer_info = []
+ for member in non_staff_members_with_stream:
+ if revoke_time := await self.task_cache.get(member.id):
+ # Member only has temporary streaming perms
+ revoke_delta = Arrow.utcfromtimestamp(revoke_time).humanize()
+ message = f"{member.mention} will have stream permissions revoked {revoke_delta}."
+ else:
+ message = f"{member.mention} has permanent streaming permissions."
+
+ # If revoke_time is None use max timestamp to force sort to put them at the end
+ streamer_info.append(
+ (revoke_time or Arrow.max.timestamp(), message)
+ )
+
+ if streamer_info:
+ # Sort based on duration left of streaming perms
+ streamer_info.sort(key=itemgetter(0))
+
+ # Only output the message in the pagination
+ lines = [line[1] for line in streamer_info]
+ embed = discord.Embed(
+ title=f"Members with streaming permission (`{len(lines)}` total)",
+ colour=Colours.soft_green
+ )
+ await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False)
+ else:
+ await ctx.send("No members with stream permissions found.")
+
def setup(bot: Bot) -> None:
"""Loads the Stream cog."""
diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py
index 3113a1149..6c21920a1 100644
--- a/bot/exts/utils/reminders.py
+++ b/bot/exts/utils/reminders.py
@@ -90,15 +90,18 @@ class Reminders(Cog):
delivery_dt: t.Optional[datetime],
) -> None:
"""Send an embed confirming the reminder change was made successfully."""
- embed = discord.Embed()
- embed.colour = discord.Colour.green()
- embed.title = random.choice(POSITIVE_REPLIES)
- embed.description = on_success
+ embed = discord.Embed(
+ description=on_success,
+ colour=discord.Colour.green(),
+ title=random.choice(POSITIVE_REPLIES)
+ )
footer_str = f"ID: {reminder_id}"
+
if delivery_dt:
# Reminder deletion will have a `None` `delivery_dt`
- footer_str = f"{footer_str}, Due: {delivery_dt.strftime('%Y-%m-%dT%H:%M:%S')}"
+ footer_str += ', Due'
+ embed.timestamp = delivery_dt
embed.set_footer(text=footer_str)
diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py
index 9f480c067..da95240bb 100644
--- a/bot/exts/utils/snekbox.py
+++ b/bot/exts/utils/snekbox.py
@@ -13,7 +13,7 @@ from discord.ext.commands import Cog, Context, command, guild_only
from bot.bot import Bot
from bot.constants import Categories, Channels, Roles, URLs
-from bot.decorators import in_whitelist
+from bot.decorators import not_in_blacklist
from bot.utils import send_to_paste_service
from bot.utils.messages import wait_for_deletion
@@ -38,9 +38,9 @@ RAW_CODE_REGEX = re.compile(
MAX_PASTE_LEN = 10000
-# `!eval` command whitelists
-EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric)
-EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use, Categories.voice)
+# `!eval` command whitelists and blacklists.
+NO_EVAL_CHANNELS = (Channels.python_general,)
+NO_EVAL_CATEGORIES = ()
EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners)
SIGKILL = 9
@@ -280,7 +280,7 @@ class Snekbox(Cog):
@command(name="eval", aliases=("e",))
@guild_only()
- @in_whitelist(channels=EVAL_CHANNELS, categories=EVAL_CATEGORIES, roles=EVAL_ROLES)
+ @not_in_blacklist(channels=NO_EVAL_CHANNELS, categories=NO_EVAL_CATEGORIES, override_roles=EVAL_ROLES)
async def eval_command(self, ctx: Context, *, code: str = None) -> None:
"""
Run Python code and get the results.
diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py
index 8d9d27c64..4c39a7c2a 100644
--- a/bot/exts/utils/utils.py
+++ b/bot/exts/utils/utils.py
@@ -109,7 +109,7 @@ class Utils(Cog):
# handle if it's an index int
if isinstance(search_value, int):
upper_bound = len(zen_lines) - 1
- lower_bound = -1 * upper_bound
+ lower_bound = -1 * len(zen_lines)
if not (lower_bound <= search_value <= upper_bound):
raise BadArgument(f"Please provide an index between {lower_bound} and {upper_bound}.")
diff --git a/bot/resources/tags/customchecks.md b/bot/resources/tags/customchecks.md
new file mode 100644
index 000000000..23ff7a66f
--- /dev/null
+++ b/bot/resources/tags/customchecks.md
@@ -0,0 +1,21 @@
+**Custom Command Checks in discord.py**
+
+Often you may find the need to use checks that don't exist by default in discord.py. Fortunately, discord.py provides `discord.ext.commands.check` which allows you to create you own checks like this:
+```py
+from discord.ext.commands import check, Context
+
+def in_any_channel(*channels):
+ async def predicate(ctx: Context):
+ return ctx.channel.id in channels
+ return check(predicate)
+```
+This check is to check whether the invoked command is in a given set of channels. The inner function, named `predicate` here, is used to perform the actual check on the command, and check logic should go in this function. It must be an async function, and always provides a single `commands.Context` argument which you can use to create check logic. This check function should return a boolean value indicating whether the check passed (return `True`) or failed (return `False`).
+
+The check can now be used like any other commands check as a decorator of a command, such as this:
+```py
[email protected](name="ping")
+@in_any_channel(728343273562701984)
+async def ping(ctx: Context):
+ ...
+```
+This would lock the `ping` command to only be used in the channel `728343273562701984`. If this check function fails it will raise a `CheckFailure` exception, which can be handled in your error handler.
diff --git a/bot/utils/checks.py b/bot/utils/checks.py
index 460a937d8..3d0c8a50c 100644
--- a/bot/utils/checks.py
+++ b/bot/utils/checks.py
@@ -20,8 +20,8 @@ from bot import constants
log = logging.getLogger(__name__)
-class InWhitelistCheckFailure(CheckFailure):
- """Raised when the `in_whitelist` check fails."""
+class ContextCheckFailure(CheckFailure):
+ """Raised when a context-specific check fails."""
def __init__(self, redirect_channel: Optional[int]) -> None:
self.redirect_channel = redirect_channel
@@ -36,6 +36,10 @@ class InWhitelistCheckFailure(CheckFailure):
super().__init__(error_message)
+class InWhitelistCheckFailure(ContextCheckFailure):
+ """Raised when the `in_whitelist` check fails."""
+
+
def in_whitelist_check(
ctx: Context,
channels: Container[int] = (),
diff --git a/config-default.yml b/config-default.yml
index b9786925d..2830fd109 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -261,7 +261,8 @@ guild:
devops: 409416496733880320
domain_leads: 807415650778742785
helpers: &HELPERS_ROLE 267630620367257601
- moderators: &MODS_ROLE 267629731250176001
+ moderators: &MODS_ROLE 831776746206265384
+ mod_team: &MOD_TEAM_ROLE 267629731250176001
owners: &OWNERS_ROLE 267627879762755584
project_leads: 815701647526330398
@@ -274,13 +275,14 @@ guild:
moderation_roles:
- *ADMINS_ROLE
+ - *MOD_TEAM_ROLE
- *MODS_ROLE
- *OWNERS_ROLE
staff_roles:
- *ADMINS_ROLE
- *HELPERS_ROLE
- - *MODS_ROLE
+ - *MOD_TEAM_ROLE
- *OWNERS_ROLE
webhooks:
diff --git a/tests/README.md b/tests/README.md
index 4f62edd68..092324123 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -114,7 +114,7 @@ class BotCogTests(unittest.TestCase):
### Mocking coroutines
-By default, the `unittest.mock.Mock` and `unittest.mock.MagicMock` classes cannot mock coroutines, since the `__call__` method they provide is synchronous. In anticipation of the `AsyncMock` that will be [introduced in Python 3.8](https://docs.python.org/3.9/whatsnew/3.8.html#unittest), we have added an `AsyncMock` helper to [`helpers.py`](/tests/helpers.py). Do note that this drop-in replacement only implements an asynchronous `__call__` method, not the additional assertions that will come with the new `AsyncMock` type in Python 3.8.
+By default, the `unittest.mock.Mock` and `unittest.mock.MagicMock` classes cannot mock coroutines, since the `__call__` method they provide is synchronous. The [`AsyncMock`](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.AsyncMock) that has been [introduced in Python 3.8](https://docs.python.org/3.9/whatsnew/3.8.html#unittest) is an asynchronous version of `MagicMock` that can be used anywhere a coroutine is expected.
### Special mocks for some `discord.py` types