aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Chris Lovering <[email protected]>2021-08-30 21:08:59 +0100
committerGravatar Chris Lovering <[email protected]>2021-08-30 21:08:59 +0100
commit2731acd6c0e83c811fa89d79f1b772e9f2ed0503 (patch)
tree425a7e5770e754325cc9d6039777c03ab989e1ee
parentSwitched back to default value getting. (diff)
parentRemove the json argument from the raw command. (#1792) (diff)
Merge branch 'main' into ks123/goodbye-talentpool-channel
-rw-r--r--.coveragerc5
-rw-r--r--.github/workflows/lint-test.yml6
-rw-r--r--Dockerfile2
-rw-r--r--bot/constants.py26
-rw-r--r--bot/converters.py219
-rw-r--r--bot/errors.py25
-rw-r--r--bot/exts/backend/branding/_cog.py6
-rw-r--r--bot/exts/backend/error_handler.py31
-rw-r--r--bot/exts/events/__init__.py0
-rw-r--r--bot/exts/events/code_jams/__init__.py8
-rw-r--r--bot/exts/events/code_jams/_channels.py113
-rw-r--r--bot/exts/events/code_jams/_cog.py235
-rw-r--r--bot/exts/filters/antimalware.py16
-rw-r--r--bot/exts/filters/antispam.py88
-rw-r--r--bot/exts/filters/filtering.py35
-rw-r--r--bot/exts/filters/pixels_token_remover.py108
-rw-r--r--bot/exts/filters/webhook_remover.py23
-rw-r--r--bot/exts/fun/duck_pond.py13
-rw-r--r--bot/exts/help_channels/_caches.py9
-rw-r--r--bot/exts/help_channels/_cog.py108
-rw-r--r--bot/exts/help_channels/_message.py44
-rw-r--r--bot/exts/info/code_snippets.py103
-rw-r--r--bot/exts/info/codeblock/_cog.py17
-rw-r--r--bot/exts/info/doc/_cog.py11
-rw-r--r--bot/exts/info/doc/_parsing.py2
-rw-r--r--bot/exts/info/help.py21
-rw-r--r--bot/exts/info/information.py125
-rw-r--r--bot/exts/info/pep.py2
-rw-r--r--bot/exts/info/pypi.py15
-rw-r--r--bot/exts/info/python_news.py12
-rw-r--r--bot/exts/info/site.py14
-rw-r--r--bot/exts/info/source.py35
-rw-r--r--bot/exts/moderation/defcon.py14
-rw-r--r--bot/exts/moderation/incidents.py24
-rw-r--r--bot/exts/moderation/infraction/_scheduler.py77
-rw-r--r--bot/exts/moderation/infraction/_utils.py43
-rw-r--r--bot/exts/moderation/infraction/infractions.py114
-rw-r--r--bot/exts/moderation/infraction/management.py45
-rw-r--r--bot/exts/moderation/infraction/superstarify.py25
-rw-r--r--bot/exts/moderation/metabase.py109
-rw-r--r--bot/exts/moderation/modlog.py4
-rw-r--r--bot/exts/moderation/modpings.py4
-rw-r--r--bot/exts/moderation/silence.py327
-rw-r--r--bot/exts/moderation/stream.py26
-rw-r--r--bot/exts/moderation/voice_gate.py47
-rw-r--r--bot/exts/moderation/watchchannels/_watchchannel.py2
-rw-r--r--bot/exts/moderation/watchchannels/bigbrother.py15
-rw-r--r--bot/exts/recruitment/talentpool/_cog.py96
-rw-r--r--bot/exts/recruitment/talentpool/_review.py57
-rw-r--r--bot/exts/utils/bot.py2
-rw-r--r--bot/exts/utils/extensions.py41
-rw-r--r--bot/exts/utils/internal.py7
-rw-r--r--bot/exts/utils/jams.py145
-rw-r--r--bot/exts/utils/ping.py30
-rw-r--r--bot/exts/utils/reminders.py151
-rw-r--r--bot/exts/utils/utils.py21
-rw-r--r--bot/pagination.py37
-rw-r--r--bot/resources/tags/blocking.md5
-rw-r--r--bot/resources/tags/bot_var.md23
-rw-r--r--bot/resources/tags/docstring.md18
-rw-r--r--bot/resources/tags/dunder-methods.md28
-rw-r--r--bot/resources/tags/for-else.md17
-rw-r--r--bot/resources/tags/modmail.md2
-rw-r--r--bot/resources/tags/venv.md20
-rw-r--r--bot/rules/mentions.py6
-rw-r--r--bot/utils/caching.py (renamed from bot/utils/cache.py)0
-rw-r--r--bot/utils/message_cache.py197
-rw-r--r--bot/utils/messages.py31
-rw-r--r--bot/utils/scheduling.py19
-rw-r--r--bot/utils/time.py85
-rw-r--r--config-default.yml46
-rw-r--r--docker-compose.yml4
-rw-r--r--poetry.lock641
-rw-r--r--pyproject.toml18
-rw-r--r--tests/README.md40
-rw-r--r--tests/bot/exts/backend/test_error_handler.py88
-rw-r--r--tests/bot/exts/events/__init__.py0
-rw-r--r--tests/bot/exts/events/test_code_jams.py170
-rw-r--r--tests/bot/exts/filters/test_antimalware.py45
-rw-r--r--tests/bot/exts/info/test_help.py23
-rw-r--r--tests/bot/exts/info/test_information.py13
-rw-r--r--tests/bot/exts/moderation/infraction/test_infractions.py6
-rw-r--r--tests/bot/exts/moderation/infraction/test_utils.py6
-rw-r--r--tests/bot/exts/moderation/test_modlog.py2
-rw-r--r--tests/bot/exts/moderation/test_silence.py600
-rw-r--r--tests/bot/exts/utils/test_jams.py171
-rw-r--r--tests/bot/rules/test_mentions.py26
-rw-r--r--tests/bot/test_converters.py40
-rw-r--r--tests/bot/utils/test_message_cache.py214
-rw-r--r--tests/bot/utils/test_time.py73
-rw-r--r--tests/helpers.py47
91 files changed, 3918 insertions, 1746 deletions
diff --git a/.coveragerc b/.coveragerc
deleted file mode 100644
index d572bd705..000000000
--- a/.coveragerc
+++ /dev/null
@@ -1,5 +0,0 @@
-[run]
-branch = true
-source =
- bot
- tests
diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml
index d96f324ec..e99e6d181 100644
--- a/.github/workflows/lint-test.yml
+++ b/.github/workflows/lint-test.yml
@@ -97,12 +97,8 @@ jobs:
--format='::error file=%(path)s,line=%(row)d,col=%(col)d::\
[flake8] %(code)s: %(text)s'"
- # We run `coverage` using the `python` command so we can suppress
- # irrelevant warnings in our CI output.
- name: Run tests and generate coverage report
- run: |
- python -Wignore -m coverage run -m unittest
- coverage report -m
+ run: pytest -n auto --cov --disable-warnings -q
# This step will publish the coverage reports coveralls.io and
# print a "job" link in the output of the GitHub Action
diff --git a/Dockerfile b/Dockerfile
index c285898dc..4d8592590 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.9.5-slim
+FROM python:3.9-slim
# Set pip to have no saved cache
ENV PIP_NO_CACHE_DIR=false \
diff --git a/bot/constants.py b/bot/constants.py
index 7616ea4df..f99913b17 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -400,6 +400,8 @@ class Categories(metaclass=YAMLGetter):
modmail: int
voice: int
+ # 2021 Summer Code Jam
+ summer_code_jam: int
class Channels(metaclass=YAMLGetter):
section = "guild"
@@ -433,10 +435,13 @@ class Channels(metaclass=YAMLGetter):
off_topic_1: int
off_topic_2: int
+ black_formatter: int
+
bot_commands: int
discord_py: int
esoteric: int
voice_gate: int
+ code_jam_planning: int
admins: int
admin_spam: int
@@ -454,15 +459,17 @@ class Channels(metaclass=YAMLGetter):
staff_announcements: int
admins_voice: int
+ code_help_voice_0: int
code_help_voice_1: int
- code_help_voice_2: int
- general_voice: int
+ general_voice_0: int
+ general_voice_1: int
staff_voice: int
+ code_help_chat_0: int
code_help_chat_1: int
- code_help_chat_2: int
staff_voice_chat: int
- voice_chat: int
+ voice_chat_0: int
+ voice_chat_1: int
big_brother_logs: int
@@ -493,8 +500,10 @@ class Roles(metaclass=YAMLGetter):
admins: int
core_developers: int
+ code_jam_event_team: int
devops: int
domain_leads: int
+ events_lead: int
helpers: int
moderators: int
mod_team: int
@@ -502,7 +511,6 @@ class Roles(metaclass=YAMLGetter):
project_leads: int
jammers: int
- team_leaders: int
class Guild(metaclass=YAMLGetter):
@@ -558,13 +566,16 @@ class Metabase(metaclass=YAMLGetter):
username: Optional[str]
password: Optional[str]
- url: str
+ base_url: str
+ public_url: str
max_session_age: int
class AntiSpam(metaclass=YAMLGetter):
section = 'anti_spam'
+ cache_size: int
+
clean_offending: bool
ping_everyone: bool
@@ -677,7 +688,7 @@ class VideoPermission(metaclass=YAMLGetter):
# Debug mode
-DEBUG_MODE = 'local' in os.environ.get("SITE_URL", "local")
+DEBUG_MODE: bool = _CONFIG_YAML["debug"] == "true"
# Paths
BOT_DIR = os.path.dirname(__file__)
@@ -686,6 +697,7 @@ PROJECT_ROOT = os.path.abspath(os.path.join(BOT_DIR, os.pardir))
# Default role combinations
MODERATION_ROLES = Guild.moderation_roles
STAFF_ROLES = Guild.staff_roles
+STAFF_PARTNERS_COMMUNITY_ROLES = STAFF_ROLES + [Roles.partners, Roles.python_community]
# Channel combinations
MODERATION_CHANNELS = Guild.moderation_channels
diff --git a/bot/converters.py b/bot/converters.py
index 2a3943831..bd4044c7e 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -1,8 +1,9 @@
+from __future__ import annotations
+
import logging
import re
import typing as t
from datetime import datetime
-from functools import partial
from ssl import CertificateError
import dateutil.parser
@@ -10,14 +11,18 @@ import dateutil.tz
import discord
from aiohttp import ClientConnectorError
from dateutil.relativedelta import relativedelta
-from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, UserConverter
-from discord.utils import DISCORD_EPOCH, snowflake_time
+from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, MemberConverter, UserConverter
+from discord.utils import DISCORD_EPOCH, escape_markdown, snowflake_time
+from bot import exts
from bot.api import ResponseCodeError
from bot.constants import URLs
from bot.exts.info.doc import _inventory_parser
+from bot.utils.extensions import EXTENSIONS, unqualify
from bot.utils.regex import INVITE_RE
from bot.utils.time import parse_duration_string
+if t.TYPE_CHECKING:
+ from bot.exts.info.source import SourceType
log = logging.getLogger(__name__)
@@ -128,6 +133,44 @@ class ValidFilterListType(Converter):
return list_type
+class Extension(Converter):
+ """
+ Fully qualify the name of an extension and ensure it exists.
+
+ The * and ** values bypass this when used with the reload command.
+ """
+
+ async def convert(self, ctx: Context, argument: str) -> str:
+ """Fully qualify the name of an extension and ensure it exists."""
+ # Special values to reload all extensions
+ if argument == "*" or argument == "**":
+ return argument
+
+ argument = argument.lower()
+
+ if argument in EXTENSIONS:
+ return argument
+ elif (qualified_arg := f"{exts.__name__}.{argument}") in EXTENSIONS:
+ return qualified_arg
+
+ matches = []
+ for ext in EXTENSIONS:
+ if argument == unqualify(ext):
+ matches.append(ext)
+
+ if len(matches) > 1:
+ matches.sort()
+ names = "\n".join(matches)
+ raise BadArgument(
+ f":x: `{argument}` is an ambiguous extension name. "
+ f"Please use one of the following fully-qualified names.```\n{names}```"
+ )
+ elif matches:
+ return matches[0]
+ else:
+ raise BadArgument(f":x: Could not find the extension `{argument}`.")
+
+
class PackageName(Converter):
"""
A converter that checks whether the given string is a valid package name.
@@ -271,23 +314,36 @@ class TagNameConverter(Converter):
return tag_name
-class TagContentConverter(Converter):
- """Ensure proposed tag content is not empty and contains at least one non-whitespace character."""
+class SourceConverter(Converter):
+ """Convert an argument into a help command, tag, command, or cog."""
@staticmethod
- async def convert(ctx: Context, tag_content: str) -> str:
- """
- Ensure tag_content is non-empty and contains at least one non-whitespace character.
+ async def convert(ctx: Context, argument: str) -> SourceType:
+ """Convert argument into source object."""
+ if argument.lower() == "help":
+ return ctx.bot.help_command
- If tag_content is valid, return the stripped version.
- """
- tag_content = tag_content.strip()
+ cog = ctx.bot.get_cog(argument)
+ if cog:
+ return cog
+
+ cmd = ctx.bot.get_command(argument)
+ if cmd:
+ return cmd
- # The tag contents should not be empty, or filled with whitespace.
- if not tag_content:
- raise BadArgument("Tag contents should not be empty, or filled with whitespace.")
+ tags_cog = ctx.bot.get_cog("Tags")
+ show_tag = True
- return tag_content
+ if not tags_cog:
+ show_tag = False
+ elif argument.lower() in tags_cog._cache:
+ return argument.lower()
+
+ escaped_arg = escape_markdown(argument)
+
+ raise BadArgument(
+ f"Unable to convert '{escaped_arg}' to valid command{', tag,' if show_tag else ''} or Cog."
+ )
class DurationDelta(Converter):
@@ -416,11 +472,11 @@ class HushDurationConverter(Converter):
MINUTES_RE = re.compile(r"(\d+)(?:M|m|$)")
- async def convert(self, ctx: Context, argument: str) -> t.Optional[int]:
+ async def convert(self, ctx: Context, argument: str) -> int:
"""
Convert `argument` to a duration that's max 15 minutes or None.
- If `"forever"` is passed, None is returned; otherwise an int of the extracted time.
+ If `"forever"` is passed, -1 is returned; otherwise an int of the extracted time.
Accepted formats are:
* <duration>,
* <duration>m,
@@ -428,7 +484,7 @@ class HushDurationConverter(Converter):
* forever.
"""
if argument == "forever":
- return None
+ return -1
match = self.MINUTES_RE.match(argument)
if not match:
raise BadArgument(f"{argument} is not a valid minutes duration.")
@@ -439,103 +495,51 @@ class HushDurationConverter(Converter):
return duration
-def proxy_user(user_id: str) -> discord.Object:
- """
- Create a proxy user object from the given id.
+def _is_an_unambiguous_user_argument(argument: str) -> bool:
+ """Check if the provided argument is a user mention, user id, or username (name#discrim)."""
+ has_id_or_mention = bool(IDConverter()._get_id_match(argument) or RE_USER_MENTION.match(argument))
- Used when a Member or User object cannot be resolved.
- """
- log.trace(f"Attempting to create a proxy user for the user id {user_id}.")
+ # Check to see if the author passed a username (a discriminator exists)
+ argument = argument.removeprefix('@')
+ has_username = len(argument) > 5 and argument[-5] == '#'
- try:
- user_id = int(user_id)
- except ValueError:
- log.debug(f"Failed to create proxy user {user_id}: could not convert to int.")
- raise BadArgument(f"User ID `{user_id}` is invalid - could not convert to an integer.")
+ return has_id_or_mention or has_username
- user = discord.Object(user_id)
- user.mention = user.id
- user.display_name = f"<@{user.id}>"
- user.avatar_url_as = lambda static_format: None
- user.bot = False
- return user
+AMBIGUOUS_ARGUMENT_MSG = ("`{argument}` is not a User mention, a User ID or a Username in the format"
+ " `name#discriminator`.")
-class UserMentionOrID(UserConverter):
+class UnambiguousUser(UserConverter):
"""
- Converts to a `discord.User`, but only if a mention or userID is provided.
+ Converts to a `discord.User`, but only if a mention, userID or a username (name#discrim) is provided.
- Unlike the default `UserConverter`, it doesn't allow conversion from a name or name#descrim.
- This is useful in cases where that lookup strategy would lead to ambiguity.
+ Unlike the default `UserConverter`, it doesn't allow conversion from a name.
+ This is useful in cases where that lookup strategy would lead to too much ambiguity.
"""
async def convert(self, ctx: Context, argument: str) -> discord.User:
- """Convert the `arg` to a `discord.User`."""
- match = self._get_id_match(argument) or RE_USER_MENTION.match(argument)
-
- if match is not None:
+ """Convert the `argument` to a `discord.User`."""
+ if _is_an_unambiguous_user_argument(argument):
return await super().convert(ctx, argument)
else:
- raise BadArgument(f"`{argument}` is not a User mention or a User ID.")
-
-
-class FetchedUser(UserConverter):
- """
- Converts to a `discord.User` or, if it fails, a `discord.Object`.
-
- Unlike the default `UserConverter`, which only does lookups via the global user cache, this
- converter attempts to fetch the user via an API call to Discord when the using the cache is
- unsuccessful.
-
- If the fetch also fails and the error doesn't imply the user doesn't exist, then a
- `discord.Object` is returned via the `user_proxy` converter.
-
- The lookup strategy is as follows (in order):
-
- 1. Lookup by ID.
- 2. Lookup by mention.
- 3. Lookup by name#discrim
- 4. Lookup by name
- 5. Lookup via API
- 6. Create a proxy user with discord.Object
- """
+ raise BadArgument(AMBIGUOUS_ARGUMENT_MSG.format(argument=argument))
- async def convert(self, ctx: Context, arg: str) -> t.Union[discord.User, discord.Object]:
- """Convert the `arg` to a `discord.User` or `discord.Object`."""
- try:
- return await super().convert(ctx, arg)
- except BadArgument:
- pass
-
- try:
- user_id = int(arg)
- log.trace(f"Fetching user {user_id}...")
- return await ctx.bot.fetch_user(user_id)
- except ValueError:
- log.debug(f"Failed to fetch user {arg}: could not convert to int.")
- raise BadArgument(f"The provided argument can't be turned into integer: `{arg}`")
- except discord.HTTPException as e:
- # If the Discord error isn't `Unknown user`, return a proxy instead
- if e.code != 10013:
- log.info(f"Failed to fetch user, returning a proxy instead: status {e.status}")
- return proxy_user(arg)
-
- log.debug(f"Failed to fetch user {arg}: user does not exist.")
- raise BadArgument(f"User `{arg}` does not exist")
-
-def _snowflake_from_regex(pattern: t.Pattern, arg: str) -> int:
+class UnambiguousMember(MemberConverter):
"""
- Extract the snowflake from `arg` using a regex `pattern` and return it as an int.
+ Converts to a `discord.Member`, but only if a mention, userID or a username (name#discrim) is provided.
- The snowflake is expected to be within the first capture group in `pattern`.
+ Unlike the default `MemberConverter`, it doesn't allow conversion from a name or nickname.
+ This is useful in cases where that lookup strategy would lead to too much ambiguity.
"""
- match = pattern.match(arg)
- if not match:
- raise BadArgument(f"Mention {str!r} is invalid.")
- return int(match.group(1))
+ async def convert(self, ctx: Context, argument: str) -> discord.Member:
+ """Convert the `argument` to a `discord.Member`."""
+ if _is_an_unambiguous_user_argument(argument):
+ return await super().convert(ctx, argument)
+ else:
+ raise BadArgument(AMBIGUOUS_ARGUMENT_MSG.format(argument=argument))
class Infraction(Converter):
@@ -567,6 +571,25 @@ class Infraction(Converter):
return await ctx.bot.api_client.get(f"bot/infractions/{arg}")
+if t.TYPE_CHECKING:
+ ValidDiscordServerInvite = dict # noqa: F811
+ ValidFilterListType = str # noqa: F811
+ Extension = str # noqa: F811
+ PackageName = str # noqa: F811
+ ValidURL = str # noqa: F811
+ Inventory = t.Tuple[str, _inventory_parser.InventoryDict] # noqa: F811
+ Snowflake = int # noqa: F811
+ TagNameConverter = str # noqa: F811
+ SourceConverter = SourceType # noqa: F811
+ DurationDelta = relativedelta # noqa: F811
+ Duration = datetime # noqa: F811
+ OffTopicName = str # noqa: F811
+ ISODateTime = datetime # noqa: F811
+ HushDurationConverter = int # noqa: F811
+ UnambiguousUser = discord.User # noqa: F811
+ UnambiguousMember = discord.Member # noqa: F811
+ Infraction = t.Optional[dict] # noqa: F811
+
Expiry = t.Union[Duration, ISODateTime]
-FetchedMember = t.Union[discord.Member, FetchedUser]
-UserMention = partial(_snowflake_from_regex, RE_USER_MENTION)
+MemberOrUser = t.Union[discord.Member, discord.User]
+UnambiguousMemberOrUser = t.Union[UnambiguousMember, UnambiguousUser]
diff --git a/bot/errors.py b/bot/errors.py
index 3544c6320..2633390a8 100644
--- a/bot/errors.py
+++ b/bot/errors.py
@@ -1,6 +1,8 @@
-from typing import Hashable, Union
+from __future__ import annotations
-from discord import Member, User
+from typing import Hashable, TYPE_CHECKING
+if TYPE_CHECKING:
+ from bot.converters import MemberOrUser
class LockedResourceError(RuntimeError):
@@ -22,7 +24,7 @@ class LockedResourceError(RuntimeError):
)
-class InvalidInfractedUser(Exception):
+class InvalidInfractedUserError(Exception):
"""
Exception raised upon attempt of infracting an invalid user.
@@ -30,7 +32,8 @@ class InvalidInfractedUser(Exception):
`user` -- User or Member which is invalid
"""
- def __init__(self, user: Union[Member, User], reason: str = "User infracted is a bot."):
+ def __init__(self, user: MemberOrUser, reason: str = "User infracted is a bot."):
+
self.user = user
self.reason = reason
@@ -41,3 +44,17 @@ class BrandingMisconfiguration(RuntimeError):
"""Raised by the Branding cog when a misconfigured event is encountered."""
pass
+
+
+class NonExistentRoleError(ValueError):
+ """
+ Raised by the Information Cog when encountering a Role that does not exist.
+
+ Attributes:
+ `role_id` -- the ID of the role that does not exist
+ """
+
+ def __init__(self, role_id: int):
+ super().__init__(f"Could not fetch data for role {role_id}")
+
+ self.role_id = role_id
diff --git a/bot/exts/backend/branding/_cog.py b/bot/exts/backend/branding/_cog.py
index 47c379a34..0ba146635 100644
--- a/bot/exts/backend/branding/_cog.py
+++ b/bot/exts/backend/branding/_cog.py
@@ -50,7 +50,7 @@ def make_embed(title: str, description: str, *, success: bool) -> discord.Embed:
For both `title` and `description`, empty string are valid values ~ fields will be empty.
"""
colour = Colours.soft_green if success else Colours.soft_red
- return discord.Embed(title=title[:256], description=description[:2048], colour=colour)
+ return discord.Embed(title=title[:256], description=description[:4096], colour=colour)
def extract_event_duration(event: Event) -> str:
@@ -293,8 +293,8 @@ class Branding(commands.Cog):
else:
content = "Python Discord is entering a new event!" if is_notification else None
- embed = discord.Embed(description=description[:2048], colour=discord.Colour.blurple())
- embed.set_footer(text=duration[:2048])
+ embed = discord.Embed(description=description[:4096], colour=discord.Colour.blurple())
+ embed.set_footer(text=duration[:4096])
await channel.send(content=content, embed=embed)
diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py
index d8de177f5..578c372c3 100644
--- a/bot/exts/backend/error_handler.py
+++ b/bot/exts/backend/error_handler.py
@@ -3,14 +3,14 @@ import logging
import typing as t
from discord import Embed
-from discord.ext.commands import Cog, Context, errors
+from discord.ext.commands import ChannelNotFound, Cog, Context, TextChannelConverter, VoiceChannelConverter, errors
from sentry_sdk import push_scope
from bot.api import ResponseCodeError
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.errors import InvalidInfractedUserError, LockedResourceError
from bot.utils.checks import ContextCheckFailure
log = logging.getLogger(__name__)
@@ -76,7 +76,7 @@ class ErrorHandler(Cog):
await self.handle_api_error(ctx, e.original)
elif isinstance(e.original, LockedResourceError):
await ctx.send(f"{e.original} Please wait for it to finish and try again later.")
- elif isinstance(e.original, InvalidInfractedUser):
+ elif isinstance(e.original, InvalidInfractedUserError):
await ctx.send(f"Cannot infract that user. {e.original.reason}")
else:
await self.handle_unexpected_error(ctx, e.original)
@@ -115,8 +115,10 @@ class ErrorHandler(Cog):
Return bool depending on success of command.
"""
command = ctx.invoked_with.lower()
+ args = ctx.message.content.lower().split(" ")
silence_command = self.bot.get_command("silence")
ctx.invoked_from_error_handler = True
+
try:
if not await silence_command.can_run(ctx):
log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.")
@@ -124,11 +126,30 @@ class ErrorHandler(Cog):
except errors.CommandError:
log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.")
return False
+
+ # Parse optional args
+ channel = None
+ duration = min(command.count("h") * 2, 15)
+ kick = False
+
+ if len(args) > 1:
+ # Parse channel
+ for converter in (TextChannelConverter(), VoiceChannelConverter()):
+ try:
+ channel = await converter.convert(ctx, args[1])
+ break
+ except ChannelNotFound:
+ continue
+
+ if len(args) > 2 and channel is not None:
+ # Parse kick
+ kick = args[2].lower() == "true"
+
if command.startswith("shh"):
- await ctx.invoke(silence_command, duration=min(command.count("h")*2, 15))
+ await ctx.invoke(silence_command, duration_or_channel=channel, duration=duration, kick=kick)
return True
elif command.startswith("unshh"):
- await ctx.invoke(self.bot.get_command("unsilence"))
+ await ctx.invoke(self.bot.get_command("unsilence"), channel=channel)
return True
return False
diff --git a/bot/exts/events/__init__.py b/bot/exts/events/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/bot/exts/events/__init__.py
diff --git a/bot/exts/events/code_jams/__init__.py b/bot/exts/events/code_jams/__init__.py
new file mode 100644
index 000000000..16e81e365
--- /dev/null
+++ b/bot/exts/events/code_jams/__init__.py
@@ -0,0 +1,8 @@
+from bot.bot import Bot
+
+
+def setup(bot: Bot) -> None:
+ """Load the CodeJams cog."""
+ from bot.exts.events.code_jams._cog import CodeJams
+
+ bot.add_cog(CodeJams(bot))
diff --git a/bot/exts/events/code_jams/_channels.py b/bot/exts/events/code_jams/_channels.py
new file mode 100644
index 000000000..34ff0ad41
--- /dev/null
+++ b/bot/exts/events/code_jams/_channels.py
@@ -0,0 +1,113 @@
+import logging
+import typing as t
+
+import discord
+
+from bot.constants import Categories, Channels, Roles
+
+log = logging.getLogger(__name__)
+
+MAX_CHANNELS = 50
+CATEGORY_NAME = "Code Jam"
+
+
+async def _get_category(guild: discord.Guild) -> discord.CategoryChannel:
+ """
+ Return a code jam category.
+
+ If all categories are full or none exist, create a new category.
+ """
+ for category in guild.categories:
+ if category.name == CATEGORY_NAME and len(category.channels) < MAX_CHANNELS:
+ return category
+
+ return await _create_category(guild)
+
+
+async def _create_category(guild: discord.Guild) -> discord.CategoryChannel:
+ """Create a new code jam category and return it."""
+ log.info("Creating a new code jam category.")
+
+ category_overwrites = {
+ guild.default_role: discord.PermissionOverwrite(read_messages=False),
+ guild.me: discord.PermissionOverwrite(read_messages=True)
+ }
+
+ category = await guild.create_category_channel(
+ CATEGORY_NAME,
+ overwrites=category_overwrites,
+ reason="It's code jam time!"
+ )
+
+ await _send_status_update(
+ guild, f"Created a new category with the ID {category.id} for this Code Jam's team channels."
+ )
+
+ return category
+
+
+def _get_overwrites(
+ members: list[tuple[discord.Member, bool]],
+ guild: discord.Guild,
+) -> dict[t.Union[discord.Member, discord.Role], discord.PermissionOverwrite]:
+ """Get code jam team channels permission overwrites."""
+ team_channel_overwrites = {
+ guild.default_role: discord.PermissionOverwrite(read_messages=False),
+ guild.get_role(Roles.code_jam_event_team): discord.PermissionOverwrite(read_messages=True)
+ }
+
+ for member, _ in members:
+ team_channel_overwrites[member] = discord.PermissionOverwrite(
+ read_messages=True
+ )
+
+ return team_channel_overwrites
+
+
+async def create_team_channel(
+ guild: discord.Guild,
+ team_name: str,
+ members: list[tuple[discord.Member, bool]],
+ team_leaders: discord.Role
+) -> None:
+ """Create the team's text channel."""
+ await _add_team_leader_roles(members, team_leaders)
+
+ # Get permission overwrites and category
+ team_channel_overwrites = _get_overwrites(members, guild)
+ code_jam_category = await _get_category(guild)
+
+ # Create a text channel for the team
+ await code_jam_category.create_text_channel(
+ team_name,
+ overwrites=team_channel_overwrites,
+ )
+
+
+async def create_team_leader_channel(guild: discord.Guild, team_leaders: discord.Role) -> None:
+ """Create the Team Leader Chat channel for the Code Jam team leaders."""
+ category: discord.CategoryChannel = guild.get_channel(Categories.summer_code_jam)
+
+ team_leaders_chat = await category.create_text_channel(
+ name="team-leaders-chat",
+ overwrites={
+ guild.default_role: discord.PermissionOverwrite(read_messages=False),
+ team_leaders: discord.PermissionOverwrite(read_messages=True)
+ }
+ )
+
+ await _send_status_update(guild, f"Created {team_leaders_chat.mention} in the {category} category.")
+
+
+async def _send_status_update(guild: discord.Guild, message: str) -> None:
+ """Inform the events lead with a status update when the command is ran."""
+ channel: discord.TextChannel = guild.get_channel(Channels.code_jam_planning)
+
+ await channel.send(f"<@&{Roles.events_lead}>\n\n{message}")
+
+
+async def _add_team_leader_roles(members: list[tuple[discord.Member, bool]], team_leaders: discord.Role) -> None:
+ """Assign the team leader role to the team leaders."""
+ for member, is_leader in members:
+ if is_leader:
+ await member.add_roles(team_leaders)
diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py
new file mode 100644
index 000000000..e099f7dfa
--- /dev/null
+++ b/bot/exts/events/code_jams/_cog.py
@@ -0,0 +1,235 @@
+import asyncio
+import csv
+import logging
+import typing as t
+from collections import defaultdict
+
+import discord
+from discord import Colour, Embed, Guild, Member
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Emojis, Roles
+from bot.exts.events.code_jams import _channels
+from bot.utils.services import send_to_paste_service
+
+log = logging.getLogger(__name__)
+
+TEAM_LEADERS_COLOUR = 0x11806a
+DELETION_REACTION = "\U0001f4a5"
+
+
+class CodeJams(commands.Cog):
+ """Manages the code-jam related parts of our server."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @commands.group(aliases=("cj", "jam"))
+ @commands.has_any_role(Roles.admins)
+ async def codejam(self, ctx: commands.Context) -> None:
+ """A Group of commands for managing Code Jams."""
+ if ctx.invoked_subcommand is None:
+ await ctx.send_help(ctx.command)
+
+ @codejam.command()
+ async def create(self, ctx: commands.Context, csv_file: t.Optional[str] = None) -> None:
+ """
+ Create code-jam teams from a CSV file or a link to one, specifying the team names, leaders and members.
+
+ The CSV file must have 3 columns: 'Team Name', 'Team Member Discord ID', and 'Team Leader'.
+
+ This will create the text channels for the teams, and give the team leaders their roles.
+ """
+ async with ctx.typing():
+ if csv_file:
+ async with self.bot.http_session.get(csv_file) as response:
+ if response.status != 200:
+ await ctx.send(f"Got a bad response from the URL: {response.status}")
+ return
+
+ csv_file = await response.text()
+
+ elif ctx.message.attachments:
+ csv_file = (await ctx.message.attachments[0].read()).decode("utf8")
+ else:
+ raise commands.BadArgument("You must include either a CSV file or a link to one.")
+
+ teams = defaultdict(list)
+ reader = csv.DictReader(csv_file.splitlines())
+
+ for row in reader:
+ member = ctx.guild.get_member(int(row["Team Member Discord ID"]))
+
+ if member is None:
+ log.trace(f"Got an invalid member ID: {row['Team Member Discord ID']}")
+ continue
+
+ teams[row["Team Name"]].append((member, row["Team Leader"].upper() == "Y"))
+
+ team_leaders = await ctx.guild.create_role(name="Code Jam Team Leaders", colour=TEAM_LEADERS_COLOUR)
+
+ for team_name, members in teams.items():
+ await _channels.create_team_channel(ctx.guild, team_name, members, team_leaders)
+
+ await _channels.create_team_leader_channel(ctx.guild, team_leaders)
+ await ctx.send(f"{Emojis.check_mark} Created Code Jam with {len(teams)} teams.")
+
+ @codejam.command()
+ @commands.has_any_role(Roles.admins)
+ async def end(self, ctx: commands.Context) -> None:
+ """
+ Delete all code jam channels.
+
+ A confirmation message is displayed with the categories and channels to be deleted.. Pressing the added reaction
+ deletes those channels.
+ """
+ def predicate_deletion_emoji_reaction(reaction: discord.Reaction, user: discord.User) -> bool:
+ """Return True if the reaction :boom: was added by the context message author on this message."""
+ return (
+ reaction.message.id == message.id
+ and user.id == ctx.author.id
+ and str(reaction) == DELETION_REACTION
+ )
+
+ # A copy of the list of channels is stored. This is to make sure that we delete precisely the channels displayed
+ # in the confirmation message.
+ categories = self.jam_categories(ctx.guild)
+ category_channels = {category: category.channels.copy() for category in categories}
+
+ confirmation_message = await self._build_confirmation_message(category_channels)
+ message = await ctx.send(confirmation_message)
+ await message.add_reaction(DELETION_REACTION)
+ try:
+ await self.bot.wait_for(
+ 'reaction_add',
+ check=predicate_deletion_emoji_reaction,
+ timeout=10
+ )
+
+ except asyncio.TimeoutError:
+ await message.clear_reaction(DELETION_REACTION)
+ await ctx.send("Command timed out.", reference=message)
+ return
+
+ else:
+ await message.clear_reaction(DELETION_REACTION)
+ for category, channels in category_channels.items():
+ for channel in channels:
+ await channel.delete(reason="Code jam ended.")
+ await category.delete(reason="Code jam ended.")
+
+ await message.add_reaction(Emojis.check_mark)
+
+ @staticmethod
+ async def _build_confirmation_message(
+ categories: dict[discord.CategoryChannel, list[discord.abc.GuildChannel]]
+ ) -> str:
+ """Sends details of the channels to be deleted to the pasting service, and formats the confirmation message."""
+ def channel_repr(channel: discord.abc.GuildChannel) -> str:
+ """Formats the channel name and ID and a readable format."""
+ return f"{channel.name} ({channel.id})"
+
+ def format_category_info(category: discord.CategoryChannel, channels: list[discord.abc.GuildChannel]) -> str:
+ """Displays the category and the channels within it in a readable format."""
+ return f"{channel_repr(category)}:\n" + "\n".join(" - " + channel_repr(channel) for channel in channels)
+
+ deletion_details = "\n\n".join(
+ format_category_info(category, channels) for category, channels in categories.items()
+ )
+
+ url = await send_to_paste_service(deletion_details)
+ if url is None:
+ url = "**Unable to send deletion details to the pasting service.**"
+
+ return f"Are you sure you want to delete all code jam channels?\n\nThe channels to be deleted: {url}"
+
+ @codejam.command()
+ @commands.has_any_role(Roles.admins, Roles.code_jam_event_team)
+ async def info(self, ctx: commands.Context, member: Member) -> None:
+ """
+ Send an info embed about the member with the team they're in.
+
+ The team is found by searching the permissions of the team channels.
+ """
+ channel = self.team_channel(ctx.guild, member)
+ if not channel:
+ await ctx.send(":x: I can't find the team channel for this member.")
+ return
+
+ embed = Embed(
+ title=str(member),
+ colour=Colour.blurple()
+ )
+ embed.add_field(name="Team", value=self.team_name(channel), inline=True)
+
+ await ctx.send(embed=embed)
+
+ @codejam.command()
+ @commands.has_any_role(Roles.admins)
+ async def move(self, ctx: commands.Context, member: Member, new_team_name: str) -> None:
+ """Move participant from one team to another by changing the user's permissions for the relevant channels."""
+ old_team_channel = self.team_channel(ctx.guild, member)
+ if not old_team_channel:
+ await ctx.send(":x: I can't find the team channel for this member.")
+ return
+
+ if old_team_channel.name == new_team_name or self.team_name(old_team_channel) == new_team_name:
+ await ctx.send(f"`{member}` is already in `{new_team_name}`.")
+ return
+
+ new_team_channel = self.team_channel(ctx.guild, new_team_name)
+ if not new_team_channel:
+ await ctx.send(f":x: I can't find a team channel named `{new_team_name}`.")
+ return
+
+ await old_team_channel.set_permissions(member, overwrite=None, reason=f"Participant moved to {new_team_name}")
+ await new_team_channel.set_permissions(
+ member,
+ overwrite=discord.PermissionOverwrite(read_messages=True),
+ reason=f"Participant moved from {old_team_channel.name}"
+ )
+
+ await ctx.send(
+ f"Participant moved from `{self.team_name(old_team_channel)}` to `{self.team_name(new_team_channel)}`."
+ )
+
+ @codejam.command()
+ @commands.has_any_role(Roles.admins)
+ async def remove(self, ctx: commands.Context, member: Member) -> None:
+ """Remove the participant from their team. Does not remove the participants or leader roles."""
+ channel = self.team_channel(ctx.guild, member)
+ if not channel:
+ await ctx.send(":x: I can't find the team channel for this member.")
+ return
+
+ await channel.set_permissions(
+ member,
+ overwrite=None,
+ reason=f"Participant removed from the team {self.team_name(channel)}."
+ )
+ await ctx.send(f"Removed the participant from `{self.team_name(channel)}`.")
+
+ @staticmethod
+ def jam_categories(guild: Guild) -> list[discord.CategoryChannel]:
+ """Get all the code jam team categories."""
+ return [category for category in guild.categories if category.name == _channels.CATEGORY_NAME]
+
+ @staticmethod
+ def team_channel(guild: Guild, criterion: t.Union[str, Member]) -> t.Optional[discord.TextChannel]:
+ """Get a team channel through either a participant or the team name."""
+ for category in CodeJams.jam_categories(guild):
+ for channel in category.channels:
+ if isinstance(channel, discord.TextChannel):
+ if (
+ # If it's a string.
+ criterion == channel.name or criterion == CodeJams.team_name(channel)
+ # If it's a member.
+ or criterion in channel.overwrites
+ ):
+ return channel
+
+ @staticmethod
+ def team_name(channel: discord.TextChannel) -> str:
+ """Retrieves the team name from the given channel."""
+ return channel.name.replace("-", " ").title()
diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py
index 26f00e91f..0eedeb0fb 100644
--- a/bot/exts/filters/antimalware.py
+++ b/bot/exts/filters/antimalware.py
@@ -7,6 +7,7 @@ from discord.ext.commands import Cog
from bot.bot import Bot
from bot.constants import Channels, Filter, URLs
+from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME
log = logging.getLogger(__name__)
@@ -15,9 +16,11 @@ PY_EMBED_DESCRIPTION = (
f"please use a code-pasting service such as {URLs.site_schema}{URLs.site_paste}"
)
+TXT_LIKE_FILES = {".txt", ".csv", ".json"}
TXT_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"
+ "We currently don't allow `{blocked_extension}` 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 "
@@ -59,6 +62,10 @@ class AntiMalware(Cog):
if message.webhook_id or message.author.bot:
return
+ # Ignore code jam channels
+ if hasattr(message.channel, "category") and message.channel.category.name == JAM_CATEGORY_NAME:
+ return
+
# Check if user is staff, if is, return
# Since we only care that roles exist to iterate over, check for the attr rather than a User/Member instance
if hasattr(message.author, "roles") and any(role.id in Filter.role_whitelist for role in message.author.roles):
@@ -70,10 +77,13 @@ class AntiMalware(Cog):
if ".py" in extensions_blocked:
# Short-circuit on *.py files to provide a pastebin link
embed.description = PY_EMBED_DESCRIPTION
- elif ".txt" in extensions_blocked:
+ elif extensions := TXT_LIKE_FILES.intersection(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 = TXT_EMBED_DESCRIPTION.format(cmd_channel_mention=cmd_channel.mention)
+ embed.description = TXT_EMBED_DESCRIPTION.format(
+ blocked_extension=extensions.pop(),
+ cmd_channel_mention=cmd_channel.mention
+ )
elif extensions_blocked:
meta_channel = self.bot.get_channel(Channels.meta)
embed.description = DISALLOWED_EMBED_DESCRIPTION.format(
diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py
index 7555e25a2..8c075fa95 100644
--- a/bot/exts/filters/antispam.py
+++ b/bot/exts/filters/antispam.py
@@ -1,8 +1,10 @@
import asyncio
import logging
+from collections import defaultdict
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime, timedelta
+from itertools import takewhile
from operator import attrgetter, itemgetter
from typing import Dict, Iterable, List, Set
@@ -17,8 +19,10 @@ from bot.constants import (
Guild as GuildConfig, Icons,
)
from bot.converters import Duration
+from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME
from bot.exts.moderation.modlog import ModLog
from bot.utils import lock, scheduling
+from bot.utils.message_cache import MessageCache
from bot.utils.messages import format_user, send_attachments
@@ -43,19 +47,18 @@ RULE_FUNCTION_MAPPING = {
class DeletionContext:
"""Represents a Deletion Context for a single spam event."""
- channel: TextChannel
- members: Dict[int, Member] = field(default_factory=dict)
+ members: frozenset[Member]
+ triggered_in: TextChannel
+ channels: set[TextChannel] = field(default_factory=set)
rules: Set[str] = field(default_factory=set)
messages: Dict[int, Message] = field(default_factory=dict)
attachments: List[List[str]] = field(default_factory=list)
- async def add(self, rule_name: str, members: Iterable[Member], messages: Iterable[Message]) -> None:
+ async def add(self, rule_name: str, channels: Iterable[TextChannel], messages: Iterable[Message]) -> None:
"""Adds new rule violation events to the deletion context."""
self.rules.add(rule_name)
- for member in members:
- if member.id not in self.members:
- self.members[member.id] = member
+ self.channels.update(channels)
for message in messages:
if message.id not in self.messages:
@@ -68,11 +71,14 @@ class DeletionContext:
async def upload_messages(self, actor_id: int, modlog: ModLog) -> None:
"""Method that takes care of uploading the queue and posting modlog alert."""
- triggered_by_users = ", ".join(format_user(m) for m in self.members.values())
+ triggered_by_users = ", ".join(format_user(m) for m in self.members)
+ triggered_in_channel = f"**Triggered in:** {self.triggered_in.mention}\n" if len(self.channels) > 1 else ""
+ channels_description = ", ".join(channel.mention for channel in self.channels)
mod_alert_message = (
f"**Triggered by:** {triggered_by_users}\n"
- f"**Channel:** {self.channel.mention}\n"
+ f"{triggered_in_channel}"
+ f"**Channels:** {channels_description}\n"
f"**Rules:** {', '.join(rule for rule in self.rules)}\n"
)
@@ -84,7 +90,7 @@ class DeletionContext:
mod_alert_message += "Message:\n"
[message] = self.messages.values()
content = message.clean_content
- remaining_chars = 2040 - len(mod_alert_message)
+ remaining_chars = 4080 - len(mod_alert_message)
if len(content) > remaining_chars:
content = content[:remaining_chars] + "..."
@@ -115,6 +121,14 @@ class AntiSpam(Cog):
self.message_deletion_queue = dict()
+ # Fetch the rule configuration with the highest rule interval.
+ max_interval_config = max(
+ AntiSpamConfig.rules.values(),
+ key=itemgetter('interval')
+ )
+ self.max_interval = max_interval_config['interval']
+ self.cache = MessageCache(AntiSpamConfig.cache_size, newest_first=True)
+
self.bot.loop.create_task(self.alert_on_validation_error(), name="AntiSpam.alert_on_validation_error")
@property
@@ -148,24 +162,16 @@ class AntiSpam(Cog):
not message.guild
or message.guild.id != GuildConfig.id
or message.author.bot
+ or (hasattr(message.channel, "category") and message.channel.category.name == JAM_CATEGORY_NAME)
or (message.channel.id in Filter.channel_whitelist and not DEBUG_MODE)
or (any(role.id in Filter.role_whitelist for role in message.author.roles) and not DEBUG_MODE)
):
return
- # Fetch the rule configuration with the highest rule interval.
- max_interval_config = max(
- AntiSpamConfig.rules.values(),
- key=itemgetter('interval')
- )
- max_interval = max_interval_config['interval']
+ self.cache.append(message)
- # Store history messages since `interval` seconds ago in a list to prevent unnecessary API calls.
- earliest_relevant_at = datetime.utcnow() - timedelta(seconds=max_interval)
- relevant_messages = [
- msg async for msg in message.channel.history(after=earliest_relevant_at, oldest_first=False)
- if not msg.author.bot
- ]
+ earliest_relevant_at = datetime.utcnow() - timedelta(seconds=self.max_interval)
+ relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, self.cache))
for rule_name in AntiSpamConfig.rules:
rule_config = AntiSpamConfig.rules[rule_name]
@@ -173,9 +179,10 @@ class AntiSpam(Cog):
# Create a list of messages that were sent in the interval that the rule cares about.
latest_interesting_stamp = datetime.utcnow() - timedelta(seconds=rule_config['interval'])
- messages_for_rule = [
- msg for msg in relevant_messages if msg.created_at > latest_interesting_stamp
- ]
+ messages_for_rule = list(
+ takewhile(lambda msg: msg.created_at > latest_interesting_stamp, relevant_messages)
+ )
+
result = await rule_function(message, messages_for_rule, rule_config)
# If the rule returns `None`, that means the message didn't violate it.
@@ -188,19 +195,19 @@ class AntiSpam(Cog):
full_reason = f"`{rule_name}` rule: {reason}"
# If there's no spam event going on for this channel, start a new Message Deletion Context
- channel = message.channel
- if channel.id not in self.message_deletion_queue:
- log.trace(f"Creating queue for channel `{channel.id}`")
- self.message_deletion_queue[message.channel.id] = DeletionContext(channel)
+ authors_set = frozenset(members)
+ if authors_set not in self.message_deletion_queue:
+ log.trace(f"Creating queue for members `{authors_set}`")
+ self.message_deletion_queue[authors_set] = DeletionContext(authors_set, message.channel)
scheduling.create_task(
- self._process_deletion_context(message.channel.id),
- name=f"AntiSpam._process_deletion_context({message.channel.id})"
+ self._process_deletion_context(authors_set),
+ name=f"AntiSpam._process_deletion_context({authors_set})"
)
# Add the relevant of this trigger to the Deletion Context
- await self.message_deletion_queue[message.channel.id].add(
+ await self.message_deletion_queue[authors_set].add(
rule_name=rule_name,
- members=members,
+ channels=set(message.channel for message in relevant_messages),
messages=relevant_messages
)
@@ -210,7 +217,7 @@ class AntiSpam(Cog):
name=f"AntiSpam.punish(message={message.id}, member={member.id}, rule={rule_name})"
)
- await self.maybe_delete_messages(channel, relevant_messages)
+ await self.maybe_delete_messages(relevant_messages)
break
@lock.lock_arg("antispam.punish", "member", attrgetter("id"))
@@ -232,14 +239,18 @@ class AntiSpam(Cog):
reason=reason
)
- async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]) -> None:
+ async def maybe_delete_messages(self, messages: List[Message]) -> None:
"""Cleans the messages if cleaning is configured."""
if AntiSpamConfig.clean_offending:
# If we have more than one message, we can use bulk delete.
if len(messages) > 1:
message_ids = [message.id for message in messages]
self.mod_log.ignore(Event.message_delete, *message_ids)
- await channel.delete_messages(messages)
+ channel_messages = defaultdict(list)
+ for message in messages:
+ channel_messages[message.channel].append(message)
+ for channel, messages in channel_messages.items():
+ await channel.delete_messages(messages)
# Otherwise, the bulk delete endpoint will throw up.
# Delete the message directly instead.
@@ -250,7 +261,7 @@ class AntiSpam(Cog):
except NotFound:
log.info(f"Tried to delete message `{messages[0].id}`, but message could not be found.")
- async def _process_deletion_context(self, context_id: int) -> None:
+ async def _process_deletion_context(self, context_id: frozenset) -> None:
"""Processes the Deletion Context queue."""
log.trace("Sleeping before processing message deletion queue.")
await asyncio.sleep(10)
@@ -262,6 +273,11 @@ class AntiSpam(Cog):
deletion_context = self.message_deletion_queue.pop(context_id)
await deletion_context.upload_messages(self.bot.user.id, self.mod_log)
+ @Cog.listener()
+ async def on_message_edit(self, before: Message, after: Message) -> None:
+ """Updates the message in the cache, if it's cached."""
+ self.cache.update(after)
+
def validate_config(rules_: Mapping = AntiSpamConfig.rules) -> Dict[str, str]:
"""Validates the antispam configs."""
diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py
index 464732453..10cc7885d 100644
--- a/bot/exts/filters/filtering.py
+++ b/bot/exts/filters/filtering.py
@@ -19,6 +19,7 @@ from bot.constants import (
Channels, Colours, Filter,
Guild, Icons, URLs
)
+from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME
from bot.exts.moderation.modlog import ModLog
from bot.utils.messages import format_user
from bot.utils.regex import INVITE_RE
@@ -103,19 +104,6 @@ class Filtering(Cog):
),
"schedule_deletion": False
},
- "filter_everyone_ping": {
- "enabled": Filter.filter_everyone_ping,
- "function": self._has_everyone_ping,
- "type": "filter",
- "content_only": True,
- "user_notification": Filter.notify_user_everyone_ping,
- "notification_msg": (
- "Please don't try to ping `@everyone` or `@here`. "
- f"Your message has been removed. {staff_mistake_str}"
- ),
- "schedule_deletion": False,
- "ping_everyone": False
- },
"watch_regex": {
"enabled": Filter.watch_regex,
"function": self._has_watch_regex_match,
@@ -129,7 +117,20 @@ class Filtering(Cog):
"type": "watchlist",
"content_only": False,
"schedule_deletion": False
- }
+ },
+ "filter_everyone_ping": {
+ "enabled": Filter.filter_everyone_ping,
+ "function": self._has_everyone_ping,
+ "type": "filter",
+ "content_only": True,
+ "user_notification": Filter.notify_user_everyone_ping,
+ "notification_msg": (
+ "Please don't try to ping `@everyone` or `@here`. "
+ f"Your message has been removed. {staff_mistake_str}"
+ ),
+ "schedule_deletion": False,
+ "ping_everyone": False
+ },
}
self.bot.loop.create_task(self.reschedule_offensive_msg_deletion())
@@ -281,6 +282,12 @@ class Filtering(Cog):
if delta is not None and delta < 100:
continue
+ if filter_name in ("filter_invites", "filter_everyone_ping"):
+ # Disable invites filter in codejam team channels
+ category = getattr(msg.channel, "category", None)
+ if category and category.name == JAM_CATEGORY_NAME:
+ continue
+
# Does the filter only need the message content or the full message?
if _filter["content_only"]:
payload = msg.content
diff --git a/bot/exts/filters/pixels_token_remover.py b/bot/exts/filters/pixels_token_remover.py
deleted file mode 100644
index 2356491e5..000000000
--- a/bot/exts/filters/pixels_token_remover.py
+++ /dev/null
@@ -1,108 +0,0 @@
-import logging
-import re
-import typing as t
-
-from discord import Colour, Message, NotFound
-from discord.ext.commands import Cog
-
-from bot.bot import Bot
-from bot.constants import Channels, Colours, Event, Icons
-from bot.exts.moderation.modlog import ModLog
-from bot.utils.messages import format_user
-
-log = logging.getLogger(__name__)
-
-LOG_MESSAGE = "Censored a valid Pixels token sent by {author} in {channel}, token was `{token}`"
-DELETION_MESSAGE_TEMPLATE = (
- "Hey {mention}! I noticed you posted a valid Pixels API "
- "token in your message and have removed your message. "
- "This means that your token has been **compromised**. "
- "I have taken the liberty of invalidating the token for you. "
- "You can go to <https://pixels.pythondiscord.com/authorize> to get a new key."
-)
-
-PIXELS_TOKEN_RE = re.compile(r"[A-Za-z0-9-_=]{30,}\.[A-Za-z0-9-_=]{50,}\.[A-Za-z0-9-_.+\=]{30,}")
-
-
-class PixelsTokenRemover(Cog):
- """Scans messages for Pixels API tokens, removes and invalidates them."""
-
- def __init__(self, bot: Bot):
- self.bot = bot
-
- @property
- def mod_log(self) -> ModLog:
- """Get currently loaded ModLog cog instance."""
- return self.bot.get_cog("ModLog")
-
- @Cog.listener()
- async def on_message(self, msg: Message) -> None:
- """Check each message for a string that matches the RS-256 token pattern."""
- # Ignore DMs; can't delete messages in there anyway.
- if not msg.guild or msg.author.bot:
- return
-
- found_token = await self.find_token_in_message(msg)
- if found_token:
- await self.take_action(msg, found_token)
-
- @Cog.listener()
- async def on_message_edit(self, before: Message, after: Message) -> None:
- """Check each edit for a string that matches the RS-256 token pattern."""
- await self.on_message(after)
-
- async def take_action(self, msg: Message, found_token: str) -> None:
- """Remove the `msg` containing the `found_token` and send a mod log message."""
- self.mod_log.ignore(Event.message_delete, msg.id)
-
- try:
- await msg.delete()
- except NotFound:
- log.debug(f"Failed to remove token in message {msg.id}: message already deleted.")
- return
-
- await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention))
-
- log_message = self.format_log_message(msg, found_token)
- log.debug(log_message)
-
- # Send pretty mod log embed to mod-alerts
- await self.mod_log.send_log_message(
- icon_url=Icons.token_removed,
- colour=Colour(Colours.soft_red),
- title="Token removed!",
- text=log_message,
- thumbnail=msg.author.avatar_url_as(static_format="png"),
- channel_id=Channels.mod_alerts,
- ping_everyone=False,
- )
-
- self.bot.stats.incr("tokens.removed_pixels_tokens")
-
- @staticmethod
- def format_log_message(msg: Message, token: str) -> str:
- """Return the generic portion of the log message to send for `token` being censored in `msg`."""
- return LOG_MESSAGE.format(
- author=format_user(msg.author),
- channel=msg.channel.mention,
- token=token
- )
-
- async def find_token_in_message(self, msg: Message) -> t.Optional[str]:
- """Return a seemingly valid token found in `msg` or `None` if no token is found."""
- # Use finditer rather than search to guard against method calls prematurely returning the
- # token check (e.g. `message.channel.send` also matches our token pattern)
- for match in PIXELS_TOKEN_RE.finditer(msg.content):
- auth_header = {"Authorization": f"Bearer {match[0]}"}
- async with self.bot.http_session.delete("https://pixels.pythondiscord.com/token", headers=auth_header) as r:
- if r.status == 204:
- # Short curcuit on first match.
- return match[0]
-
- # No matching substring
- return
-
-
-def setup(bot: Bot) -> None:
- """Load the PixelsTokenRemover cog."""
- bot.add_cog(PixelsTokenRemover(bot))
diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py
index f11fc8912..25e267426 100644
--- a/bot/exts/filters/webhook_remover.py
+++ b/bot/exts/filters/webhook_remover.py
@@ -9,12 +9,15 @@ from bot.constants import Channels, Colours, Event, Icons
from bot.exts.moderation.modlog import ModLog
from bot.utils.messages import format_user
-WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\d+/)\S+/?", re.IGNORECASE)
+WEBHOOK_URL_RE = re.compile(
+ r"((?:https?:\/\/)?(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/)\S+\/?",
+ re.IGNORECASE
+)
ALERT_MESSAGE_TEMPLATE = (
"{user}, looks like you posted a Discord webhook URL. Therefore, your "
- "message has been removed. Your webhook may have been **compromised** so "
- "please re-create the webhook **immediately**. If you believe this was a "
+ "message has been removed, and your webhook has been deleted. "
+ "You can re-create it if you wish to. If you believe this was a "
"mistake, please let us know."
)
@@ -32,7 +35,7 @@ class WebhookRemover(Cog):
"""Get current instance of `ModLog`."""
return self.bot.get_cog("ModLog")
- async def delete_and_respond(self, msg: Message, redacted_url: str) -> None:
+ async def delete_and_respond(self, msg: Message, redacted_url: str, *, webhook_deleted: bool) -> None:
"""Delete `msg` and send a warning that it contained the Discord webhook `redacted_url`."""
# Don't log this, due internal delete, not by user. Will make different entry.
self.mod_log.ignore(Event.message_delete, msg.id)
@@ -44,9 +47,12 @@ class WebhookRemover(Cog):
return
await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention))
-
+ if webhook_deleted:
+ delete_state = "The webhook was successfully deleted."
+ else:
+ delete_state = "There was an error when deleting the webhook, it might have already been removed."
message = (
- f"{format_user(msg.author)} posted a Discord webhook URL to {msg.channel.mention}. "
+ f"{format_user(msg.author)} posted a Discord webhook URL to {msg.channel.mention}. {delete_state} "
f"Webhook URL was `{redacted_url}`"
)
log.debug(message)
@@ -72,7 +78,10 @@ class WebhookRemover(Cog):
matches = WEBHOOK_URL_RE.search(msg.content)
if matches:
- await self.delete_and_respond(msg, matches[1] + "xxx")
+ async with self.bot.http_session.delete(matches[0]) as resp:
+ # The Discord API Returns a 204 NO CONTENT response on success.
+ deleted_successfully = resp.status == 204
+ await self.delete_and_respond(msg, matches[1] + "xxx", webhook_deleted=deleted_successfully)
@Cog.listener()
async def on_message_edit(self, before: Message, after: Message) -> None:
diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py
index c78b9c141..7f7e4585c 100644
--- a/bot/exts/fun/duck_pond.py
+++ b/bot/exts/fun/duck_pond.py
@@ -3,11 +3,12 @@ import logging
from typing import Union
import discord
-from discord import Color, Embed, Member, Message, RawReactionActionEvent, TextChannel, User, errors
+from discord import Color, Embed, Message, RawReactionActionEvent, TextChannel, errors
from discord.ext.commands import Cog, Context, command
from bot import constants
from bot.bot import Bot
+from bot.converters import MemberOrUser
from bot.utils.checks import has_any_role
from bot.utils.messages import count_unique_users_reaction, send_attachments
from bot.utils.webhooks import send_webhook
@@ -36,7 +37,7 @@ class DuckPond(Cog):
log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`")
@staticmethod
- def is_staff(member: Union[User, Member]) -> bool:
+ def is_staff(member: MemberOrUser) -> bool:
"""Check if a specific member or user is staff."""
if hasattr(member, "roles"):
for role in member.roles:
@@ -171,8 +172,14 @@ class DuckPond(Cog):
if not self.is_helper_viewable(channel):
return
- message = await channel.fetch_message(payload.message_id)
+ try:
+ message = await channel.fetch_message(payload.message_id)
+ except discord.NotFound:
+ return # Message was deleted.
+
member = discord.utils.get(message.guild.members, id=payload.user_id)
+ if not member:
+ return # Member left or wasn't in the cache.
# Was the message sent by a human staff member?
if not self.is_staff(message.author) or message.author.bot:
diff --git a/bot/exts/help_channels/_caches.py b/bot/exts/help_channels/_caches.py
index c5e4ee917..8d45c2466 100644
--- a/bot/exts/help_channels/_caches.py
+++ b/bot/exts/help_channels/_caches.py
@@ -24,3 +24,12 @@ question_messages = RedisCache(namespace="HelpChannels.question_messages")
# This cache keeps track of the dynamic message ID for
# the continuously updated message in the #How-to-get-help channel.
dynamic_message = RedisCache(namespace="HelpChannels.dynamic_message")
+
+# This cache keeps track of who has help-dms on.
+# RedisCache[discord.User.id, bool]
+help_dm = RedisCache(namespace="HelpChannels.help_dm")
+
+# This cache tracks member who are participating and opted in to help channel dms.
+# serialise the set as a comma separated string to allow usage with redis
+# RedisCache[discord.TextChannel.id, str[set[discord.User.id]]]
+session_participants = RedisCache(namespace="HelpChannels.session_participants")
diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py
index 5c410a0a1..cfc9cf477 100644
--- a/bot/exts/help_channels/_cog.py
+++ b/bot/exts/help_channels/_cog.py
@@ -12,6 +12,7 @@ from discord.ext import commands
from bot import constants
from bot.bot import Bot
+from bot.constants import Channels, RedirectOutput
from bot.exts.help_channels import _caches, _channel, _message, _name, _stats
from bot.utils import channel as channel_utils, lock, scheduling
@@ -266,6 +267,8 @@ class HelpChannels(commands.Cog):
for channel in channels[:abs(missing)]:
await self.unclaim_channel(channel, closed_on=_channel.ClosingReason.CLEANUP)
+ self.available_help_channels = set(_channel.get_category_channels(self.available_category))
+
# Getting channels that need to be included in the dynamic message.
await self.update_available_help_channels()
log.trace("Dynamic available help message updated.")
@@ -386,7 +389,12 @@ class HelpChannels(commands.Cog):
)
log.trace(f"Sending dormant message for #{channel} ({channel.id}).")
- embed = discord.Embed(description=_message.DORMANT_MSG)
+ embed = discord.Embed(
+ description=_message.DORMANT_MSG.format(
+ dormant=self.dormant_category.name,
+ available=self.available_category.name,
+ )
+ )
await channel.send(embed=embed)
log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.")
@@ -424,6 +432,7 @@ class HelpChannels(commands.Cog):
) -> None:
"""Actual implementation of `unclaim_channel`. See that for full documentation."""
await _caches.claimants.delete(channel.id)
+ await _caches.session_participants.delete(channel.id)
claimant = self.bot.get_guild(constants.Guild.id).get_member(claimant_id)
if claimant is None:
@@ -466,7 +475,9 @@ class HelpChannels(commands.Cog):
if channel_utils.is_in_category(message.channel, constants.Categories.help_available):
if not _channel.is_excluded_channel(message.channel):
await self.claim_channel(message)
- else:
+
+ elif channel_utils.is_in_category(message.channel, constants.Categories.help_in_use):
+ await self.notify_session_participants(message)
await _message.update_message_caches(message)
@commands.Cog.listener()
@@ -507,11 +518,6 @@ class HelpChannels(commands.Cog):
async def update_available_help_channels(self) -> None:
"""Updates the dynamic message within #how-to-get-help for available help channels."""
- if not self.available_help_channels:
- self.available_help_channels = set(
- c for c in self.available_category.channels if not _channel.is_excluded_channel(c)
- )
-
available_channels = AVAILABLE_HELP_CHANNELS.format(
available=", ".join(
c.mention for c in sorted(self.available_help_channels, key=attrgetter("position"))
@@ -535,3 +541,91 @@ class HelpChannels(commands.Cog):
)
self.dynamic_message = new_dynamic_message["id"]
await _caches.dynamic_message.set("message_id", self.dynamic_message)
+
+ @staticmethod
+ def _serialise_session_participants(participants: set[int]) -> str:
+ """Convert a set to a comma separated string."""
+ return ','.join(str(p) for p in participants)
+
+ @staticmethod
+ def _deserialise_session_participants(s: str) -> set[int]:
+ """Convert a comma separated string into a set."""
+ return set(int(user_id) for user_id in s.split(",") if user_id != "")
+
+ @lock.lock_arg(NAMESPACE, "message", attrgetter("channel.id"))
+ @lock.lock_arg(NAMESPACE, "message", attrgetter("author.id"))
+ async def notify_session_participants(self, message: discord.Message) -> None:
+ """
+ Check if the message author meets the requirements to be notified.
+
+ If they meet the requirements they are notified.
+ """
+ if await _caches.claimants.get(message.channel.id) == message.author.id:
+ return # Ignore messages sent by claimants
+
+ if not await _caches.help_dm.get(message.author.id):
+ return # Ignore message if user is opted out of help dms
+
+ if (await self.bot.get_context(message)).command == self.close_command:
+ return # Ignore messages that are closing the channel
+
+ session_participants = self._deserialise_session_participants(
+ await _caches.session_participants.get(message.channel.id) or ""
+ )
+
+ if message.author.id not in session_participants:
+ session_participants.add(message.author.id)
+
+ embed = discord.Embed(
+ title="Currently Helping",
+ description=f"You're currently helping in {message.channel.mention}",
+ color=constants.Colours.soft_green,
+ timestamp=message.created_at
+ )
+ embed.add_field(name="Conversation", value=f"[Jump to message]({message.jump_url})")
+
+ try:
+ await message.author.send(embed=embed)
+ except discord.Forbidden:
+ log.trace(
+ f"Failed to send helpdm message to {message.author.id}. DMs Closed/Blocked. "
+ "Removing user from helpdm."
+ )
+ bot_commands_channel = self.bot.get_channel(Channels.bot_commands)
+ await _caches.help_dm.delete(message.author.id)
+ await bot_commands_channel.send(
+ f"{message.author.mention} {constants.Emojis.cross_mark} "
+ "To receive updates on help channels you're active in, enable your DMs.",
+ delete_after=RedirectOutput.delete_delay
+ )
+ return
+
+ await _caches.session_participants.set(
+ message.channel.id,
+ self._serialise_session_participants(session_participants)
+ )
+
+ @commands.command(name="helpdm")
+ async def helpdm_command(
+ self,
+ ctx: commands.Context,
+ state_bool: bool
+ ) -> None:
+ """
+ Allows user to toggle "Helping" dms.
+
+ If this is set to on the user will receive a dm for the channel they are participating in.
+
+ If this is set to off the user will not receive a dm for channel that they are participating in.
+ """
+ state_str = "ON" if state_bool else "OFF"
+
+ if state_bool == await _caches.help_dm.get(ctx.author.id, False):
+ await ctx.send(f"{constants.Emojis.cross_mark} {ctx.author.mention} Help DMs are already {state_str}")
+ return
+
+ if state_bool:
+ await _caches.help_dm.set(ctx.author.id, True)
+ else:
+ await _caches.help_dm.delete(ctx.author.id)
+ await ctx.send(f"{constants.Emojis.ok_hand} {ctx.author.mention} Help DMs {state_str}!")
diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py
index afd698ffe..077b20b47 100644
--- a/bot/exts/help_channels/_message.py
+++ b/bot/exts/help_channels/_message.py
@@ -9,22 +9,20 @@ from arrow import Arrow
import bot
from bot import constants
from bot.exts.help_channels import _caches
-from bot.utils.channel import is_in_category
log = logging.getLogger(__name__)
ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/"
AVAILABLE_MSG = f"""
-**Send your question here to claim the channel**
-This channel will be dedicated to answering your question only. Others will try to answer and help you solve the issue.
+Send your question here to claim the channel.
-**Keep in mind:**
-• It's always ok to just ask your question. You don't need permission.
-• Explain what you expect to happen and what actually happens.
-• Include a code sample and error message, if you got any.
+**Remember to:**
+• **Ask** your Python question, not if you can ask or if there's an expert who can help.
+• **Show** a code sample as text (rather than a screenshot) and the error message, if you got one.
+• **Explain** what you expect to happen and what actually happens.
-For more tips, check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**.
+For more tips, check out our guide on [asking good questions]({ASKING_GUIDE_URL}).
"""
AVAILABLE_TITLE = "Available help channel"
@@ -32,12 +30,12 @@ AVAILABLE_TITLE = "Available help channel"
AVAILABLE_FOOTER = "Closes after a period of inactivity, or when you send !close."
DORMANT_MSG = f"""
-This help channel has been marked as **dormant**, and has been moved into the **Help: Dormant** \
+This help channel has been marked as **dormant**, and has been moved into the **{{dormant}}** \
category at the bottom of the channel list. It is no longer possible to send messages in this \
channel until it becomes available again.
If your question wasn't answered yet, you can claim a new help channel from the \
-**Help: Available** category by simply asking your question again. Consider rephrasing the \
+**{{available}}** category by simply asking your question again. Consider rephrasing the \
question to maximize your chance of getting a good answer. If you're not sure how, have a look \
through our guide for **[asking a good question]({ASKING_GUIDE_URL})**.
"""
@@ -47,23 +45,21 @@ async def update_message_caches(message: discord.Message) -> None:
"""Checks the source of new content in a help channel and updates the appropriate cache."""
channel = message.channel
- # Confirm the channel is an in use help channel
- if is_in_category(channel, constants.Categories.help_in_use):
- log.trace(f"Checking if #{channel} ({channel.id}) has had a reply.")
+ log.trace(f"Checking if #{channel} ({channel.id}) has had a reply.")
- claimant_id = await _caches.claimants.get(channel.id)
- if not claimant_id:
- # The mapping for this channel doesn't exist, we can't do anything.
- return
+ claimant_id = await _caches.claimants.get(channel.id)
+ if not claimant_id:
+ # The mapping for this channel doesn't exist, we can't do anything.
+ return
- # datetime.timestamp() would assume it's local, despite d.py giving a (naïve) UTC time.
- timestamp = Arrow.fromdatetime(message.created_at).timestamp()
+ # datetime.timestamp() would assume it's local, despite d.py giving a (naïve) UTC time.
+ timestamp = Arrow.fromdatetime(message.created_at).timestamp()
- # Overwrite the appropriate last message cache depending on the author of the message
- if message.author.id == claimant_id:
- await _caches.claimant_last_message_times.set(channel.id, timestamp)
- else:
- await _caches.non_claimant_last_message_times.set(channel.id, timestamp)
+ # Overwrite the appropriate last message cache depending on the author of the message
+ if message.author.id == claimant_id:
+ await _caches.claimant_last_message_times.set(channel.id, timestamp)
+ else:
+ await _caches.non_claimant_last_message_times.set(channel.id, timestamp)
async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]:
diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py
index 24a9ae28a..4a90a0668 100644
--- a/bot/exts/info/code_snippets.py
+++ b/bot/exts/info/code_snippets.py
@@ -4,8 +4,8 @@ import textwrap
from typing import Any
from urllib.parse import quote_plus
+import discord
from aiohttp import ClientResponseError
-from discord import Message
from discord.ext.commands import Cog
from bot.bot import Bot
@@ -45,6 +45,17 @@ class CodeSnippets(Cog):
Matches each message against a regex and prints the contents of all matched snippets.
"""
+ def __init__(self, bot: Bot):
+ """Initializes the cog's bot."""
+ self.bot = bot
+
+ self.pattern_handlers = [
+ (GITHUB_RE, self._fetch_github_snippet),
+ (GITHUB_GIST_RE, self._fetch_github_gist_snippet),
+ (GITLAB_RE, self._fetch_gitlab_snippet),
+ (BITBUCKET_RE, self._fetch_bitbucket_snippet)
+ ]
+
async def _fetch_response(self, url: str, response_format: str, **kwargs) -> Any:
"""Makes http requests using aiohttp."""
async with self.bot.http_session.get(url, raise_for_status=True, **kwargs) as response:
@@ -208,56 +219,56 @@ class CodeSnippets(Cog):
# Returns an empty codeblock if the snippet is empty
return f'{ret}``` ```'
- def __init__(self, bot: Bot):
- """Initializes the cog's bot."""
- self.bot = bot
+ async def _parse_snippets(self, content: str) -> str:
+ """Parse message content and return a string with a code block for each URL found."""
+ all_snippets = []
+
+ for pattern, handler in self.pattern_handlers:
+ for match in pattern.finditer(content):
+ try:
+ snippet = await handler(**match.groupdict())
+ all_snippets.append((match.start(), snippet))
+ except ClientResponseError as error:
+ error_message = error.message # noqa: B306
+ log.log(
+ logging.DEBUG if error.status == 404 else logging.ERROR,
+ f'Failed to fetch code snippet from {match[0]!r}: {error.status} '
+ f'{error_message} for GET {error.request_info.real_url.human_repr()}'
+ )
- self.pattern_handlers = [
- (GITHUB_RE, self._fetch_github_snippet),
- (GITHUB_GIST_RE, self._fetch_github_gist_snippet),
- (GITLAB_RE, self._fetch_gitlab_snippet),
- (BITBUCKET_RE, self._fetch_bitbucket_snippet)
- ]
+ # Sorts the list of snippets by their match index and joins them into a single message
+ return '\n'.join(map(lambda x: x[1], sorted(all_snippets)))
@Cog.listener()
- async def on_message(self, message: Message) -> None:
+ async def on_message(self, message: discord.Message) -> None:
"""Checks if the message has a snippet link, removes the embed, then sends the snippet contents."""
- if not message.author.bot:
- all_snippets = []
-
- for pattern, handler in self.pattern_handlers:
- for match in pattern.finditer(message.content):
- try:
- snippet = await handler(**match.groupdict())
- all_snippets.append((match.start(), snippet))
- except ClientResponseError as error:
- error_message = error.message # noqa: B306
- log.log(
- logging.DEBUG if error.status == 404 else logging.ERROR,
- f'Failed to fetch code snippet from {match[0]!r}: {error.status} '
- f'{error_message} for GET {error.request_info.real_url.human_repr()}'
- )
-
- # Sorts the list of snippets by their match index and joins them into a single message
- message_to_send = '\n'.join(map(lambda x: x[1], sorted(all_snippets)))
-
- if 0 < len(message_to_send) <= 2000 and message_to_send.count('\n') <= 15:
+ if message.author.bot:
+ return
+
+ message_to_send = await self._parse_snippets(message.content)
+ destination = message.channel
+
+ if 0 < len(message_to_send) <= 2000 and message_to_send.count('\n') <= 15:
+ try:
await message.edit(suppress=True)
- if len(message_to_send) > 1000 and message.channel.id != Channels.bot_commands:
- # Redirects to #bot-commands if the snippet contents are too long
- await self.bot.wait_until_guild_available()
- await message.channel.send(('The snippet you tried to send was too long. Please '
- f'see <#{Channels.bot_commands}> for the full snippet.'))
- bot_commands_channel = self.bot.get_channel(Channels.bot_commands)
- await wait_for_deletion(
- await bot_commands_channel.send(message_to_send),
- (message.author.id,)
- )
- else:
- await wait_for_deletion(
- await message.channel.send(message_to_send),
- (message.author.id,)
- )
+ except discord.NotFound:
+ # Don't send snippets if the original message was deleted.
+ return
+
+ if len(message_to_send) > 1000 and message.channel.id != Channels.bot_commands:
+ # Redirects to #bot-commands if the snippet contents are too long
+ await self.bot.wait_until_guild_available()
+ destination = self.bot.get_channel(Channels.bot_commands)
+
+ await message.channel.send(
+ 'The snippet you tried to send was too long. '
+ f'Please see {destination.mention} for the full snippet.'
+ )
+
+ await wait_for_deletion(
+ await destination.send(message_to_send),
+ (message.author.id,)
+ )
def setup(bot: Bot) -> None:
diff --git a/bot/exts/info/codeblock/_cog.py b/bot/exts/info/codeblock/_cog.py
index 9094d9d15..9a0705d2b 100644
--- a/bot/exts/info/codeblock/_cog.py
+++ b/bot/exts/info/codeblock/_cog.py
@@ -177,10 +177,13 @@ class CodeBlockCog(Cog, name="Code Block"):
if not bot_message:
return
- if not instructions:
- log.info("User's incorrect code block has been fixed. Removing instructions message.")
- await bot_message.delete()
- del self.codeblock_message_ids[payload.message_id]
- else:
- log.info("Message edited but still has invalid code blocks; editing the instructions.")
- await bot_message.edit(embed=self.create_embed(instructions))
+ try:
+ if not instructions:
+ log.info("User's incorrect code block was fixed. Removing instructions message.")
+ await bot_message.delete()
+ del self.codeblock_message_ids[payload.message_id]
+ else:
+ log.info("Message edited but still has invalid code blocks; editing instructions.")
+ await bot_message.edit(embed=self.create_embed(instructions))
+ except discord.NotFound:
+ log.debug("Could not find instructions message; it was probably deleted.")
diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py
index c54a3ee1c..fb9b2584a 100644
--- a/bot/exts/info/doc/_cog.py
+++ b/bot/exts/info/doc/_cog.py
@@ -341,10 +341,13 @@ class DocCog(commands.Cog):
if doc_embed is None:
error_message = await send_denial(ctx, "No documentation found for the requested symbol.")
await wait_for_deletion(error_message, (ctx.author.id,), timeout=NOT_FOUND_DELETE_DELAY)
- with suppress(discord.NotFound):
- await ctx.message.delete()
- with suppress(discord.NotFound):
- await error_message.delete()
+
+ # Make sure that we won't cause a ghost-ping by deleting the message
+ if not (ctx.message.mentions or ctx.message.role_mentions):
+ with suppress(discord.NotFound):
+ await ctx.message.delete()
+ await error_message.delete()
+
else:
msg = await ctx.send(embed=doc_embed)
await wait_for_deletion(msg, (ctx.author.id,))
diff --git a/bot/exts/info/doc/_parsing.py b/bot/exts/info/doc/_parsing.py
index bf840b96f..1a0d42c47 100644
--- a/bot/exts/info/doc/_parsing.py
+++ b/bot/exts/info/doc/_parsing.py
@@ -34,7 +34,7 @@ _EMBED_CODE_BLOCK_LINE_LENGTH = 61
# _MAX_SIGNATURE_AMOUNT code block wrapped lines with py syntax highlight
_MAX_SIGNATURES_LENGTH = (_EMBED_CODE_BLOCK_LINE_LENGTH + 8) * MAX_SIGNATURE_AMOUNT
# Maximum embed description length - signatures on top
-_MAX_DESCRIPTION_LENGTH = 2048 - _MAX_SIGNATURES_LENGTH
+_MAX_DESCRIPTION_LENGTH = 4096 - _MAX_SIGNATURES_LENGTH
_TRUNCATE_STRIP_CHARACTERS = "!?:;." + string.whitespace
BracketPair = namedtuple("BracketPair", ["opening_bracket", "closing_bracket"])
diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py
index 3a05b2c8a..21a6cf752 100644
--- a/bot/exts/info/help.py
+++ b/bot/exts/info/help.py
@@ -6,11 +6,11 @@ from typing import List, Union
from discord import Colour, Embed
from discord.ext.commands import Bot, Cog, Command, CommandError, Context, DisabledCommand, Group, HelpCommand
-from fuzzywuzzy import fuzz, process
-from fuzzywuzzy.utils import full_process
+from rapidfuzz import fuzz, process
+from rapidfuzz.utils import default_process
from bot import constants
-from bot.constants import Channels, STAFF_ROLES
+from bot.constants import Channels, STAFF_PARTNERS_COMMUNITY_ROLES
from bot.decorators import redirect_output
from bot.pagination import LinePaginator
from bot.utils.messages import wait_for_deletion
@@ -54,7 +54,7 @@ class CustomHelpCommand(HelpCommand):
def __init__(self):
super().__init__(command_attrs={"help": "Shows help for bot commands"})
- @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES)
+ @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_PARTNERS_COMMUNITY_ROLES)
async def command_callback(self, ctx: Context, *, command: str = None) -> None:
"""Attempts to match the provided query with a valid command or cog."""
# the only reason we need to tamper with this is because d.py does not support "categories",
@@ -125,16 +125,9 @@ class CustomHelpCommand(HelpCommand):
Will return an instance of the `HelpQueryNotFound` exception with the error message and possible matches.
"""
- choices = await self.get_all_help_choices()
-
- # Run fuzzywuzzy's processor beforehand, and avoid matching if processed string is empty
- # This avoids fuzzywuzzy from raising a warning on inputs with only non-alphanumeric characters
- if (processed := full_process(string)):
- result = process.extractBests(processed, choices, scorer=fuzz.ratio, score_cutoff=60, processor=None)
- else:
- result = []
-
- return HelpQueryNotFound(f'Query "{string}" not found.', dict(result))
+ choices = list(await self.get_all_help_choices())
+ result = process.extract(default_process(string), choices, scorer=fuzz.ratio, score_cutoff=60, processor=None)
+ return HelpQueryNotFound(f'Query "{string}" not found.', {choice[0]: choice[1] for choice in result})
async def subcommand_not_found(self, command: Command, string: str) -> "HelpQueryNotFound":
"""
diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py
index 834fee1b4..bcf8c10d2 100644
--- a/bot/exts/info/information.py
+++ b/bot/exts/info/information.py
@@ -3,21 +3,23 @@ import logging
import pprint
import textwrap
from collections import defaultdict
-from typing import Any, DefaultDict, Dict, Mapping, Optional, Tuple, Union
+from typing import Any, DefaultDict, Mapping, Optional, Tuple, Union
-import fuzzywuzzy
+import rapidfuzz
from discord import AllowedMentions, Colour, Embed, Guild, Message, Role
from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role
+from discord.utils import escape_markdown
from bot import constants
from bot.api import ResponseCodeError
from bot.bot import Bot
-from bot.converters import FetchedMember
+from bot.converters import MemberOrUser
from bot.decorators import in_whitelist
+from bot.errors import NonExistentRoleError
from bot.pagination import LinePaginator
from bot.utils.channel import is_mod_channel, is_staff_channel
from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check
-from bot.utils.time import humanize_delta, time_since
+from bot.utils.time import TimestampFormats, discord_timestamp, humanize_delta
log = logging.getLogger(__name__)
@@ -42,15 +44,29 @@ class Information(Cog):
return channel_counter
@staticmethod
- def get_member_counts(guild: Guild) -> Dict[str, int]:
+ def join_role_stats(role_ids: list[int], guild: Guild, name: Optional[str] = None) -> dict[str, int]:
+ """Return a dictionary with the number of `members` of each role given, and the `name` for this joined group."""
+ members = 0
+ for role_id in role_ids:
+ if (role := guild.get_role(role_id)) is not None:
+ members += len(role.members)
+ else:
+ raise NonExistentRoleError(role_id)
+ return {name or role.name.title(): members}
+
+ @staticmethod
+ def get_member_counts(guild: Guild) -> dict[str, int]:
"""Return the total number of members for certain roles in `guild`."""
- roles = (
- guild.get_role(role_id) for role_id in (
- constants.Roles.helpers, constants.Roles.moderators, constants.Roles.admins,
- constants.Roles.owners, constants.Roles.contributors,
- )
+ role_ids = [constants.Roles.helpers, constants.Roles.mod_team, constants.Roles.admins,
+ constants.Roles.owners, constants.Roles.contributors]
+
+ role_stats = {}
+ for role_id in role_ids:
+ role_stats.update(Information.join_role_stats([role_id], guild))
+ role_stats.update(
+ Information.join_role_stats([constants.Roles.project_leads, constants.Roles.domain_leads], guild, "Leads")
)
- return {role.name.title(): len(role.members) for role in roles}
+ return role_stats
def get_extended_server_info(self, ctx: Context) -> str:
"""Return additional server info only visible in moderation channels."""
@@ -79,7 +95,7 @@ class Information(Cog):
{python_general.mention} cooldown: {python_general.slowmode_delay}s
""")
- @has_any_role(*constants.STAFF_ROLES)
+ @has_any_role(*constants.STAFF_PARTNERS_COMMUNITY_ROLES)
@command(name="roles")
async def roles_info(self, ctx: Context) -> None:
"""Returns a list of all roles and their corresponding IDs."""
@@ -99,7 +115,7 @@ class Information(Cog):
await LinePaginator.paginate(role_list, ctx, embed, empty=False)
- @has_any_role(*constants.STAFF_ROLES)
+ @has_any_role(*constants.STAFF_PARTNERS_COMMUNITY_ROLES)
@command(name="role")
async def role_info(self, ctx: Context, *roles: Union[Role, str]) -> None:
"""
@@ -117,9 +133,9 @@ class Information(Cog):
parsed_roles.add(role_name)
continue
- match = fuzzywuzzy.process.extractOne(
+ match = rapidfuzz.process.extractOne(
role_name, all_roles, score_cutoff=80,
- scorer=fuzzywuzzy.fuzz.ratio
+ scorer=rapidfuzz.fuzz.ratio
)
if not match:
@@ -154,7 +170,7 @@ class Information(Cog):
"""Returns an embed full of server information."""
embed = Embed(colour=Colour.blurple(), title="Server Information")
- created = time_since(ctx.guild.created_at, precision="days")
+ created = discord_timestamp(ctx.guild.created_at, TimestampFormats.RELATIVE)
region = ctx.guild.region
num_roles = len(ctx.guild.roles) - 1 # Exclude @everyone
@@ -171,21 +187,21 @@ class Information(Cog):
online_presences = py_invite.approximate_presence_count
offline_presences = py_invite.approximate_member_count - online_presences
member_status = (
- f"{constants.Emojis.status_online} {online_presences} "
- f"{constants.Emojis.status_offline} {offline_presences}"
+ f"{constants.Emojis.status_online} {online_presences:,} "
+ f"{constants.Emojis.status_offline} {offline_presences:,}"
)
- embed.description = textwrap.dedent(f"""
- Created: {created}
- Voice region: {region}\
- {features}
- Roles: {num_roles}
- Member status: {member_status}
- """)
+ embed.description = (
+ f"Created: {created}"
+ f"\nVoice region: {region}"
+ f"{features}"
+ f"\nRoles: {num_roles}"
+ f"\nMember status: {member_status}"
+ )
embed.set_thumbnail(url=ctx.guild.icon_url)
# Members
- total_members = ctx.guild.member_count
+ total_members = f"{ctx.guild.member_count:,}"
member_counts = self.get_member_counts(ctx.guild)
member_info = "\n".join(f"{role}: {count}" for role, count in member_counts.items())
embed.add_field(name=f"Members: {total_members}", value=member_info)
@@ -205,8 +221,13 @@ class Information(Cog):
await ctx.send(embed=embed)
@command(name="user", aliases=["user_info", "member", "member_info", "u"])
- async def user_info(self, ctx: Context, user: FetchedMember = None) -> None:
+ async def user_info(self, ctx: Context, user_or_message: Union[MemberOrUser, Message] = None) -> None:
"""Returns info about a user."""
+ if isinstance(user_or_message, Message):
+ user = user_or_message.author
+ else:
+ user = user_or_message
+
if user is None:
user = ctx.author
@@ -216,19 +237,20 @@ class Information(Cog):
return
# Will redirect to #bot-commands if it fails.
- if in_whitelist_check(ctx, roles=constants.STAFF_ROLES):
+ if in_whitelist_check(ctx, roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES):
embed = await self.create_user_embed(ctx, user)
await ctx.send(embed=embed)
- async def create_user_embed(self, ctx: Context, user: FetchedMember) -> Embed:
+ async def create_user_embed(self, ctx: Context, user: MemberOrUser) -> Embed:
"""Creates an embed containing information on the `user`."""
on_server = bool(ctx.guild.get_member(user.id))
- created = time_since(user.created_at, max_units=3)
+ created = discord_timestamp(user.created_at, TimestampFormats.RELATIVE)
name = str(user)
if on_server and user.nick:
name = f"{user.nick} ({name})"
+ name = escape_markdown(name)
if user.public_flags.verified_bot:
name += f" {constants.Emojis.verified_bot}"
@@ -241,11 +263,15 @@ class Information(Cog):
if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}", None)):
badges.append(emoji)
- activity = await self.user_messages(user)
-
if on_server:
- joined = time_since(user.joined_at, max_units=3)
- roles = ", ".join(role.mention for role in user.roles[1:])
+ if user.joined_at:
+ joined = discord_timestamp(user.joined_at, TimestampFormats.RELATIVE)
+ else:
+ joined = "Unable to get join date"
+
+ # The 0 is for excluding the default @everyone role,
+ # and the -1 is for reversing the order of the roles to highest to lowest in hierarchy.
+ roles = ", ".join(role.mention for role in user.roles[:0:-1])
membership = {"Joined": joined, "Verified": not user.pending, "Roles": roles or None}
if not is_mod_channel(ctx.channel):
membership.pop("Verified")
@@ -272,8 +298,7 @@ class Information(Cog):
# Show more verbose output in moderation channels for infractions and nominations
if is_mod_channel(ctx.channel):
- fields.append(activity)
-
+ fields.append(await self.user_messages(user))
fields.append(await self.expanded_user_infraction_counts(user))
fields.append(await self.user_nomination_counts(user))
else:
@@ -293,7 +318,7 @@ class Information(Cog):
return embed
- async def basic_user_infraction_counts(self, user: FetchedMember) -> Tuple[str, str]:
+ async def basic_user_infraction_counts(self, user: MemberOrUser) -> Tuple[str, str]:
"""Gets the total and active infraction counts for the given `member`."""
infractions = await self.bot.api_client.get(
'bot/infractions',
@@ -310,7 +335,7 @@ class Information(Cog):
return "Infractions", infraction_output
- async def expanded_user_infraction_counts(self, user: FetchedMember) -> Tuple[str, str]:
+ async def expanded_user_infraction_counts(self, user: MemberOrUser) -> Tuple[str, str]:
"""
Gets expanded infraction counts for the given `member`.
@@ -351,7 +376,7 @@ class Information(Cog):
return "Infractions", "\n".join(infraction_output)
- async def user_nomination_counts(self, user: FetchedMember) -> Tuple[str, str]:
+ async def user_nomination_counts(self, user: MemberOrUser) -> Tuple[str, str]:
"""Gets the active and historical nomination counts for the given `member`."""
nominations = await self.bot.api_client.get(
'bot/nominations',
@@ -376,7 +401,7 @@ class Information(Cog):
return "Nominations", "\n".join(output)
- async def user_messages(self, user: FetchedMember) -> Tuple[Union[bool, str], Tuple[str, str]]:
+ async def user_messages(self, user: MemberOrUser) -> Tuple[Union[bool, str], Tuple[str, str]]:
"""
Gets the amount of messages for `member`.
@@ -435,11 +460,12 @@ class Information(Cog):
# remove trailing whitespace
return out.rstrip()
- @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES)
- @group(invoke_without_command=True)
- @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_ROLES)
- async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None:
- """Shows information about the raw API response."""
+ async def send_raw_content(self, ctx: Context, message: Message, json: bool = False) -> None:
+ """
+ Send information about the raw API response for a `discord.Message`.
+
+ If `json` is True, send the information in a copy-pasteable Python format.
+ """
if ctx.author not in message.channel.members:
await ctx.send(":x: You do not have permissions to see the channel this message is in.")
return
@@ -475,10 +501,17 @@ class Information(Cog):
for page in paginator.pages:
await ctx.send(page, allowed_mentions=AllowedMentions.none())
+ @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES)
+ @group(invoke_without_command=True)
+ @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES)
+ async def raw(self, ctx: Context, message: Message) -> None:
+ """Shows information about the raw API response."""
+ await self.send_raw_content(ctx, message)
+
@raw.command()
async def json(self, ctx: Context, message: Message) -> None:
"""Shows information about the raw API response in a copy-pasteable Python format."""
- await ctx.invoke(self.raw, message=message, json=True)
+ await self.send_raw_content(ctx, message, json=True)
def setup(bot: Bot) -> None:
diff --git a/bot/exts/info/pep.py b/bot/exts/info/pep.py
index 8ac96bbdb..b11b34db0 100644
--- a/bot/exts/info/pep.py
+++ b/bot/exts/info/pep.py
@@ -9,7 +9,7 @@ from discord.ext.commands import Cog, Context, command
from bot.bot import Bot
from bot.constants import Keys
-from bot.utils.cache import AsyncCache
+from bot.utils.caching import AsyncCache
log = logging.getLogger(__name__)
diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py
index 2e42e7d6b..62498ce0b 100644
--- a/bot/exts/info/pypi.py
+++ b/bot/exts/info/pypi.py
@@ -2,13 +2,15 @@ import itertools
import logging
import random
import re
+from contextlib import suppress
-from discord import Embed
+from discord import Embed, NotFound
from discord.ext.commands import Cog, Context, command
from discord.utils import escape_markdown
from bot.bot import Bot
from bot.constants import Colours, NEGATIVE_REPLIES, RedirectOutput
+from bot.utils.messages import wait_for_deletion
URL = "https://pypi.org/pypi/{package}/json"
PYPI_ICON = "https://cdn.discordapp.com/emojis/766274397257334814.png"
@@ -67,8 +69,15 @@ class PyPi(Cog):
log.trace(f"Error when fetching PyPi package: {response.status}.")
if error:
- await ctx.send(embed=embed, delete_after=INVALID_INPUT_DELETE_DELAY)
- await ctx.message.delete(delay=INVALID_INPUT_DELETE_DELAY)
+ error_message = await ctx.send(embed=embed)
+ await wait_for_deletion(error_message, (ctx.author.id,), timeout=INVALID_INPUT_DELETE_DELAY)
+
+ # Make sure that we won't cause a ghost-ping by deleting the message
+ if not (ctx.message.mentions or ctx.message.role_mentions):
+ with suppress(NotFound):
+ await ctx.message.delete()
+ await error_message.delete()
+
else:
await ctx.send(embed=embed)
diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py
index 0ab5738a4..63eb4ac17 100644
--- a/bot/exts/info/python_news.py
+++ b/bot/exts/info/python_news.py
@@ -1,4 +1,5 @@
import logging
+import re
import typing as t
from datetime import date, datetime
@@ -72,6 +73,11 @@ class PythonNews(Cog):
if mail["name"].split("@")[0] in constants.PythonNews.mail_lists:
self.webhook_names[mail["name"].split("@")[0]] = mail["display_name"]
+ @staticmethod
+ def escape_markdown(content: str) -> str:
+ """Escape the markdown underlines and spoilers."""
+ return re.sub(r"[_|]", lambda match: "\\" + match[0], content)
+
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
@@ -103,7 +109,7 @@ class PythonNews(Cog):
# Build an embed and send a webhook
embed = discord.Embed(
title=new["title"],
- description=new["summary"],
+ description=self.escape_markdown(new["summary"]),
timestamp=new_datetime,
url=new["link"],
colour=constants.Colours.soft_green
@@ -167,13 +173,13 @@ class PythonNews(Cog):
):
continue
- content = email_information["content"]
+ content = self.escape_markdown(email_information["content"])
link = THREAD_URL.format(id=thread["href"].split("/")[-2], list=maillist)
# Build an embed and send a message to the webhook
embed = discord.Embed(
title=thread_information["subject"],
- description=content[:500] + f"... [continue reading]({link})" if len(content) > 500 else content,
+ description=content[:1000] + f"... [continue reading]({link})" if len(content) > 1000 else content,
timestamp=new_date,
url=link,
colour=constants.Colours.soft_green
diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py
index fb5b99086..28eb558a6 100644
--- a/bot/exts/info/site.py
+++ b/bot/exts/info/site.py
@@ -9,7 +9,7 @@ from bot.pagination import LinePaginator
log = logging.getLogger(__name__)
-PAGES_URL = f"{URLs.site_schema}{URLs.site}/pages"
+BASE_URL = f"{URLs.site_schema}{URLs.site}"
class Site(Cog):
@@ -43,7 +43,7 @@ class Site(Cog):
@site_group.command(name="resources", root_aliases=("resources", "resource"))
async def site_resources(self, ctx: Context) -> None:
"""Info about the site's Resources page."""
- learning_url = f"{PAGES_URL}/resources"
+ learning_url = f"{BASE_URL}/resources"
embed = Embed(title="Resources")
embed.set_footer(text=f"{learning_url}")
@@ -59,7 +59,7 @@ class Site(Cog):
@site_group.command(name="tools", root_aliases=("tools",))
async def site_tools(self, ctx: Context) -> None:
"""Info about the site's Tools page."""
- tools_url = f"{PAGES_URL}/resources/tools"
+ tools_url = f"{BASE_URL}/resources/tools"
embed = Embed(title="Tools")
embed.set_footer(text=f"{tools_url}")
@@ -74,7 +74,7 @@ class Site(Cog):
@site_group.command(name="help")
async def site_help(self, ctx: Context) -> None:
"""Info about the site's Getting Help page."""
- url = f"{PAGES_URL}/resources/guides/asking-good-questions"
+ url = f"{BASE_URL}/pages/guides/pydis-guides/asking-good-questions/"
embed = Embed(title="Asking Good Questions")
embed.set_footer(text=url)
@@ -90,7 +90,7 @@ class Site(Cog):
@site_group.command(name="faq", root_aliases=("faq",))
async def site_faq(self, ctx: Context) -> None:
"""Info about the site's FAQ page."""
- url = f"{PAGES_URL}/frequently-asked-questions"
+ url = f"{BASE_URL}/pages/frequently-asked-questions"
embed = Embed(title="FAQ")
embed.set_footer(text=url)
@@ -107,13 +107,13 @@ class Site(Cog):
@site_group.command(name="rules", aliases=("r", "rule"), root_aliases=("rules", "rule"))
async def site_rules(self, ctx: Context, rules: Greedy[int]) -> None:
"""Provides a link to all rules or, if specified, displays specific rule(s)."""
- rules_embed = Embed(title='Rules', color=Colour.blurple(), url=f'{PAGES_URL}/rules')
+ rules_embed = Embed(title='Rules', color=Colour.blurple(), url=f'{BASE_URL}/pages/rules')
if not rules:
# Rules were not submitted. Return the default description.
rules_embed.description = (
"The rules and guidelines that apply to this community can be found on"
- f" our [rules page]({PAGES_URL}/rules). We expect"
+ f" our [rules page]({BASE_URL}/pages/rules). We expect"
" all members of the community to have read and understood these."
)
diff --git a/bot/exts/info/source.py b/bot/exts/info/source.py
index ef07c77a1..8ce25b4e8 100644
--- a/bot/exts/info/source.py
+++ b/bot/exts/info/source.py
@@ -2,47 +2,16 @@ import inspect
from pathlib import Path
from typing import Optional, Tuple, Union
-from discord import Embed, utils
+from discord import Embed
from discord.ext import commands
from bot.bot import Bot
from bot.constants import URLs
+from bot.converters import SourceConverter
SourceType = Union[commands.HelpCommand, commands.Command, commands.Cog, str, commands.ExtensionNotLoaded]
-class SourceConverter(commands.Converter):
- """Convert an argument into a help command, tag, command, or cog."""
-
- @staticmethod
- async def convert(ctx: commands.Context, argument: str) -> SourceType:
- """Convert argument into source object."""
- if argument.lower() == "help":
- return ctx.bot.help_command
-
- cog = ctx.bot.get_cog(argument)
- if cog:
- return cog
-
- cmd = ctx.bot.get_command(argument)
- if cmd:
- return cmd
-
- tags_cog = ctx.bot.get_cog("Tags")
- show_tag = True
-
- if not tags_cog:
- show_tag = False
- elif argument.lower() in tags_cog._cache:
- return argument.lower()
-
- escaped_arg = utils.escape_markdown(argument)
-
- raise commands.BadArgument(
- f"Unable to convert '{escaped_arg}' to valid command{', tag,' if show_tag else ''} or Cog."
- )
-
-
class BotSource(commands.Cog):
"""Displays information about the bot's source code."""
diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py
index dfb1afd19..6ac077b93 100644
--- a/bot/exts/moderation/defcon.py
+++ b/bot/exts/moderation/defcon.py
@@ -9,7 +9,7 @@ from typing import Optional, Union
from aioredis import RedisError
from async_rediscache import RedisCache
from dateutil.relativedelta import relativedelta
-from discord import Colour, Embed, Member, User
+from discord import Colour, Embed, Forbidden, Member, User
from discord.ext import tasks
from discord.ext.commands import Cog, Context, group, has_any_role
@@ -19,7 +19,9 @@ from bot.converters import DurationDelta, Expiry
from bot.exts.moderation.modlog import ModLog
from bot.utils.messages import format_user
from bot.utils.scheduling import Scheduler
-from bot.utils.time import humanize_delta, parse_duration_string, relativedelta_to_timedelta
+from bot.utils.time import (
+ TimestampFormats, discord_timestamp, humanize_delta, parse_duration_string, relativedelta_to_timedelta
+)
log = logging.getLogger(__name__)
@@ -116,10 +118,12 @@ class Defcon(Cog):
try:
await member.send(REJECTION_MESSAGE.format(user=member.mention))
-
message_sent = True
+ except Forbidden:
+ log.debug(f"Cannot send DEFCON rejection DM to {member}: DMs disabled")
except Exception:
- log.exception(f"Unable to send rejection message to user: {member}")
+ # Broadly catch exceptions because DM isn't critical, but it's imperative to kick them.
+ log.exception(f"Error sending DEFCON rejection message to {member}")
await member.kick(reason="DEFCON active, user is too new")
self.bot.stats.incr("defcon.leaves")
@@ -150,7 +154,7 @@ class Defcon(Cog):
colour=Colour.blurple(), title="DEFCON Status",
description=f"""
**Threshold:** {humanize_delta(self.threshold) if self.threshold else "-"}
- **Expires in:** {humanize_delta(relativedelta(self.expiry, datetime.utcnow())) if self.expiry else "-"}
+ **Expires:** {discord_timestamp(self.expiry, TimestampFormats.RELATIVE) if self.expiry else "-"}
**Verification level:** {ctx.guild.verification_level.name}
"""
)
diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py
index 0e479d33f..561e0251e 100644
--- a/bot/exts/moderation/incidents.py
+++ b/bot/exts/moderation/incidents.py
@@ -143,7 +143,14 @@ async def add_signals(incident: discord.Message) -> None:
log.trace(f"Skipping emoji as it's already been placed: {signal_emoji}")
else:
log.trace(f"Adding reaction: {signal_emoji}")
- await incident.add_reaction(signal_emoji.value)
+ try:
+ await incident.add_reaction(signal_emoji.value)
+ except discord.NotFound as e:
+ if e.code != 10008:
+ raise
+
+ log.trace(f"Couldn't react with signal because message {incident.id} was deleted; skipping incident")
+ return
class Incidents(Cog):
@@ -288,14 +295,20 @@ class Incidents(Cog):
members_roles: t.Set[int] = {role.id for role in member.roles}
if not members_roles & ALLOWED_ROLES: # Intersection is truthy on at least 1 common element
log.debug(f"Removing invalid reaction: user {member} is not permitted to send signals")
- await incident.remove_reaction(reaction, member)
+ try:
+ await incident.remove_reaction(reaction, member)
+ except discord.NotFound:
+ log.trace("Couldn't remove reaction because the reaction or its message was deleted")
return
try:
signal = Signal(reaction)
except ValueError:
log.debug(f"Removing invalid reaction: emoji {reaction} is not a valid signal")
- await incident.remove_reaction(reaction, member)
+ try:
+ await incident.remove_reaction(reaction, member)
+ except discord.NotFound:
+ log.trace("Couldn't remove reaction because the reaction or its message was deleted")
return
log.trace(f"Received signal: {signal}")
@@ -313,7 +326,10 @@ class Incidents(Cog):
confirmation_task = self.make_confirmation_task(incident, timeout)
log.trace("Deleting original message")
- await incident.delete()
+ try:
+ await incident.delete()
+ except discord.NotFound:
+ log.trace("Couldn't delete message because it was already deleted")
log.trace(f"Awaiting deletion confirmation: {timeout=} seconds")
try:
diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py
index 988fb7220..6ba4e74e9 100644
--- a/bot/exts/moderation/infraction/_scheduler.py
+++ b/bot/exts/moderation/infraction/_scheduler.py
@@ -13,8 +13,8 @@ from bot import constants
from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.constants import Colours
+from bot.converters import MemberOrUser
from bot.exts.moderation.infraction import _utils
-from bot.exts.moderation.infraction._utils import UserSnowflake
from bot.exts.moderation.modlog import ModLog
from bot.utils import messages, scheduling, time
from bot.utils.channel import is_mod_channel
@@ -47,12 +47,33 @@ class InfractionScheduler:
log.trace(f"Rescheduling infractions for {self.__class__.__name__}.")
infractions = await self.bot.api_client.get(
- 'bot/infractions',
- params={'active': 'true'}
+ "bot/infractions",
+ params={
+ "active": "true",
+ "ordering": "expires_at",
+ "permanent": "false",
+ "types": ",".join(supported_infractions),
+ },
)
- for infraction in infractions:
- if infraction["expires_at"] is not None and infraction["type"] in supported_infractions:
- self.schedule_expiration(infraction)
+
+ to_schedule = [i for i in infractions if i["id"] not in self.scheduler]
+
+ for infraction in to_schedule:
+ log.trace("Scheduling %r", infraction)
+ self.schedule_expiration(infraction)
+
+ # Call ourselves again when the last infraction would expire. This will be the "oldest" infraction we've seen
+ # from the database so far, and new ones are scheduled as part of application.
+ # We make sure to fire this
+ if to_schedule:
+ next_reschedule_point = max(
+ dateutil.parser.isoparse(infr["expires_at"]).replace(tzinfo=None) for infr in to_schedule
+ )
+ log.trace("Will reschedule remaining infractions at %s", next_reschedule_point)
+
+ self.scheduler.schedule_at(next_reschedule_point, -1, self.reschedule_infractions(supported_infractions))
+
+ log.trace("Done rescheduling")
async def reapply_infraction(
self,
@@ -94,7 +115,7 @@ class InfractionScheduler:
self,
ctx: Context,
infraction: _utils.Infraction,
- user: UserSnowflake,
+ user: MemberOrUser,
action_coro: t.Optional[t.Awaitable] = None,
user_reason: t.Optional[str] = None,
additional_info: str = "",
@@ -144,17 +165,10 @@ class InfractionScheduler:
dm_result = f"{constants.Emojis.failmail} "
dm_log_text = "\nDM: **Failed**"
- # Sometimes user is a discord.Object; make it a proper user.
- try:
- if not isinstance(user, (discord.Member, discord.User)):
- user = await self.bot.fetch_user(user.id)
- except discord.HTTPException as e:
- log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})")
- else:
- # Accordingly display whether the user was successfully notified via DM.
- if await _utils.notify_infraction(user, infr_type.replace("_", " ").title(), expiry, user_reason, icon):
- dm_result = ":incoming_envelope: "
- dm_log_text = "\nDM: Sent"
+ # Accordingly display whether the user was successfully notified via DM.
+ if await _utils.notify_infraction(user, infr_type.replace("_", " ").title(), expiry, user_reason, icon):
+ dm_result = ":incoming_envelope: "
+ dm_log_text = "\nDM: Sent"
end_msg = ""
if infraction["actor"] == self.bot.user.id:
@@ -243,14 +257,18 @@ class InfractionScheduler:
self,
ctx: Context,
infr_type: str,
- user: UserSnowflake,
- send_msg: bool = True
+ user: MemberOrUser,
+ *,
+ send_msg: bool = True,
+ notify: 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.
+ the context channel. Otherwise, no such message will be sent.
+
+ If `notify` is True, notify the user of the pardon via DM where applicable.
"""
log.trace(f"Pardoning {infr_type} infraction for {user}.")
@@ -271,7 +289,7 @@ class InfractionScheduler:
return
# Deactivate the infraction and cancel its scheduled expiration task.
- log_text = await self.deactivate_infraction(response[0], send_log=False)
+ log_text = await self.deactivate_infraction(response[0], send_log=False, notify=notify)
log_text["Member"] = messages.format_user(user)
log_text["Actor"] = ctx.author.mention
@@ -324,7 +342,9 @@ class InfractionScheduler:
async def deactivate_infraction(
self,
infraction: _utils.Infraction,
- send_log: bool = True
+ *,
+ send_log: bool = True,
+ notify: bool = True
) -> t.Dict[str, str]:
"""
Deactivate an active infraction and return a dictionary of lines to send in a mod log.
@@ -333,6 +353,8 @@ class InfractionScheduler:
expiration task cancelled. If `send_log` is True, a mod log is sent for the
deactivation of the infraction.
+ If `notify` is True, notify the user of the pardon via DM where applicable.
+
Infractions of unsupported types will raise a ValueError.
"""
guild = self.bot.get_guild(constants.Guild.id)
@@ -359,7 +381,7 @@ class InfractionScheduler:
try:
log.trace("Awaiting the pardon action coroutine.")
- returned_log = await self._pardon_action(infraction)
+ returned_log = await self._pardon_action(infraction, notify)
if returned_log is not None:
log_text = {**log_text, **returned_log} # Merge the logs together
@@ -447,10 +469,15 @@ class InfractionScheduler:
return log_text
@abstractmethod
- async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]:
+ async def _pardon_action(
+ self,
+ infraction: _utils.Infraction,
+ notify: bool
+ ) -> t.Optional[t.Dict[str, str]]:
"""
Execute deactivation steps specific to the infraction's type and return a log dict.
+ If `notify` is True, notify the user of the pardon via DM where applicable.
If an infraction type is unsupported, return None instead.
"""
raise NotImplementedError
diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py
index a98b4828b..b20ef1d06 100644
--- a/bot/exts/moderation/infraction/_utils.py
+++ b/bot/exts/moderation/infraction/_utils.py
@@ -7,7 +7,8 @@ from discord.ext.commands import Context
from bot.api import ResponseCodeError
from bot.constants import Colours, Icons
-from bot.errors import InvalidInfractedUser
+from bot.converters import MemberOrUser
+from bot.errors import InvalidInfractedUserError
log = logging.getLogger(__name__)
@@ -24,8 +25,6 @@ INFRACTION_ICONS = {
RULES_URL = "https://pythondiscord.com/pages/rules"
# Type aliases
-UserObject = t.Union[discord.Member, discord.User]
-UserSnowflake = t.Union[UserObject, discord.Object]
Infraction = t.Dict[str, t.Union[str, int, bool]]
APPEAL_EMAIL = "[email protected]"
@@ -45,7 +44,7 @@ INFRACTION_DESCRIPTION_TEMPLATE = (
)
-async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]:
+async def post_user(ctx: Context, user: MemberOrUser) -> t.Optional[dict]:
"""
Create a new user in the database.
@@ -53,14 +52,11 @@ async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]:
"""
log.trace(f"Attempting to add user {user.id} to the database.")
- if not isinstance(user, (discord.Member, discord.User)):
- log.debug("The user being added to the DB is not a Member or User object.")
-
payload = {
- 'discriminator': int(getattr(user, 'discriminator', 0)),
+ 'discriminator': int(user.discriminator),
'id': user.id,
'in_guild': False,
- 'name': getattr(user, 'name', 'Name unknown'),
+ 'name': user.name,
'roles': []
}
@@ -75,7 +71,7 @@ async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]:
async def post_infraction(
ctx: Context,
- user: UserSnowflake,
+ user: MemberOrUser,
infr_type: str,
reason: str,
expires_at: datetime = None,
@@ -85,7 +81,7 @@ async def post_infraction(
"""Posts an infraction to the API."""
if isinstance(user, (discord.Member, discord.User)) and user.bot:
log.trace(f"Posting of {infr_type} infraction for {user} to the API aborted. User is a bot.")
- raise InvalidInfractedUser(user)
+ raise InvalidInfractedUserError(user)
log.trace(f"Posting {infr_type} infraction for {user} to the API.")
@@ -118,7 +114,7 @@ async def post_infraction(
async def get_active_infraction(
ctx: Context,
- user: UserSnowflake,
+ user: MemberOrUser,
infr_type: str,
send_msg: bool = True
) -> t.Optional[dict]:
@@ -143,17 +139,22 @@ async def get_active_infraction(
# 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']}**."
- )
+ await send_active_infraction_message(ctx, active_infractions[0])
return active_infractions[0]
else:
log.trace(f"{user} does not have active infractions of type {infr_type}.")
+async def send_active_infraction_message(ctx: Context, infraction: Infraction) -> None:
+ """Send a message stating that the given infraction is active."""
+ await ctx.send(
+ f":x: According to my records, this user already has a {infraction['type']} infraction. "
+ f"See infraction **#{infraction['id']}**."
+ )
+
+
async def notify_infraction(
- user: UserObject,
+ user: MemberOrUser,
infr_type: str,
expires_at: t.Optional[str] = None,
reason: t.Optional[str] = None,
@@ -169,8 +170,8 @@ async def notify_infraction(
)
# For case when other fields than reason is too long and this reach limit, then force-shorten string
- if len(text) > 2048:
- text = f"{text[:2045]}..."
+ if len(text) > 4096:
+ text = f"{text[:4093]}..."
embed = discord.Embed(
description=text,
@@ -189,7 +190,7 @@ async def notify_infraction(
async def notify_pardon(
- user: UserObject,
+ user: MemberOrUser,
title: str,
content: str,
icon_url: str = Icons.user_verified
@@ -207,7 +208,7 @@ async def notify_pardon(
return await send_private_embed(user, embed)
-async def send_private_embed(user: UserObject, embed: discord.Embed) -> bool:
+async def send_private_embed(user: MemberOrUser, embed: discord.Embed) -> bool:
"""
A helper method for sending an embed to a user's DMs.
diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py
index f19323c7c..eaba97703 100644
--- a/bot/exts/moderation/infraction/infractions.py
+++ b/bot/exts/moderation/infraction/infractions.py
@@ -10,11 +10,10 @@ from discord.ext.commands import Context, command
from bot import constants
from bot.bot import Bot
from bot.constants import Event
-from bot.converters import Duration, Expiry, FetchedMember
+from bot.converters import Duration, Expiry, MemberOrUser, UnambiguousMemberOrUser
from bot.decorators import respect_role_hierarchy
from bot.exts.moderation.infraction import _utils
from bot.exts.moderation.infraction._scheduler import InfractionScheduler
-from bot.exts.moderation.infraction._utils import UserSnowflake
from bot.utils.messages import format_user
log = logging.getLogger(__name__)
@@ -54,7 +53,7 @@ class Infractions(InfractionScheduler, commands.Cog):
# region: Permanent infractions
@command()
- async def warn(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None:
+ async def warn(self, ctx: Context, user: UnambiguousMemberOrUser, *, reason: t.Optional[str] = None) -> None:
"""Warn a user for the given reason."""
if not isinstance(user, Member):
await ctx.send(":x: The user doesn't appear to be on the server.")
@@ -67,7 +66,7 @@ class Infractions(InfractionScheduler, commands.Cog):
await self.apply_infraction(ctx, infraction, user)
@command()
- async def kick(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None:
+ async def kick(self, ctx: Context, user: UnambiguousMemberOrUser, *, reason: t.Optional[str] = None) -> None:
"""Kick a user for the given reason."""
if not isinstance(user, Member):
await ctx.send(":x: The user doesn't appear to be on the server.")
@@ -79,7 +78,7 @@ class Infractions(InfractionScheduler, commands.Cog):
async def ban(
self,
ctx: Context,
- user: FetchedMember,
+ user: UnambiguousMemberOrUser,
duration: t.Optional[Expiry] = None,
*,
reason: t.Optional[str] = None
@@ -95,7 +94,7 @@ class Infractions(InfractionScheduler, commands.Cog):
async def purgeban(
self,
ctx: Context,
- user: FetchedMember,
+ user: UnambiguousMemberOrUser,
duration: t.Optional[Expiry] = None,
*,
reason: t.Optional[str] = None
@@ -111,7 +110,7 @@ class Infractions(InfractionScheduler, commands.Cog):
async def voiceban(
self,
ctx: Context,
- user: FetchedMember,
+ user: UnambiguousMemberOrUser,
duration: t.Optional[Expiry] = None,
*,
reason: t.Optional[str]
@@ -129,7 +128,7 @@ class Infractions(InfractionScheduler, commands.Cog):
@command(aliases=["mute"])
async def tempmute(
self, ctx: Context,
- user: FetchedMember,
+ user: UnambiguousMemberOrUser,
duration: t.Optional[Expiry] = None,
*,
reason: t.Optional[str] = None
@@ -163,7 +162,7 @@ class Infractions(InfractionScheduler, commands.Cog):
async def tempban(
self,
ctx: Context,
- user: FetchedMember,
+ user: UnambiguousMemberOrUser,
duration: Expiry,
*,
reason: t.Optional[str] = None
@@ -189,7 +188,7 @@ class Infractions(InfractionScheduler, commands.Cog):
async def tempvoiceban(
self,
ctx: Context,
- user: FetchedMember,
+ user: UnambiguousMemberOrUser,
duration: Expiry,
*,
reason: t.Optional[str]
@@ -215,7 +214,7 @@ class Infractions(InfractionScheduler, commands.Cog):
# region: Permanent shadow infractions
@command(hidden=True)
- async def note(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None:
+ async def note(self, ctx: Context, user: UnambiguousMemberOrUser, *, reason: t.Optional[str] = None) -> None:
"""Create a private note for a user with the given reason without notifying the user."""
infraction = await _utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False)
if infraction is None:
@@ -224,7 +223,7 @@ class Infractions(InfractionScheduler, commands.Cog):
await self.apply_infraction(ctx, infraction, user)
@command(hidden=True, aliases=['shadowban', 'sban'])
- async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None:
+ async def shadow_ban(self, ctx: Context, user: UnambiguousMemberOrUser, *, reason: t.Optional[str] = None) -> None:
"""Permanently ban a user for the given reason without notifying the user."""
await self.apply_ban(ctx, user, reason, hidden=True)
@@ -235,7 +234,7 @@ class Infractions(InfractionScheduler, commands.Cog):
async def shadow_tempban(
self,
ctx: Context,
- user: FetchedMember,
+ user: UnambiguousMemberOrUser,
duration: Expiry,
*,
reason: t.Optional[str] = None
@@ -261,17 +260,17 @@ class Infractions(InfractionScheduler, commands.Cog):
# region: Remove infractions (un- commands)
@command()
- async def unmute(self, ctx: Context, user: FetchedMember) -> None:
+ async def unmute(self, ctx: Context, user: UnambiguousMemberOrUser) -> None:
"""Prematurely end the active mute infraction for the user."""
await self.pardon_infraction(ctx, "mute", user)
@command()
- async def unban(self, ctx: Context, user: FetchedMember) -> None:
+ async def unban(self, ctx: Context, user: UnambiguousMemberOrUser) -> None:
"""Prematurely end the active ban infraction for the user."""
await self.pardon_infraction(ctx, "ban", user)
@command(aliases=("uvban",))
- async def unvoiceban(self, ctx: Context, user: FetchedMember) -> None:
+ async def unvoiceban(self, ctx: Context, user: UnambiguousMemberOrUser) -> None:
"""Prematurely end the active voice ban infraction for the user."""
await self.pardon_infraction(ctx, "voice_ban", user)
@@ -280,8 +279,19 @@ class Infractions(InfractionScheduler, commands.Cog):
async def apply_mute(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None:
"""Apply a mute infraction with kwargs passed to `post_infraction`."""
- if await _utils.get_active_infraction(ctx, user, "mute"):
- return
+ if active := await _utils.get_active_infraction(ctx, user, "mute", send_msg=False):
+ if active["actor"] != self.bot.user.id:
+ await _utils.send_active_infraction_message(ctx, active)
+ return
+
+ # Allow the current mute attempt to override an automatically triggered mute.
+ log_text = await self.deactivate_infraction(active, notify=False)
+ if "Failure" in log_text:
+ await ctx.send(
+ f":x: can't override infraction **mute** for {user.mention}: "
+ f"failed to deactivate. {log_text['Failure']}"
+ )
+ return
infraction = await _utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs)
if infraction is None:
@@ -320,7 +330,7 @@ class Infractions(InfractionScheduler, commands.Cog):
async def apply_ban(
self,
ctx: Context,
- user: UserSnowflake,
+ user: MemberOrUser,
reason: t.Optional[str],
purge_days: t.Optional[int] = 0,
**kwargs
@@ -345,7 +355,7 @@ class Infractions(InfractionScheduler, commands.Cog):
return
log.trace("Old tempban is being replaced by new permaban.")
- await self.pardon_infraction(ctx, "ban", user, is_temporary)
+ await self.pardon_infraction(ctx, "ban", user, send_msg=is_temporary)
infraction = await _utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs)
if infraction is None:
@@ -376,7 +386,7 @@ class Infractions(InfractionScheduler, commands.Cog):
await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False)
@respect_role_hierarchy(member_arg=2)
- async def apply_voice_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None:
+ async def apply_voice_ban(self, ctx: Context, user: MemberOrUser, reason: t.Optional[str], **kwargs) -> None:
"""Apply a voice ban infraction with kwargs passed to `post_infraction`."""
if await _utils.get_active_infraction(ctx, user, "voice_ban"):
return
@@ -403,8 +413,15 @@ class Infractions(InfractionScheduler, commands.Cog):
# endregion
# region: Base pardon functions
- async def pardon_mute(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]:
- """Remove a user's muted role, DM them a notification, and return a log dict."""
+ async def pardon_mute(
+ self,
+ user_id: int,
+ guild: discord.Guild,
+ reason: t.Optional[str],
+ *,
+ notify: bool = True
+ ) -> t.Dict[str, str]:
+ """Remove a user's muted role, optionally DM them a notification, and return a log dict."""
user = guild.get_member(user_id)
log_text = {}
@@ -413,16 +430,17 @@ class Infractions(InfractionScheduler, commands.Cog):
self.mod_log.ignore(Event.member_update, user.id)
await user.remove_roles(self._muted_role, reason=reason)
- # DM the user about the expiration.
- notified = await _utils.notify_pardon(
- user=user,
- title="You have been unmuted",
- content="You may now send messages in the server.",
- icon_url=_utils.INFRACTION_ICONS["mute"][1]
- )
+ if notify:
+ # DM the user about the expiration.
+ notified = await _utils.notify_pardon(
+ user=user,
+ title="You have been unmuted",
+ content="You may now send messages in the server.",
+ icon_url=_utils.INFRACTION_ICONS["mute"][1]
+ )
+ log_text["DM"] = "Sent" if notified else "**Failed**"
log_text["Member"] = format_user(user)
- log_text["DM"] = "Sent" if notified else "**Failed**"
else:
log.info(f"Failed to unmute user {user_id}: user not found")
log_text["Failure"] = "User was not found in the guild."
@@ -444,31 +462,39 @@ class Infractions(InfractionScheduler, commands.Cog):
return log_text
- async def pardon_voice_ban(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]:
- """Add Voice Verified role back to user, DM them a notification, and return a log dict."""
+ async def pardon_voice_ban(
+ self,
+ user_id: int,
+ guild: discord.Guild,
+ *,
+ notify: bool = True
+ ) -> t.Dict[str, str]:
+ """Optionally DM the user a pardon notification and return a log dict."""
user = guild.get_member(user_id)
log_text = {}
if user:
- # DM user about infraction expiration
- notified = await _utils.notify_pardon(
- user=user,
- title="Voice ban ended",
- content="You have been unbanned and can verify yourself again in the server.",
- icon_url=_utils.INFRACTION_ICONS["voice_ban"][1]
- )
+ if notify:
+ # DM user about infraction expiration
+ notified = await _utils.notify_pardon(
+ user=user,
+ title="Voice ban ended",
+ content="You have been unbanned and can verify yourself again in the server.",
+ icon_url=_utils.INFRACTION_ICONS["voice_ban"][1]
+ )
+ log_text["DM"] = "Sent" if notified else "**Failed**"
log_text["Member"] = format_user(user)
- log_text["DM"] = "Sent" if notified else "**Failed**"
else:
log_text["Info"] = "User was not found in the guild."
return log_text
- async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]:
+ async def _pardon_action(self, infraction: _utils.Infraction, notify: bool) -> t.Optional[t.Dict[str, str]]:
"""
Execute deactivation steps specific to the infraction's type and return a log dict.
+ If `notify` is True, notify the user of the pardon via DM where applicable.
If an infraction type is unsupported, return None instead.
"""
guild = self.bot.get_guild(constants.Guild.id)
@@ -476,11 +502,11 @@ class Infractions(InfractionScheduler, commands.Cog):
reason = f"Infraction #{infraction['id']} expired or was pardoned."
if infraction["type"] == "mute":
- return await self.pardon_mute(user_id, guild, reason)
+ return await self.pardon_mute(user_id, guild, reason, notify=notify)
elif infraction["type"] == "ban":
return await self.pardon_ban(user_id, guild, reason)
elif infraction["type"] == "voice_ban":
- return await self.pardon_voice_ban(user_id, guild, reason)
+ return await self.pardon_voice_ban(user_id, guild, notify=notify)
# endregion
diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py
index b3783cd60..7f27896d7 100644
--- a/bot/exts/moderation/infraction/management.py
+++ b/bot/exts/moderation/infraction/management.py
@@ -3,19 +3,22 @@ import textwrap
import typing as t
from datetime import datetime
+import dateutil.parser
import discord
+from dateutil.relativedelta import relativedelta
from discord.ext import commands
from discord.ext.commands import Context
from discord.utils import escape_markdown
from bot import constants
from bot.bot import Bot
-from bot.converters import Expiry, Infraction, Snowflake, UserMention, allowed_strings, proxy_user
+from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, UnambiguousUser, allowed_strings
from bot.exts.moderation.infraction.infractions import Infractions
from bot.exts.moderation.modlog import ModLog
from bot.pagination import LinePaginator
from bot.utils import messages, time
from bot.utils.channel import is_mod_channel
+from bot.utils.time import humanize_delta, until_expiration
log = logging.getLogger(__name__)
@@ -78,7 +81,7 @@ class ModManagement(commands.Cog):
"""
old_reason = infraction["reason"]
- if old_reason is not None:
+ if old_reason is not None and reason is not None:
add_period = not old_reason.endswith((".", "!", "?"))
reason = old_reason + (". " if add_period else " ") + reason
@@ -164,8 +167,8 @@ class ModManagement(commands.Cog):
self.infractions_cog.schedule_expiration(new_infraction)
log_text += f"""
- Previous expiry: {infraction['expires_at'] or "Permanent"}
- New expiry: {new_infraction['expires_at'] or "Permanent"}
+ Previous expiry: {until_expiration(infraction['expires_at']) or "Permanent"}
+ New expiry: {until_expiration(new_infraction['expires_at']) or "Permanent"}
""".rstrip()
changes = ' & '.join(confirm_messages)
@@ -198,29 +201,34 @@ class ModManagement(commands.Cog):
# region: Search infractions
@infraction_group.group(name="search", aliases=('s',), invoke_without_command=True)
- async def infraction_search_group(self, ctx: Context, query: t.Union[UserMention, Snowflake, str]) -> None:
+ async def infraction_search_group(self, ctx: Context, query: t.Union[UnambiguousUser, Snowflake, str]) -> None:
"""Searches for infractions in the database."""
if isinstance(query, int):
await self.search_user(ctx, discord.Object(query))
- else:
+ elif isinstance(query, str):
await self.search_reason(ctx, query)
+ else:
+ await self.search_user(ctx, query)
@infraction_search_group.command(name="user", aliases=("member", "id"))
- async def search_user(self, ctx: Context, user: t.Union[discord.User, proxy_user]) -> None:
+ async def search_user(self, ctx: Context, user: t.Union[MemberOrUser, discord.Object]) -> None:
"""Search for infractions by member."""
infraction_list = await self.bot.api_client.get(
'bot/infractions/expanded',
params={'user__id': str(user.id)}
)
- user = self.bot.get_user(user.id)
- if not user and infraction_list:
- # Use the user data retrieved from the DB for the username.
- user = infraction_list[0]["user"]
- user = escape_markdown(user["name"]) + f"#{user['discriminator']:04}"
+ if isinstance(user, (discord.Member, discord.User)):
+ user_str = escape_markdown(str(user))
+ else:
+ if infraction_list:
+ user = infraction_list[0]["user"]
+ user_str = escape_markdown(user["name"]) + f"#{user['discriminator']:04}"
+ else:
+ user_str = str(user.id)
embed = discord.Embed(
- title=f"Infractions for {user} ({len(infraction_list)} total)",
+ title=f"Infractions for {user_str} ({len(infraction_list)} total)",
colour=discord.Colour.orange()
)
await self.send_infraction_list(ctx, embed, infraction_list)
@@ -288,10 +296,11 @@ class ModManagement(commands.Cog):
remaining = "Inactive"
if expires_at is None:
- expires = "*Permanent*"
+ duration = "*Permanent*"
else:
- date_from = datetime.strptime(created, time.INFRACTION_FORMAT)
- expires = time.format_infraction_with_duration(expires_at, date_from)
+ date_from = datetime.fromtimestamp(float(time.DISCORD_TIMESTAMP_REGEX.match(created).group(1)))
+ date_to = dateutil.parser.isoparse(expires_at).replace(tzinfo=None)
+ duration = humanize_delta(relativedelta(date_to, date_from))
lines = textwrap.dedent(f"""
{"**===============**" if active else "==============="}
@@ -300,8 +309,8 @@ class ModManagement(commands.Cog):
Type: **{infraction["type"]}**
Shadow: {infraction["hidden"]}
Created: {created}
- Expires: {expires}
- Remaining: {remaining}
+ Expires: {remaining}
+ Duration: {duration}
Actor: <@{infraction["actor"]["id"]}>
ID: `{infraction["id"]}`
Reason: {infraction["reason"] or "*None*"}
diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py
index 07e79b9fe..05a2bbe10 100644
--- a/bot/exts/moderation/infraction/superstarify.py
+++ b/bot/exts/moderation/infraction/superstarify.py
@@ -192,8 +192,8 @@ class Superstarify(InfractionScheduler, Cog):
"""Remove the superstarify infraction and allow the user to change their nickname."""
await self.pardon_infraction(ctx, "superstar", member)
- async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]:
- """Pardon a superstar infraction and return a log dict."""
+ async def _pardon_action(self, infraction: _utils.Infraction, notify: bool) -> t.Optional[t.Dict[str, str]]:
+ """Pardon a superstar infraction, optionally notify the user via DM, and return a log dict."""
if infraction["type"] != "superstar":
return
@@ -208,18 +208,19 @@ class Superstarify(InfractionScheduler, Cog):
)
return {}
+ log_text = {"Member": format_user(user)}
+
# DM the user about the expiration.
- notified = await _utils.notify_pardon(
- user=user,
- title="You are no longer superstarified",
- content="You may now change your nickname on the server.",
- icon_url=_utils.INFRACTION_ICONS["superstar"][1]
- )
+ if notify:
+ notified = await _utils.notify_pardon(
+ user=user,
+ title="You are no longer superstarified",
+ content="You may now change your nickname on the server.",
+ icon_url=_utils.INFRACTION_ICONS["superstar"][1]
+ )
+ log_text["DM"] = "Sent" if notified else "**Failed**"
- return {
- "Member": format_user(user),
- "DM": "Sent" if notified else "**Failed**"
- }
+ return log_text
@staticmethod
def get_nick(infraction_id: int, member_id: int) -> str:
diff --git a/bot/exts/moderation/metabase.py b/bot/exts/moderation/metabase.py
index db5f04d83..9eeeec074 100644
--- a/bot/exts/moderation/metabase.py
+++ b/bot/exts/moderation/metabase.py
@@ -42,6 +42,25 @@ class Metabase(Cog):
self.init_task = self.bot.loop.create_task(self.init_cog())
+ async def cog_command_error(self, ctx: Context, error: Exception) -> None:
+ """Handle ClientResponseError errors locally to invalidate token if needed."""
+ if not isinstance(error.original, ClientResponseError):
+ return
+
+ if error.original.status == 403:
+ # User doesn't have access to the given question
+ log.warning(f"Failed to auth with Metabase for {error.original.url}.")
+ await ctx.send(f":x: {ctx.author.mention} Failed to auth with Metabase for that question.")
+ elif error.original.status == 404:
+ await ctx.send(f":x: {ctx.author.mention} That question could not be found.")
+ else:
+ # User credentials are invalid, or the refresh failed.
+ # Delete the expiry time, to force a refresh on next startup.
+ await self.session_info.delete("session_expiry")
+ log.exception("Session token is invalid or refresh failed.")
+ await ctx.send(f":x: {ctx.author.mention} Session token is invalid or refresh failed.")
+ error.handled = True
+
async def init_cog(self) -> None:
"""Initialise the metabase session."""
expiry_time = await self.session_info.get("session_expiry")
@@ -65,7 +84,7 @@ class Metabase(Cog):
"username": MetabaseConfig.username,
"password": MetabaseConfig.password
}
- async with self.bot.http_session.post(f"{MetabaseConfig.url}/session", json=data) as resp:
+ async with self.bot.http_session.post(f"{MetabaseConfig.base_url}/api/session", json=data) as resp:
json_data = await resp.json()
self.session_token = json_data.get("id")
@@ -86,7 +105,7 @@ class Metabase(Cog):
"""A group of commands for interacting with metabase."""
await ctx.send_help(ctx.command)
- @metabase_group.command(name="extract")
+ @metabase_group.command(name="extract", aliases=("export",))
async def metabase_extract(
self,
ctx: Context,
@@ -106,48 +125,50 @@ class Metabase(Cog):
Valid extensions are: csv and json.
"""
- async with ctx.typing():
-
- # Make sure we have a session token before running anything
- await self.init_task
-
- url = f"{MetabaseConfig.url}/card/{question_id}/query/{extension}"
- try:
- async with self.bot.http_session.post(url, headers=self.headers, raise_for_status=True) as resp:
- if extension == "csv":
- out = await resp.text()
- # Save the output for use with int e
- self.exports[question_id] = list(csv.DictReader(StringIO(out)))
-
- elif extension == "json":
- out = await resp.json()
- # Save the output for use with int e
- self.exports[question_id] = out
-
- # Format it nicely for human eyes
- out = json.dumps(out, indent=4, sort_keys=True)
- except ClientResponseError as e:
- if e.status == 403:
- # User doesn't have access to the given question
- log.warning(f"Failed to auth with Metabase for question {question_id}.")
- await ctx.send(f":x: {ctx.author.mention} Failed to auth with Metabase for that question.")
- else:
- # User credentials are invalid, or the refresh failed.
- # Delete the expiry time, to force a refresh on next startup.
- await self.session_info.delete("session_expiry")
- log.exception("Session token is invalid or refresh failed.")
- await ctx.send(f":x: {ctx.author.mention} Session token is invalid or refresh failed.")
- return
-
- paste_link = await send_to_paste_service(out, extension=extension)
- if paste_link:
- message = f":+1: {ctx.author.mention} Here's your link: {paste_link}"
- else:
- message = f":x: {ctx.author.mention} Link service is unavailible."
- await ctx.send(
- f"{message}\nYou can also access this data within internal eval by doing: "
- f"`bot.get_cog('Metabase').exports[{question_id}]`"
- )
+ await ctx.trigger_typing()
+
+ # Make sure we have a session token before running anything
+ await self.init_task
+
+ url = f"{MetabaseConfig.base_url}/api/card/{question_id}/query/{extension}"
+
+ async with self.bot.http_session.post(url, headers=self.headers, raise_for_status=True) as resp:
+ if extension == "csv":
+ out = await resp.text(encoding="utf-8")
+ # Save the output for use with int e
+ self.exports[question_id] = list(csv.DictReader(StringIO(out)))
+
+ elif extension == "json":
+ out = await resp.json(encoding="utf-8")
+ # Save the output for use with int e
+ self.exports[question_id] = out
+
+ # Format it nicely for human eyes
+ out = json.dumps(out, indent=4, sort_keys=True)
+
+ paste_link = await send_to_paste_service(out, extension=extension)
+ if paste_link:
+ message = f":+1: {ctx.author.mention} Here's your link: {paste_link}"
+ else:
+ message = f":x: {ctx.author.mention} Link service is unavailible."
+ await ctx.send(
+ f"{message}\nYou can also access this data within internal eval by doing: "
+ f"`bot.get_cog('Metabase').exports[{question_id}]`"
+ )
+
+ @metabase_group.command(name="publish", aliases=("share",))
+ async def metabase_publish(self, ctx: Context, question_id: int) -> None:
+ """Publically shares the given question and posts the link."""
+ await ctx.trigger_typing()
+ # Make sure we have a session token before running anything
+ await self.init_task
+
+ url = f"{MetabaseConfig.base_url}/api/card/{question_id}/public_link"
+
+ async with self.bot.http_session.post(url, headers=self.headers, raise_for_status=True) as resp:
+ response_json = await resp.json(encoding="utf-8")
+ sharing_url = f"{MetabaseConfig.public_url}/public/question/{response_json['uuid']}"
+ await ctx.send(f":+1: {ctx.author.mention} Here's your sharing link: {sharing_url}")
# This cannot be static (must have a __func__ attribute).
async def cog_check(self, ctx: Context) -> bool:
diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py
index be65ade6e..be2245650 100644
--- a/bot/exts/moderation/modlog.py
+++ b/bot/exts/moderation/modlog.py
@@ -99,7 +99,7 @@ class ModLog(Cog, name="ModLog"):
"""Generate log embed and send to logging channel."""
# Truncate string directly here to avoid removing newlines
embed = discord.Embed(
- description=text[:2045] + "..." if len(text) > 2048 else text
+ description=text[:4093] + "..." if len(text) > 4096 else text
)
if title and icon_url:
@@ -564,7 +564,7 @@ class ModLog(Cog, name="ModLog"):
# Shorten the message content if necessary
content = message.clean_content
- remaining_chars = 2040 - len(response)
+ remaining_chars = 4090 - len(response)
if len(content) > remaining_chars:
botlog_url = await self.upload_log(messages=[message], actor_id=message.author.id)
diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py
index 1ad5005de..80c9f0c38 100644
--- a/bot/exts/moderation/modpings.py
+++ b/bot/exts/moderation/modpings.py
@@ -44,7 +44,7 @@ class ModPings(Cog):
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:
+ if mod.id in pings_off:
await self.pings_off_mods.delete(mod.id)
continue
@@ -59,6 +59,7 @@ class ModPings(Cog):
"""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.")
+ await self.pings_off_mods.delete(mod.id)
@group(name='modpings', aliases=('modping',), invoke_without_command=True)
@has_any_role(*MODERATION_ROLES)
@@ -86,7 +87,6 @@ class ModPings(Cog):
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.")
diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py
index 2a7ca932e..95e2792c3 100644
--- a/bot/exts/moderation/silence.py
+++ b/bot/exts/moderation/silence.py
@@ -1,36 +1,46 @@
import json
import logging
+import typing
from contextlib import suppress
from datetime import datetime, timedelta, timezone
-from operator import attrgetter
-from typing import Optional
+from typing import Optional, OrderedDict, Union
from async_rediscache import RedisCache
-from discord import TextChannel
+from discord import Guild, PermissionOverwrite, TextChannel, VoiceChannel
from discord.ext import commands, tasks
from discord.ext.commands import Context
+from bot import constants
from bot.bot import Bot
-from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles
from bot.converters import HushDurationConverter
-from bot.utils.lock import LockedResourceError, lock_arg
+from bot.utils.lock import LockedResourceError, lock, lock_arg
from bot.utils.scheduling import Scheduler
log = logging.getLogger(__name__)
LOCK_NAMESPACE = "silence"
-MSG_SILENCE_FAIL = f"{Emojis.cross_mark} current channel is already silenced."
-MSG_SILENCE_PERMANENT = f"{Emojis.check_mark} silenced current channel indefinitely."
-MSG_SILENCE_SUCCESS = f"{Emojis.check_mark} silenced current channel for {{duration}} minute(s)."
+MSG_SILENCE_FAIL = f"{constants.Emojis.cross_mark} {{channel}} is already silenced."
+MSG_SILENCE_PERMANENT = f"{constants.Emojis.check_mark} silenced {{channel}} indefinitely."
+MSG_SILENCE_SUCCESS = f"{constants.Emojis.check_mark} silenced {{{{channel}}}} for {{duration}} minute(s)."
-MSG_UNSILENCE_FAIL = f"{Emojis.cross_mark} current channel was not silenced."
+MSG_UNSILENCE_FAIL = f"{constants.Emojis.cross_mark} {{channel}} was not silenced."
MSG_UNSILENCE_MANUAL = (
- f"{Emojis.cross_mark} current channel was not unsilenced because the current overwrites were "
+ f"{constants.Emojis.cross_mark} {{channel}} was not unsilenced because the current overwrites were "
f"set manually or the cache was prematurely cleared. "
f"Please edit the overwrites manually to unsilence."
)
-MSG_UNSILENCE_SUCCESS = f"{Emojis.check_mark} unsilenced current channel."
+MSG_UNSILENCE_SUCCESS = f"{constants.Emojis.check_mark} unsilenced {{channel}}."
+
+TextOrVoiceChannel = Union[TextChannel, VoiceChannel]
+
+VOICE_CHANNELS = {
+ constants.Channels.code_help_voice_0: constants.Channels.code_help_chat_0,
+ constants.Channels.code_help_voice_1: constants.Channels.code_help_chat_1,
+ constants.Channels.general_voice_0: constants.Channels.voice_chat_0,
+ constants.Channels.general_voice_1: constants.Channels.voice_chat_1,
+ constants.Channels.staff_voice: constants.Channels.staff_voice_chat,
+}
class SilenceNotifier(tasks.Loop):
@@ -41,7 +51,7 @@ class SilenceNotifier(tasks.Loop):
self._silenced_channels = {}
self._alert_channel = alert_channel
- def add_channel(self, channel: TextChannel) -> None:
+ def add_channel(self, channel: TextOrVoiceChannel) -> None:
"""Add channel to `_silenced_channels` and start loop if not launched."""
if not self._silenced_channels:
self.start()
@@ -68,7 +78,15 @@ class SilenceNotifier(tasks.Loop):
f"{channel.mention} for {(self._current_loop-start)//60} min"
for channel, start in self._silenced_channels.items()
)
- await self._alert_channel.send(f"<@&{Roles.moderators}> currently silenced channels: {channels_text}")
+ await self._alert_channel.send(
+ f"<@&{constants.Roles.moderators}> currently silenced channels: {channels_text}"
+ )
+
+
+async def _select_lock_channel(args: OrderedDict[str, any]) -> TextOrVoiceChannel:
+ """Passes the channel to be silenced to the resource lock."""
+ channel, _ = Silence.parse_silence_args(args["ctx"], args["duration_or_channel"], args["duration"])
+ return channel
class Silence(commands.Cog):
@@ -92,88 +110,190 @@ class Silence(commands.Cog):
"""Set instance attributes once the guild is available and reschedule unsilences."""
await self.bot.wait_until_guild_available()
- guild = self.bot.get_guild(Guild.id)
+ guild = self.bot.get_guild(constants.Guild.id)
+
self._everyone_role = guild.default_role
- self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts)
- self.notifier = SilenceNotifier(self.bot.get_channel(Channels.mod_log))
+ self._verified_voice_role = guild.get_role(constants.Roles.voice_verified)
+
+ self._mod_alerts_channel = self.bot.get_channel(constants.Channels.mod_alerts)
+
+ self.notifier = SilenceNotifier(self.bot.get_channel(constants.Channels.mod_log))
await self._reschedule()
+ async def send_message(
+ self,
+ message: str,
+ source_channel: TextChannel,
+ target_channel: TextOrVoiceChannel,
+ *,
+ alert_target: bool = False
+ ) -> None:
+ """Helper function to send message confirmation to `source_channel`, and notification to `target_channel`."""
+ # Reply to invocation channel
+ source_reply = message
+ if source_channel != target_channel:
+ source_reply = source_reply.format(channel=target_channel.mention)
+ else:
+ source_reply = source_reply.format(channel="current channel")
+ await source_channel.send(source_reply)
+
+ # Reply to target channel
+ if alert_target:
+ if isinstance(target_channel, VoiceChannel):
+ voice_chat = self.bot.get_channel(VOICE_CHANNELS.get(target_channel.id))
+ if voice_chat and source_channel != voice_chat:
+ await voice_chat.send(message.format(channel=target_channel.mention))
+
+ elif source_channel != target_channel:
+ await target_channel.send(message.format(channel="current channel"))
+
@commands.command(aliases=("hush",))
- @lock_arg(LOCK_NAMESPACE, "ctx", attrgetter("channel"), raise_error=True)
- async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None:
+ @lock(LOCK_NAMESPACE, _select_lock_channel, raise_error=True)
+ async def silence(
+ self,
+ ctx: Context,
+ duration_or_channel: typing.Union[TextOrVoiceChannel, HushDurationConverter] = None,
+ duration: HushDurationConverter = 10,
+ *,
+ kick: bool = False
+ ) -> None:
"""
Silence the current channel for `duration` minutes or `forever`.
Duration is capped at 15 minutes, passing forever makes the silence indefinite.
Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start.
+
+ Passing a voice channel will attempt to move members out of the channel and back to force sync permissions.
+ If `kick` is True, members will not be added back to the voice channel, and members will be unable to rejoin.
"""
await self._init_task
+ channel, duration = self.parse_silence_args(ctx, duration_or_channel, duration)
- channel_info = f"#{ctx.channel} ({ctx.channel.id})"
+ channel_info = f"#{channel} ({channel.id})"
log.debug(f"{ctx.author} is silencing channel {channel_info}.")
- if not await self._set_silence_overwrites(ctx.channel):
+ if not await self._set_silence_overwrites(channel, kick=kick):
log.info(f"Tried to silence channel {channel_info} but the channel was already silenced.")
- await ctx.send(MSG_SILENCE_FAIL)
+ await self.send_message(MSG_SILENCE_FAIL, ctx.channel, channel, alert_target=False)
return
- await self._schedule_unsilence(ctx, duration)
+ if isinstance(channel, VoiceChannel):
+ if kick:
+ await self._kick_voice_members(channel)
+ else:
+ await self._force_voice_sync(channel)
+
+ await self._schedule_unsilence(ctx, channel, duration)
if duration is None:
- self.notifier.add_channel(ctx.channel)
+ self.notifier.add_channel(channel)
log.info(f"Silenced {channel_info} indefinitely.")
- await ctx.send(MSG_SILENCE_PERMANENT)
+ await self.send_message(MSG_SILENCE_PERMANENT, ctx.channel, channel, alert_target=True)
+
else:
log.info(f"Silenced {channel_info} for {duration} minute(s).")
- await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration))
-
- @commands.command(aliases=("unhush",))
- async def unsilence(self, ctx: Context) -> None:
- """
- Unsilence the current channel.
-
- If the channel was silenced indefinitely, notifications for the channel will stop.
- """
- await self._init_task
- log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.")
- await self._unsilence_wrapper(ctx.channel)
-
- @lock_arg(LOCK_NAMESPACE, "channel", raise_error=True)
- async def _unsilence_wrapper(self, channel: TextChannel) -> None:
- """Unsilence `channel` and send a success/failure message."""
- if not await self._unsilence(channel):
- overwrite = channel.overwrites_for(self._everyone_role)
- if overwrite.send_messages is False or overwrite.add_reactions is False:
- await channel.send(MSG_UNSILENCE_MANUAL)
+ formatted_message = MSG_SILENCE_SUCCESS.format(duration=duration)
+ await self.send_message(formatted_message, ctx.channel, channel, alert_target=True)
+
+ @staticmethod
+ def parse_silence_args(
+ ctx: Context,
+ duration_or_channel: typing.Union[TextOrVoiceChannel, int],
+ duration: HushDurationConverter
+ ) -> typing.Tuple[TextOrVoiceChannel, Optional[int]]:
+ """Helper method to parse the arguments of the silence command."""
+ if duration_or_channel:
+ if isinstance(duration_or_channel, (TextChannel, VoiceChannel)):
+ channel = duration_or_channel
else:
- await channel.send(MSG_UNSILENCE_FAIL)
+ channel = ctx.channel
+ duration = duration_or_channel
else:
- await channel.send(MSG_UNSILENCE_SUCCESS)
+ channel = ctx.channel
+
+ if duration == -1:
+ duration = None
- async def _set_silence_overwrites(self, channel: TextChannel) -> bool:
+ return channel, duration
+
+ async def _set_silence_overwrites(self, channel: TextOrVoiceChannel, *, kick: bool = False) -> bool:
"""Set silence permission overwrites for `channel` and return True if successful."""
- overwrite = channel.overwrites_for(self._everyone_role)
- prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions)
+ # Get the original channel overwrites
+ if isinstance(channel, TextChannel):
+ role = self._everyone_role
+ overwrite = channel.overwrites_for(role)
+ prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions)
+
+ else:
+ role = self._verified_voice_role
+ overwrite = channel.overwrites_for(role)
+ prev_overwrites = dict(speak=overwrite.speak)
+ if kick:
+ prev_overwrites.update(connect=overwrite.connect)
+ # Stop if channel was already silenced
if channel.id in self.scheduler or all(val is False for val in prev_overwrites.values()):
return False
- overwrite.update(send_messages=False, add_reactions=False)
- await channel.set_permissions(self._everyone_role, overwrite=overwrite)
+ # Set new permissions, store
+ overwrite.update(**dict.fromkeys(prev_overwrites, False))
+ await channel.set_permissions(role, overwrite=overwrite)
await self.previous_overwrites.set(channel.id, json.dumps(prev_overwrites))
return True
- async def _schedule_unsilence(self, ctx: Context, duration: Optional[int]) -> None:
+ async def _schedule_unsilence(self, ctx: Context, channel: TextOrVoiceChannel, duration: Optional[int]) -> None:
"""Schedule `ctx.channel` to be unsilenced if `duration` is not None."""
if duration is None:
- await self.unsilence_timestamps.set(ctx.channel.id, -1)
+ await self.unsilence_timestamps.set(channel.id, -1)
else:
- self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence))
+ self.scheduler.schedule_later(duration * 60, channel.id, ctx.invoke(self.unsilence, channel=channel))
unsilence_time = datetime.now(tz=timezone.utc) + timedelta(minutes=duration)
- await self.unsilence_timestamps.set(ctx.channel.id, unsilence_time.timestamp())
+ await self.unsilence_timestamps.set(channel.id, unsilence_time.timestamp())
- async def _unsilence(self, channel: TextChannel) -> bool:
+ @commands.command(aliases=("unhush",))
+ async def unsilence(self, ctx: Context, *, channel: TextOrVoiceChannel = None) -> None:
+ """
+ Unsilence the given channel if given, else the current one.
+
+ If the channel was silenced indefinitely, notifications for the channel will stop.
+ """
+ await self._init_task
+ if channel is None:
+ channel = ctx.channel
+ log.debug(f"Unsilencing channel #{channel} from {ctx.author}'s command.")
+ await self._unsilence_wrapper(channel, ctx)
+
+ @lock_arg(LOCK_NAMESPACE, "channel", raise_error=True)
+ async def _unsilence_wrapper(self, channel: TextOrVoiceChannel, ctx: Optional[Context] = None) -> None:
+ """
+ Unsilence `channel` and send a success/failure message to ctx.channel.
+
+ If ctx is None or not passed, `channel` is used in its place.
+ If `channel` and ctx.channel are the same, only one message is sent.
+ """
+ msg_channel = channel
+ if ctx is not None:
+ msg_channel = ctx.channel
+
+ if not await self._unsilence(channel):
+ if isinstance(channel, VoiceChannel):
+ overwrite = channel.overwrites_for(self._verified_voice_role)
+ has_channel_overwrites = overwrite.speak is False
+ else:
+ overwrite = channel.overwrites_for(self._everyone_role)
+ has_channel_overwrites = overwrite.send_messages is False or overwrite.add_reactions is False
+
+ # Send fail message to muted channel or voice chat channel, and invocation channel
+ if has_channel_overwrites:
+ await self.send_message(MSG_UNSILENCE_MANUAL, msg_channel, channel, alert_target=False)
+ else:
+ await self.send_message(MSG_UNSILENCE_FAIL, msg_channel, channel, alert_target=False)
+
+ else:
+ await self.send_message(MSG_UNSILENCE_SUCCESS, msg_channel, channel, alert_target=True)
+
+ async def _unsilence(self, channel: TextOrVoiceChannel) -> bool:
"""
Unsilence `channel`.
@@ -183,19 +303,34 @@ class Silence(commands.Cog):
Return `True` if channel permissions were changed, `False` otherwise.
"""
+ # Get stored overwrites, and return if channel is unsilenced
prev_overwrites = await self.previous_overwrites.get(channel.id)
if channel.id not in self.scheduler and prev_overwrites is None:
log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.")
return False
- overwrite = channel.overwrites_for(self._everyone_role)
+ # Select the role based on channel type, and get current overwrites
+ if isinstance(channel, TextChannel):
+ role = self._everyone_role
+ overwrite = channel.overwrites_for(role)
+ permissions = "`Send Messages` and `Add Reactions`"
+ else:
+ role = self._verified_voice_role
+ overwrite = channel.overwrites_for(role)
+ permissions = "`Speak` and `Connect`"
+
+ # Check if old overwrites were not stored
if prev_overwrites is None:
log.info(f"Missing previous overwrites for #{channel} ({channel.id}); defaulting to None.")
- overwrite.update(send_messages=None, add_reactions=None)
+ overwrite.update(send_messages=None, add_reactions=None, speak=None, connect=None)
else:
overwrite.update(**json.loads(prev_overwrites))
- await channel.set_permissions(self._everyone_role, overwrite=overwrite)
+ # Update Permissions
+ await channel.set_permissions(role, overwrite=overwrite)
+ if isinstance(channel, VoiceChannel):
+ await self._force_voice_sync(channel)
+
log.info(f"Unsilenced channel #{channel} ({channel.id}).")
self.scheduler.cancel(channel.id)
@@ -203,15 +338,81 @@ class Silence(commands.Cog):
await self.previous_overwrites.delete(channel.id)
await self.unsilence_timestamps.delete(channel.id)
+ # Alert Admin team if old overwrites were not available
if prev_overwrites is None:
await self._mod_alerts_channel.send(
- f"<@&{Roles.admins}> Restored overwrites with default values after unsilencing "
- f"{channel.mention}. Please check that the `Send Messages` and `Add Reactions` "
- f"overwrites for {self._everyone_role.mention} are at their desired values."
+ f"<@&{constants.Roles.admins}> Restored overwrites with default values after unsilencing "
+ f"{channel.mention}. Please check that the {permissions} "
+ f"overwrites for {role.mention} are at their desired values."
)
return True
+ @staticmethod
+ async def _get_afk_channel(guild: Guild) -> VoiceChannel:
+ """Get a guild's AFK channel, or create one if it does not exist."""
+ afk_channel = guild.afk_channel
+
+ if afk_channel is None:
+ overwrites = {
+ guild.default_role: PermissionOverwrite(speak=False, connect=False, view_channel=False)
+ }
+ afk_channel = await guild.create_voice_channel("mute-temp", overwrites=overwrites)
+ log.info(f"Failed to get afk-channel, created #{afk_channel} ({afk_channel.id})")
+
+ return afk_channel
+
+ @staticmethod
+ async def _kick_voice_members(channel: VoiceChannel) -> None:
+ """Remove all non-staff members from a voice channel."""
+ log.debug(f"Removing all non staff members from #{channel.name} ({channel.id}).")
+
+ for member in channel.members:
+ # Skip staff
+ if any(role.id in constants.MODERATION_ROLES for role in member.roles):
+ continue
+
+ try:
+ await member.move_to(None, reason="Kicking member from voice channel.")
+ log.trace(f"Kicked {member.name} from voice channel.")
+ except Exception as e:
+ log.debug(f"Failed to move {member.name}. Reason: {e}")
+ continue
+
+ log.debug("Removed all members.")
+
+ async def _force_voice_sync(self, channel: VoiceChannel) -> None:
+ """
+ Move all non-staff members from `channel` to a temporary channel and back to force toggle role mute.
+
+ Permission modification has to happen before this function.
+ """
+ # Obtain temporary channel
+ delete_channel = channel.guild.afk_channel is None
+ afk_channel = await self._get_afk_channel(channel.guild)
+
+ try:
+ # Move all members to temporary channel and back
+ for member in channel.members:
+ # Skip staff
+ if any(role.id in constants.MODERATION_ROLES for role in member.roles):
+ continue
+
+ try:
+ await member.move_to(afk_channel, reason="Muting VC member.")
+ log.trace(f"Moved {member.name} to afk channel.")
+
+ await member.move_to(channel, reason="Muting VC member.")
+ log.trace(f"Moved {member.name} to original voice channel.")
+ except Exception as e:
+ log.debug(f"Failed to move {member.name}. Reason: {e}")
+ continue
+
+ finally:
+ # Delete VC channel if it was created.
+ if delete_channel:
+ await afk_channel.delete(reason="Deleting temporary mute channel.")
+
async def _reschedule(self) -> None:
"""Reschedule unsilencing of active silences and add permanent ones to the notifier."""
for channel_id, timestamp in await self.unsilence_timestamps.items():
@@ -247,7 +448,7 @@ class Silence(commands.Cog):
# This cannot be static (must have a __func__ attribute).
async def cog_check(self, ctx: Context) -> bool:
"""Only allow moderators to invoke the commands in this cog."""
- return await commands.has_any_role(*MODERATION_ROLES).predicate(ctx)
+ return await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx)
def setup(bot: Bot) -> None:
diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py
index fd856a7f4..01d2614b0 100644
--- a/bot/exts/moderation/stream.py
+++ b/bot/exts/moderation/stream.py
@@ -9,11 +9,14 @@ from async_rediscache import RedisCache
from discord.ext import commands
from bot.bot import Bot
-from bot.constants import Colours, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES, VideoPermission
+from bot.constants import (
+ Colours, Emojis, Guild, MODERATION_ROLES, Roles,
+ STAFF_PARTNERS_COMMUNITY_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
+from bot.utils.time import discord_timestamp, format_infraction_with_duration
log = logging.getLogger(__name__)
@@ -134,16 +137,7 @@ class Stream(commands.Cog):
await member.add_roles(discord.Object(Roles.video), reason="Temporary streaming access granted")
- # Use embed as embed timestamps do timezone conversions.
- embed = discord.Embed(
- description=f"{Emojis.check_mark} {member.mention} can now stream.",
- colour=Colours.soft_green
- )
- embed.set_footer(text=f"Streaming permission has been given to {member} until")
- embed.timestamp = duration
-
- # Mention in content as mentions in embeds don't ping
- await ctx.send(content=member.mention, embed=embed)
+ await ctx.send(f"{Emojis.check_mark} {member.mention} can now stream until {discord_timestamp(duration)}.")
# Convert here for nicer logging
revoke_time = format_infraction_with_duration(str(duration))
@@ -202,17 +196,17 @@ class Stream(commands.Cog):
@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 = [
+ """Lists all users who aren't staff, partners or members of the python community and have stream permissions."""
+ non_staff_partners_community_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)
+ if not any(role.id in STAFF_PARTNERS_COMMUNITY_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:
+ for member in non_staff_partners_community_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()
diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py
index 0cbce6a51..8494a1e2e 100644
--- a/bot/exts/moderation/voice_gate.py
+++ b/bot/exts/moderation/voice_gate.py
@@ -8,6 +8,7 @@ from async_rediscache import RedisCache
from discord import Colour, Member, VoiceState
from discord.ext.commands import Cog, Context, command
+
from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.constants import Channels, Event, MODERATION_ROLES, Roles, VoiceGate as GateConf
@@ -40,6 +41,12 @@ VOICE_PING = (
"If you don't yet qualify, you'll be told why!"
)
+VOICE_PING_DM = (
+ "Wondering why you can't talk in the voice channels? "
+ "Use the `!voiceverify` command in {channel_mention} to verify. "
+ "If you don't yet qualify, you'll be told why!"
+)
+
class VoiceGate(Cog):
"""Voice channels verification management."""
@@ -75,37 +82,43 @@ class VoiceGate(Cog):
log.trace(f"Voice gate reminder message for user {member_id} was already removed")
@redis_cache.atomic_transaction
- async def _ping_newcomer(self, member: discord.Member) -> bool:
+ async def _ping_newcomer(self, member: discord.Member) -> tuple:
"""
See if `member` should be sent a voice verification notification, and send it if so.
- Returns False if the notification was not sent. This happens when:
+ Returns (False, None) if the notification was not sent. This happens when:
* The `member` has already received the notification
* The `member` is already voice-verified
- Otherwise, the notification message ID is stored in `redis_cache` and True is returned.
+ Otherwise, the notification message ID is stored in `redis_cache` and return (True, channel).
+ channel is either [discord.TextChannel, discord.DMChannel].
"""
if await self.redis_cache.contains(member.id):
log.trace("User already in cache. Ignore.")
- return False
+ return False, None
log.trace("User not in cache and is in a voice channel.")
verified = any(Roles.voice_verified == role.id for role in member.roles)
if verified:
log.trace("User is verified, add to the cache and ignore.")
await self.redis_cache.set(member.id, NO_MSG)
- return False
+ return False, None
log.trace("User is unverified. Send ping.")
+
await self.bot.wait_until_guild_available()
voice_verification_channel = self.bot.get_channel(Channels.voice_gate)
- message = await voice_verification_channel.send(f"Hello, {member.mention}! {VOICE_PING}")
- await self.redis_cache.set(member.id, message.id)
+ try:
+ message = await member.send(VOICE_PING_DM.format(channel_mention=voice_verification_channel.mention))
+ except discord.Forbidden:
+ log.trace("DM failed for Voice ping message. Sending in channel.")
+ message = await voice_verification_channel.send(f"Hello, {member.mention}! {VOICE_PING}")
- return True
+ await self.redis_cache.set(member.id, message.id)
+ return True, message.channel
- @command(aliases=('voiceverify',))
+ @command(aliases=("voiceverify", "voice-verify",))
@has_no_roles(Roles.voice_verified)
@in_whitelist(channels=(Channels.voice_gate,), redirect=None)
async def voice_verify(self, ctx: Context, *_) -> None:
@@ -144,8 +157,12 @@ class VoiceGate(Cog):
color=Colour.red()
)
log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} Metricity data.")
+ try:
+ await ctx.author.send(embed=embed)
+ except discord.Forbidden:
+ log.info("Could not send user DM. Sending in voice-verify channel and scheduling delete.")
+ await ctx.send(embed=embed)
- await ctx.author.send(embed=embed)
return
checks = {
@@ -237,13 +254,17 @@ class VoiceGate(Cog):
log.trace("User not in a voice channel. Ignore.")
return
+ if isinstance(after.channel, discord.StageChannel):
+ log.trace("User joined a stage channel. Ignore.")
+ return
+
# To avoid race conditions, checking if the user should receive a notification
# and sending it if appropriate is delegated to an atomic helper
- notification_sent = await self._ping_newcomer(member)
+ notification_sent, message_channel = await self._ping_newcomer(member)
- # Schedule the notification to be deleted after the configured delay, which is
+ # Schedule the channel ping notification to be deleted after the configured delay, which is
# again delegated to an atomic helper
- if notification_sent:
+ if notification_sent and isinstance(message_channel, discord.TextChannel):
await asyncio.sleep(GateConf.voice_ping_delete_delay)
await self._delete_ping(member.id)
diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py
index 9f26c34f2..146426569 100644
--- a/bot/exts/moderation/watchchannels/_watchchannel.py
+++ b/bot/exts/moderation/watchchannels/_watchchannel.py
@@ -295,7 +295,7 @@ class WatchChannel(metaclass=CogABCMeta):
footer = f"Added {time_delta} by {actor} | Reason: {reason}"
embed = Embed(description=f"{msg.author.mention} {message_jump}")
- embed.set_footer(text=textwrap.shorten(footer, width=128, placeholder="..."))
+ embed.set_footer(text=textwrap.shorten(footer, width=256, placeholder="..."))
await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url)
diff --git a/bot/exts/moderation/watchchannels/bigbrother.py b/bot/exts/moderation/watchchannels/bigbrother.py
index 3b44056d3..3aa253fea 100644
--- a/bot/exts/moderation/watchchannels/bigbrother.py
+++ b/bot/exts/moderation/watchchannels/bigbrother.py
@@ -6,7 +6,7 @@ from discord.ext.commands import Cog, Context, group, has_any_role
from bot.bot import Bot
from bot.constants import Channels, MODERATION_ROLES, Webhooks
-from bot.converters import FetchedMember
+from bot.converters import MemberOrUser
from bot.exts.moderation.infraction._utils import post_infraction
from bot.exts.moderation.watchchannels._watchchannel import WatchChannel
@@ -60,7 +60,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
@bigbrother_group.command(name='watch', aliases=('w',), root_aliases=('watch',))
@has_any_role(*MODERATION_ROLES)
- async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
+ async def watch_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None:
"""
Relay messages sent by the given `user` to the `#big-brother` channel.
@@ -71,11 +71,11 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
@bigbrother_group.command(name='unwatch', aliases=('uw',), root_aliases=('unwatch',))
@has_any_role(*MODERATION_ROLES)
- async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
+ async def unwatch_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None:
"""Stop relaying messages by the given `user`."""
await self.apply_unwatch(ctx, user, reason)
- async def apply_watch(self, ctx: Context, user: FetchedMember, reason: str) -> None:
+ async def apply_watch(self, ctx: Context, user: MemberOrUser, reason: str) -> None:
"""
Add `user` to watched users and apply a watch infraction with `reason`.
@@ -94,6 +94,11 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
await ctx.send(f":x: {user} is already being watched.")
return
+ # discord.User instances don't have a roles attribute
+ if hasattr(user, "roles") and any(role.id in MODERATION_ROLES for role in user.roles):
+ await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I must be kind to my masters.")
+ return
+
response = await post_infraction(ctx, user, 'watch', reason, hidden=True, active=True)
if response is not None:
@@ -120,7 +125,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
await ctx.send(msg)
- async def apply_unwatch(self, ctx: Context, user: FetchedMember, reason: str, send_message: bool = True) -> None:
+ async def apply_unwatch(self, ctx: Context, user: MemberOrUser, reason: str, send_message: bool = True) -> None:
"""
Remove `user` from watched users and mark their infraction as inactive with `reason`.
diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py
index a5b6de00f..8db2d7eac 100644
--- a/bot/exts/recruitment/talentpool/_cog.py
+++ b/bot/exts/recruitment/talentpool/_cog.py
@@ -5,18 +5,20 @@ from io import StringIO
from typing import Union
import discord
-from discord import Color, Embed, Member, PartialMessage, RawReactionActionEvent, User
+from async_rediscache import RedisCache
+from discord import Color, Embed, Member, PartialMessage, RawReactionActionEvent
from discord.ext.commands import Cog, Context, group, has_any_role
from bot.api import ResponseCodeError
from bot.bot import Bot
-from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, STAFF_ROLES
-from bot.converters import FetchedMember
+from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES
+from bot.converters import MemberOrUser
from bot.exts.recruitment.talentpool._review import Reviewer
from bot.pagination import LinePaginator
from bot.utils import time
from bot.utils.time import get_time_delta
+AUTOREVIEW_ENABLED_KEY = "autoreview_enabled"
REASON_MAX_CHARS = 1000
log = logging.getLogger(__name__)
@@ -25,10 +27,25 @@ log = logging.getLogger(__name__)
class TalentPool(Cog, name="Talentpool"):
"""Relays messages of helper candidates to a watch channel to observe them."""
+ # RedisCache[str, bool]
+ # Can contain a single key, "autoreview_enabled", with the value a bool indicating if autoreview is enabled.
+ talentpool_settings = RedisCache()
+
def __init__(self, bot: Bot) -> None:
self.bot = bot
self.reviewer = Reviewer(self.__class__.__name__, bot, self)
- self.bot.loop.create_task(self.reviewer.reschedule_reviews())
+ self.bot.loop.create_task(self.schedule_autoreviews())
+
+ async def schedule_autoreviews(self) -> None:
+ """Reschedule reviews for active nominations if autoreview is enabled."""
+ if await self.autoreview_enabled():
+ await self.reviewer.reschedule_reviews()
+ else:
+ self.log.trace("Not scheduling reviews as autoreview is disabled.")
+
+ async def autoreview_enabled(self) -> bool:
+ """Return whether automatic posting of nomination reviews is enabled."""
+ return await self.talentpool_settings.get(AUTOREVIEW_ENABLED_KEY, True)
# Stores talentpool users in cache
self.cache = defaultdict(dict)
@@ -59,7 +76,51 @@ class TalentPool(Cog, name="Talentpool"):
"""Highlights the activity of helper nominees by relaying their messages to the talent pool channel."""
await ctx.send_help(ctx.command)
- @nomination_group.command(name='list', aliases=('all', 'watched'), root_aliases=("nominees",))
+ @nomination_group.group(name="autoreview", aliases=("ar",), invoke_without_command=True)
+ @has_any_role(*MODERATION_ROLES)
+ async def nomination_autoreview_group(self, ctx: Context) -> None:
+ """Commands for enabling or disabling autoreview."""
+ await ctx.send_help(ctx.command)
+
+ @nomination_autoreview_group.command(name="enable", aliases=("on",))
+ @has_any_role(Roles.admins)
+ async def autoreview_enable(self, ctx: Context) -> None:
+ """
+ Enable automatic posting of reviews.
+
+ This will post reviews up to one day overdue. Older nominations can be
+ manually reviewed with the `tp post_review <user_id>` command.
+ """
+ if await self.autoreview_enabled():
+ await ctx.send(":x: Autoreview is already enabled")
+ return
+
+ await self.talentpool_settings.set(AUTOREVIEW_ENABLED_KEY, True)
+ await self.reviewer.reschedule_reviews()
+ await ctx.send(":white_check_mark: Autoreview enabled")
+
+ @nomination_autoreview_group.command(name="disable", aliases=("off",))
+ @has_any_role(Roles.admins)
+ async def autoreview_disable(self, ctx: Context) -> None:
+ """Disable automatic posting of reviews."""
+ if not await self.autoreview_enabled():
+ await ctx.send(":x: Autoreview is already disabled")
+ return
+
+ await self.talentpool_settings.set(AUTOREVIEW_ENABLED_KEY, False)
+ self.reviewer.cancel_all()
+ await ctx.send(":white_check_mark: Autoreview disabled")
+
+ @nomination_autoreview_group.command(name="status")
+ @has_any_role(*MODERATION_ROLES)
+ async def autoreview_status(self, ctx: Context) -> None:
+ """Show whether automatic posting of reviews is enabled or disabled."""
+ if await self.autoreview_enabled():
+ await ctx.send("Autoreview is currently enabled")
+ else:
+ await ctx.send("Autoreview is currently disabled")
+
+ @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",))
@has_any_role(*MODERATION_ROLES)
async def list_command(
self,
@@ -142,7 +203,7 @@ class TalentPool(Cog, name="Talentpool"):
@nomination_group.command(name='forcewatch', aliases=('fw', 'forceadd', 'fa'), root_aliases=("forcenominate",))
@has_any_role(*MODERATION_ROLES)
- async def force_watch_command(self, ctx: Context, user: FetchedMember, *, reason: str = '') -> None:
+ async def force_watch_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None:
"""
Adds the given `user` to the talent pool, from any channel.
@@ -152,7 +213,7 @@ class TalentPool(Cog, name="Talentpool"):
@nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",))
@has_any_role(*STAFF_ROLES)
- async def add_command(self, ctx: Context, user: FetchedMember, *, reason: str = '') -> None:
+ async def watch_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None:
"""
Adds the given `user` to the talent pool.
@@ -171,7 +232,7 @@ class TalentPool(Cog, name="Talentpool"):
await self._watch_user(ctx, user, reason)
- async def _watch_user(self, ctx: Context, user: FetchedMember, reason: str) -> None:
+ async def _watch_user(self, ctx: Context, user: MemberOrUser, reason: str) -> None:
"""Adds the given user to the talent pool."""
if user.bot:
await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.")
@@ -215,7 +276,7 @@ class TalentPool(Cog, name="Talentpool"):
self.cache[user.id] = response_data
- if user.id not in self.reviewer:
+ if await self.autoreview_enabled() and user.id not in self.reviewer:
self.reviewer.schedule_review(user.id)
history = await self.bot.api_client.get(
@@ -227,7 +288,7 @@ class TalentPool(Cog, name="Talentpool"):
}
)
- msg = f"✅ The nomination for {user} has been added to the talent pool"
+ msg = f"✅ The nomination for {user.mention} has been added to the talent pool"
if history:
msg += f"\n\n({len(history)} previous nominations in total)"
@@ -235,7 +296,7 @@ class TalentPool(Cog, name="Talentpool"):
@nomination_group.command(name='history', aliases=('info', 'search'))
@has_any_role(*MODERATION_ROLES)
- async def history_command(self, ctx: Context, user: FetchedMember) -> None:
+ async def history_command(self, ctx: Context, user: MemberOrUser) -> None:
"""Shows the specified user's nomination history."""
result = await self.bot.api_client.get(
'bot/nominations',
@@ -264,7 +325,7 @@ class TalentPool(Cog, name="Talentpool"):
@nomination_group.command(name='end', aliases=('unwatch',), root_aliases=("unnominate",))
@has_any_role(*MODERATION_ROLES)
- async def end_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
+ async def unwatch_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None:
"""
Ends the active nomination of the specified user with the given reason.
@@ -275,7 +336,7 @@ class TalentPool(Cog, name="Talentpool"):
return
if await self.unwatch(user.id, reason):
- await ctx.send(f":white_check_mark: Successfully un-nominated {user}")
+ await ctx.send(f":white_check_mark: Messages sent by {user.mention} will no longer be relayed")
else:
await ctx.send(":x: The specified user does not have an active nomination")
@@ -287,7 +348,7 @@ class TalentPool(Cog, name="Talentpool"):
@nomination_edit_group.command(name='reason')
@has_any_role(*MODERATION_ROLES)
- async def edit_reason_command(self, ctx: Context, nomination_id: int, actor: FetchedMember, *, reason: str) -> None:
+ async def edit_reason_command(self, ctx: Context, nomination_id: int, actor: MemberOrUser, *, reason: str) -> None:
"""Edits the reason of a specific nominator in a specific active nomination."""
if len(reason) > REASON_MAX_CHARS:
await ctx.send(f":x: Maxiumum allowed characters for the reason is {REASON_MAX_CHARS}.")
@@ -308,7 +369,7 @@ class TalentPool(Cog, name="Talentpool"):
return
if not any(entry["actor"] == actor.id for entry in nomination["entries"]):
- await ctx.send(f":x: {actor} doesn't have an entry in this nomination.")
+ await ctx.send(f":x: {actor.mention} doesn't have an entry in this nomination.")
return
log.trace(f"Changing reason for nomination with id {nomination_id} of actor {actor} to {repr(reason)}")
@@ -381,7 +442,7 @@ class TalentPool(Cog, name="Talentpool"):
await ctx.message.add_reaction(Emojis.check_mark)
@Cog.listener()
- async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None:
+ async def on_member_ban(self, guild: Guild, user: Union[MemberOrUser]) -> None:
"""Remove `user` from the talent pool after they are banned."""
await self.unwatch(user.id, "User was banned.")
@@ -427,7 +488,8 @@ class TalentPool(Cog, name="Talentpool"):
json={'end_reason': reason, 'active': False}
)
- self.reviewer.cancel(user_id)
+ if await self.autoreview_enabled():
+ self.reviewer.cancel(user_id)
return True
diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py
index 4e61ecb3e..dfdcdcffe 100644
--- a/bot/exts/recruitment/talentpool/_review.py
+++ b/bot/exts/recruitment/talentpool/_review.py
@@ -10,16 +10,15 @@ from datetime import datetime, timedelta
from typing import List, Optional, Union
from dateutil.parser import isoparse
-from dateutil.relativedelta import relativedelta
from discord import Embed, Emoji, Member, Message, NoMoreItems, PartialMessage, TextChannel
from discord.ext.commands import Context
from bot.api import ResponseCodeError
from bot.bot import Bot
-from bot.constants import Channels, Colours, Emojis, Guild, Roles
+from bot.constants import Channels, Colours, Emojis, Guild
from bot.utils.messages import count_unique_users_reaction, pin_no_system_message
from bot.utils.scheduling import Scheduler
-from bot.utils.time import get_time_delta, humanize_delta, time_since
+from bot.utils.time import get_time_delta, time_since
if typing.TYPE_CHECKING:
from bot.exts.recruitment.talentpool._cog import TalentPool
@@ -31,11 +30,15 @@ MAX_DAYS_IN_POOL = 30
# Maximum amount of characters allowed in a message
MAX_MESSAGE_SIZE = 2000
+# Maximum amount of characters allowed in an embed
+MAX_EMBED_SIZE = 4000
-# Regex finding the user ID of a user mention
-MENTION_RE = re.compile(r"<@!?(\d+?)>")
-# Regex matching role pings
-ROLE_MENTION_RE = re.compile(r"<@&\d+>")
+# Regex for finding the first message of a nomination, and extracting the nominee.
+# Historic nominations will have 2 role mentions at the start, new ones won't, optionally match for this.
+NOMINATION_MESSAGE_REGEX = re.compile(
+ r"(?:<@&\d+> <@&\d+>\n)*?<@!?(\d+?)> \(.+#\d{4}\) for Helper!\n\n\*\*Nominated by:\*\*",
+ re.MULTILINE
+)
class Reviewer:
@@ -75,7 +78,7 @@ class Reviewer:
async def post_review(self, user_id: int, update_database: bool) -> None:
"""Format the review of a user and post it to the nomination voting channel."""
- review, seen_emoji = await self.make_review(user_id)
+ review, reviewed_emoji = await self.make_review(user_id)
if not review:
return
@@ -88,8 +91,8 @@ class Reviewer:
await pin_no_system_message(messages[0])
last_message = messages[-1]
- if seen_emoji:
- for reaction in (seen_emoji, "\N{THUMBS UP SIGN}", "\N{THUMBS DOWN SIGN}"):
+ if reviewed_emoji:
+ for reaction in (reviewed_emoji, "\N{THUMBS UP SIGN}", "\N{THUMBS DOWN SIGN}"):
await last_message.add_reaction(reaction)
if update_database:
@@ -97,7 +100,7 @@ class Reviewer:
await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True})
async def make_review(self, user_id: int) -> typing.Tuple[str, Optional[Emoji]]:
- """Format a generic review of a user and return it with the seen emoji."""
+ """Format a generic review of a user and return it with the reviewed emoji."""
log.trace(f"Formatting the review of {user_id}")
# Since `watched_users` is a defaultdict, we should take care
@@ -117,7 +120,7 @@ class Reviewer:
f"I tried to review the user with ID `{user_id}`, but they don't appear to be on the server :pensive:"
), None
- opening = f"<@&{Roles.mod_team}> <@&{Roles.admins}>\n{member.mention} ({member}) for Helper!"
+ opening = f"{member.mention} ({member}) for Helper!"
current_nominations = "\n\n".join(
f"**<@{entry['actor']}>:** {entry['reason'] or '*no reason given*'}"
@@ -127,28 +130,28 @@ class Reviewer:
review_body = await self._construct_review_body(member)
- seen_emoji = self._random_ducky(guild)
+ reviewed_emoji = self._random_ducky(guild)
vote_request = (
"*Refer to their nomination and infraction histories for further details*.\n"
- f"*Please react {seen_emoji} if you've seen this post."
- " Then react :+1: for approval, or :-1: for disapproval*."
+ f"*Please react {reviewed_emoji} once you have reviewed this user,"
+ " and react :+1: for approval, or :-1: for disapproval*."
)
review = "\n\n".join((opening, current_nominations, review_body, vote_request))
- return review, seen_emoji
+ return review, reviewed_emoji
async def archive_vote(self, message: PartialMessage, passed: bool) -> None:
"""Archive this vote to #nomination-archive."""
message = await message.fetch()
- # We consider the first message in the nomination to contain the two role pings
+ # We consider the first message in the nomination to contain the user ping, username#discrim, and fixed text
messages = [message]
- if not len(ROLE_MENTION_RE.findall(message.content)) >= 2:
+ if not NOMINATION_MESSAGE_REGEX.search(message.content):
with contextlib.suppress(NoMoreItems):
async for new_message in message.channel.history(before=message.created_at):
messages.append(new_message)
- if len(ROLE_MENTION_RE.findall(new_message.content)) >= 2:
+ if NOMINATION_MESSAGE_REGEX.search(new_message.content):
break
log.debug(f"Found {len(messages)} messages: {', '.join(str(m.id) for m in messages)}")
@@ -160,10 +163,10 @@ class Reviewer:
content = "".join(parts)
# We assume that the first user mentioned is the user that we are voting on
- user_id = int(MENTION_RE.search(content).group(1))
+ user_id = int(NOMINATION_MESSAGE_REGEX.search(content).group(1))
# Get reaction counts
- seen = await count_unique_users_reaction(
+ reviewed = await count_unique_users_reaction(
messages[0],
lambda r: "ducky" in str(r) or str(r) == "\N{EYES}",
count_bots=False
@@ -188,7 +191,7 @@ class Reviewer:
embed_content = (
f"{result} on {timestamp}\n"
- f"With {seen} {Emojis.ducky_dave} {upvotes} :+1: {downvotes} :-1:\n\n"
+ f"With {reviewed} {Emojis.ducky_dave} {upvotes} :+1: {downvotes} :-1:\n\n"
f"{stripped_content}"
)
@@ -199,7 +202,7 @@ class Reviewer:
channel = self.bot.get_channel(Channels.nomination_archive)
for number, part in enumerate(
- textwrap.wrap(embed_content, width=MAX_MESSAGE_SIZE, replace_whitespace=False, placeholder="")
+ textwrap.wrap(embed_content, width=MAX_EMBED_SIZE, replace_whitespace=False, placeholder="")
):
await channel.send(embed=Embed(
title=embed_title if number == 0 else None,
@@ -253,9 +256,9 @@ class Reviewer:
last_channel = user_activity["top_channel_activity"][-1]
channels += f", and {last_channel[1]} in {last_channel[0]}"
- time_on_server = humanize_delta(relativedelta(datetime.utcnow(), member.joined_at), max_units=2)
+ joined_at_formatted = time_since(member.joined_at)
review = (
- f"{member.name} has been on the server for **{time_on_server}**"
+ f"{member.name} joined the server **{joined_at_formatted}**"
f" and has **{messages} messages**{channels}."
)
@@ -345,7 +348,7 @@ class Reviewer:
nomination_times = f"{num_entries} times" if num_entries > 1 else "once"
rejection_times = f"{len(history)} times" if len(history) > 1 else "once"
- end_time = time_since(isoparse(history[0]['ended_at']).replace(tzinfo=None), max_units=2)
+ end_time = time_since(isoparse(history[0]['ended_at']).replace(tzinfo=None))
review = (
f"They were nominated **{nomination_times}** before"
@@ -357,7 +360,7 @@ class Reviewer:
@staticmethod
def _random_ducky(guild: Guild) -> Union[Emoji, str]:
- """Picks a random ducky emoji to be used to mark the vote as seen. If no duckies found returns :eyes:."""
+ """Picks a random ducky emoji. If no duckies found returns :eyes:."""
duckies = [emoji for emoji in guild.emojis if emoji.name.startswith("ducky")]
if not duckies:
return ":eyes:"
diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py
index a4c828f95..d84709616 100644
--- a/bot/exts/utils/bot.py
+++ b/bot/exts/utils/bot.py
@@ -44,6 +44,8 @@ class BotCog(Cog, name="Bot"):
"""Repeat the given message in either a specified channel or the current channel."""
if channel is None:
await ctx.send(text)
+ elif not channel.permissions_for(ctx.author).send_messages:
+ await ctx.send("You don't have permission to speak in that channel.")
else:
await channel.send(text)
diff --git a/bot/exts/utils/extensions.py b/bot/exts/utils/extensions.py
index 8a1ed98f4..f78664527 100644
--- a/bot/exts/utils/extensions.py
+++ b/bot/exts/utils/extensions.py
@@ -10,8 +10,9 @@ from discord.ext.commands import Context, group
from bot import exts
from bot.bot import Bot
from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs
+from bot.converters import Extension
from bot.pagination import LinePaginator
-from bot.utils.extensions import EXTENSIONS, unqualify
+from bot.utils.extensions import EXTENSIONS
log = logging.getLogger(__name__)
@@ -29,44 +30,6 @@ class Action(Enum):
RELOAD = functools.partial(Bot.reload_extension)
-class Extension(commands.Converter):
- """
- Fully qualify the name of an extension and ensure it exists.
-
- The * and ** values bypass this when used with the reload command.
- """
-
- async def convert(self, ctx: Context, argument: str) -> str:
- """Fully qualify the name of an extension and ensure it exists."""
- # Special values to reload all extensions
- if argument == "*" or argument == "**":
- return argument
-
- argument = argument.lower()
-
- if argument in EXTENSIONS:
- return argument
- elif (qualified_arg := f"{exts.__name__}.{argument}") in EXTENSIONS:
- return qualified_arg
-
- matches = []
- for ext in EXTENSIONS:
- if argument == unqualify(ext):
- matches.append(ext)
-
- if len(matches) > 1:
- matches.sort()
- names = "\n".join(matches)
- raise commands.BadArgument(
- f":x: `{argument}` is an ambiguous extension name. "
- f"Please use one of the following fully-qualified names.```\n{names}```"
- )
- elif matches:
- return matches[0]
- else:
- raise commands.BadArgument(f":x: Could not find the extension `{argument}`.")
-
-
class Extensions(commands.Cog):
"""Extension management commands."""
diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py
index 6f2da3131..5d2cd7611 100644
--- a/bot/exts/utils/internal.py
+++ b/bot/exts/utils/internal.py
@@ -11,10 +11,10 @@ from io import StringIO
from typing import Any, Optional, Tuple
import discord
-from discord.ext.commands import Cog, Context, group, has_any_role
+from discord.ext.commands import Cog, Context, group, has_any_role, is_owner
from bot.bot import Bot
-from bot.constants import Roles
+from bot.constants import DEBUG_MODE, Roles
from bot.utils import find_nth_occurrence, send_to_paste_service
log = logging.getLogger(__name__)
@@ -33,6 +33,9 @@ class Internal(Cog):
self.socket_event_total = 0
self.socket_events = Counter()
+ if DEBUG_MODE:
+ self.eval.add_check(is_owner().predicate)
+
@Cog.listener()
async def on_socket_response(self, msg: dict) -> None:
"""When a websocket event is received, increase our counters."""
diff --git a/bot/exts/utils/jams.py b/bot/exts/utils/jams.py
deleted file mode 100644
index 98fbcb303..000000000
--- a/bot/exts/utils/jams.py
+++ /dev/null
@@ -1,145 +0,0 @@
-import logging
-import typing as t
-
-from discord import CategoryChannel, Guild, Member, PermissionOverwrite, Role
-from discord.ext import commands
-from more_itertools import unique_everseen
-
-from bot.bot import Bot
-from bot.constants import Roles
-
-log = logging.getLogger(__name__)
-
-MAX_CHANNELS = 50
-CATEGORY_NAME = "Code Jam"
-
-
-class CodeJams(commands.Cog):
- """Manages the code-jam related parts of our server."""
-
- def __init__(self, bot: Bot):
- self.bot = bot
-
- @commands.command()
- @commands.has_any_role(Roles.admins)
- async def createteam(self, ctx: commands.Context, team_name: str, members: commands.Greedy[Member]) -> None:
- """
- Create team channels (voice and text) in the Code Jams category, assign roles, and add overwrites for the team.
-
- The first user passed will always be the team leader.
- """
- # Ignore duplicate members
- members = list(unique_everseen(members))
-
- # We had a little issue during Code Jam 4 here, the greedy converter did it's job
- # and ignored anything which wasn't a valid argument which left us with teams of
- # two members or at some times even 1 member. This fixes that by checking that there
- # are always 3 members in the members list.
- if len(members) < 3:
- await ctx.send(
- ":no_entry_sign: One of your arguments was invalid\n"
- f"There must be a minimum of 3 valid members in your team. Found: {len(members)}"
- " members"
- )
- return
-
- team_channel = await self.create_channels(ctx.guild, team_name, members)
- await self.add_roles(ctx.guild, members)
-
- await ctx.send(
- f":ok_hand: Team created: {team_channel}\n"
- f"**Team Leader:** {members[0].mention}\n"
- f"**Team Members:** {' '.join(member.mention for member in members[1:])}"
- )
-
- async def get_category(self, guild: Guild) -> CategoryChannel:
- """
- Return a code jam category.
-
- If all categories are full or none exist, create a new category.
- """
- for category in guild.categories:
- # Need 2 available spaces: one for the text channel and one for voice.
- if category.name == CATEGORY_NAME and MAX_CHANNELS - len(category.channels) >= 2:
- return category
-
- return await self.create_category(guild)
-
- @staticmethod
- async def create_category(guild: Guild) -> CategoryChannel:
- """Create a new code jam category and return it."""
- log.info("Creating a new code jam category.")
-
- category_overwrites = {
- guild.default_role: PermissionOverwrite(read_messages=False),
- guild.me: PermissionOverwrite(read_messages=True)
- }
-
- return await guild.create_category_channel(
- CATEGORY_NAME,
- overwrites=category_overwrites,
- reason="It's code jam time!"
- )
-
- @staticmethod
- def get_overwrites(members: t.List[Member], guild: Guild) -> t.Dict[t.Union[Member, Role], PermissionOverwrite]:
- """Get code jam team channels permission overwrites."""
- # First member is always the team leader
- team_channel_overwrites = {
- members[0]: PermissionOverwrite(
- manage_messages=True,
- read_messages=True,
- manage_webhooks=True,
- connect=True
- ),
- guild.default_role: PermissionOverwrite(read_messages=False, connect=False),
- }
-
- # Rest of members should just have read_messages
- for member in members[1:]:
- team_channel_overwrites[member] = PermissionOverwrite(
- read_messages=True,
- connect=True
- )
-
- return team_channel_overwrites
-
- async def create_channels(self, guild: Guild, team_name: str, members: t.List[Member]) -> str:
- """Create team text and voice channels. Return the mention for the text channel."""
- # Get permission overwrites and category
- team_channel_overwrites = self.get_overwrites(members, guild)
- code_jam_category = await self.get_category(guild)
-
- # Create a text channel for the team
- team_channel = await guild.create_text_channel(
- team_name,
- overwrites=team_channel_overwrites,
- category=code_jam_category
- )
-
- # Create a voice channel for the team
- team_voice_name = " ".join(team_name.split("-")).title()
-
- await guild.create_voice_channel(
- team_voice_name,
- overwrites=team_channel_overwrites,
- category=code_jam_category
- )
-
- return team_channel.mention
-
- @staticmethod
- async def add_roles(guild: Guild, members: t.List[Member]) -> None:
- """Assign team leader and jammer roles."""
- # Assign team leader role
- await members[0].add_roles(guild.get_role(Roles.team_leaders))
-
- # Assign rest of roles
- jammer_role = guild.get_role(Roles.jammers)
- for member in members:
- await member.add_roles(jammer_role)
-
-
-def setup(bot: Bot) -> None:
- """Load the CodeJams cog."""
- bot.add_cog(CodeJams(bot))
diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py
index 750ff46d2..cf0e3265e 100644
--- a/bot/exts/utils/ping.py
+++ b/bot/exts/utils/ping.py
@@ -1,18 +1,16 @@
-import socket
-import urllib.parse
from datetime import datetime
-import aioping
+from aiohttp import client_exceptions
from discord import Embed
from discord.ext import commands
from bot.bot import Bot
-from bot.constants import Channels, Emojis, STAFF_ROLES, URLs
+from bot.constants import Channels, STAFF_PARTNERS_COMMUNITY_ROLES, URLs
from bot.decorators import in_whitelist
DESCRIPTIONS = (
"Command processing time",
- "Python Discord website latency",
+ "Python Discord website status",
"Discord API latency"
)
ROUND_LATENCY = 3
@@ -25,7 +23,7 @@ class Latency(commands.Cog):
self.bot = bot
@commands.command()
- @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES)
+ @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_PARTNERS_COMMUNITY_ROLES)
async def ping(self, ctx: commands.Context) -> None:
"""
Gets different measures of latency within the bot.
@@ -41,23 +39,23 @@ class Latency(commands.Cog):
bot_ping = f"{bot_ping:.{ROUND_LATENCY}f} ms"
try:
- url = urllib.parse.urlparse(URLs.site_schema + URLs.site).hostname
- try:
- delay = await aioping.ping(url, family=socket.AddressFamily.AF_INET) * 1000
- site_ping = f"{delay:.{ROUND_LATENCY}f} ms"
- except OSError:
- # Some machines do not have permission to run ping
- site_ping = "Permission denied, could not ping."
+ async with self.bot.http_session.get(f"{URLs.site_api_schema}{URLs.site_api}/healthcheck") as request:
+ request.raise_for_status()
+ site_status = "Healthy"
- except TimeoutError:
- site_ping = f"{Emojis.cross_mark} Connection timed out."
+ except client_exceptions.ClientResponseError as e:
+ """The site returned an unexpected response."""
+ site_status = f"The site returned an error in the response: ({e.status}) {e}"
+ except client_exceptions.ClientConnectionError:
+ """Something went wrong with the connection."""
+ site_status = "Could not establish connection with the site."
# Discord Protocol latency return value is in seconds, must be multiplied by 1000 to get milliseconds.
discord_ping = f"{self.bot.latency * 1000:.{ROUND_LATENCY}f} ms"
embed = Embed(title="Pong!")
- for desc, latency in zip(DESCRIPTIONS, [bot_ping, site_ping, discord_ping]):
+ for desc, latency in zip(DESCRIPTIONS, [bot_ping, site_status, discord_ping]):
embed.add_field(name=desc, value=latency, inline=False)
await ctx.send(embed=embed)
diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py
index 6c21920a1..41b6cac5c 100644
--- a/bot/exts/utils/reminders.py
+++ b/bot/exts/utils/reminders.py
@@ -3,23 +3,25 @@ import logging
import random
import textwrap
import typing as t
-from datetime import datetime, timedelta
+from datetime import datetime
from operator import itemgetter
import discord
from dateutil.parser import isoparse
-from dateutil.relativedelta import relativedelta
from discord.ext.commands import Cog, Context, Greedy, group
from bot.bot import Bot
-from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_ROLES
-from bot.converters import Duration
+from bot.constants import (
+ Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES,
+ Roles, STAFF_PARTNERS_COMMUNITY_ROLES
+)
+from bot.converters import Duration, UnambiguousUser
from bot.pagination import LinePaginator
from bot.utils.checks import has_any_role_check, has_no_roles_check
from bot.utils.lock import lock_arg
from bot.utils.messages import send_denial
from bot.utils.scheduling import Scheduler
-from bot.utils.time import humanize_delta
+from bot.utils.time import TimestampFormats, discord_timestamp
log = logging.getLogger(__name__)
@@ -28,6 +30,7 @@ WHITELISTED_CHANNELS = Guild.reminder_whitelist
MAXIMUM_REMINDERS = 5
Mentionable = t.Union[discord.Member, discord.Role]
+ReminderMention = t.Union[UnambiguousUser, discord.Role]
class Reminders(Cog):
@@ -62,8 +65,7 @@ class Reminders(Cog):
# If the reminder is already overdue ...
if remind_at < now:
- late = relativedelta(now, remind_at)
- await self.send_reminder(reminder, late)
+ await self.send_reminder(reminder, remind_at)
else:
self.schedule_reminder(reminder)
@@ -86,8 +88,7 @@ class Reminders(Cog):
async def _send_confirmation(
ctx: Context,
on_success: str,
- reminder_id: t.Union[str, int],
- delivery_dt: t.Optional[datetime],
+ reminder_id: t.Union[str, int]
) -> None:
"""Send an embed confirming the reminder change was made successfully."""
embed = discord.Embed(
@@ -98,11 +99,6 @@ class Reminders(Cog):
footer_str = f"ID: {reminder_id}"
- if delivery_dt:
- # Reminder deletion will have a `None` `delivery_dt`
- footer_str += ', Due'
- embed.timestamp = delivery_dt
-
embed.set_footer(text=footer_str)
await ctx.send(embed=embed)
@@ -118,7 +114,7 @@ class Reminders(Cog):
If mentions aren't allowed, also return the type of mention(s) disallowed.
"""
- if await has_no_roles_check(ctx, *STAFF_ROLES):
+ if await has_no_roles_check(ctx, *STAFF_PARTNERS_COMMUNITY_ROLES):
return False, "members/roles"
elif await has_no_roles_check(ctx, *MODERATION_ROLES):
return all(isinstance(mention, discord.Member) for mention in mentions), "roles"
@@ -144,7 +140,7 @@ class Reminders(Cog):
"""Converts Role and Member ids to their corresponding objects if possible."""
guild = self.bot.get_guild(Guild.id)
for mention_id in mention_ids:
- if (mentionable := (guild.get_member(mention_id) or guild.get_role(mention_id))):
+ if mentionable := (guild.get_member(mention_id) or guild.get_role(mention_id)):
yield mentionable
def schedule_reminder(self, reminder: dict) -> None:
@@ -174,59 +170,90 @@ class Reminders(Cog):
self.schedule_reminder(reminder)
@lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True)
- async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None:
+ async def send_reminder(self, reminder: dict, expected_time: datetime = None) -> None:
"""Send the reminder."""
is_valid, user, channel = self.ensure_valid_reminder(reminder)
if not is_valid:
# No need to cancel the task too; it'll simply be done once this coroutine returns.
return
-
embed = discord.Embed()
- embed.colour = discord.Colour.blurple()
- embed.set_author(
- icon_url=Icons.remind_blurple,
- name="It has arrived!"
- )
-
- embed.description = f"Here's your reminder: `{reminder['content']}`."
-
- if reminder.get("jump_url"): # keep backward compatibility
- embed.description += f"\n[Jump back to when you created the reminder]({reminder['jump_url']})"
-
- if late:
+ if expected_time:
embed.colour = discord.Colour.red()
embed.set_author(
icon_url=Icons.remind_red,
- name=f"Sorry it arrived {humanize_delta(late, max_units=2)} late!"
+ name="Sorry, your reminder should have arrived earlier!"
+ )
+ else:
+ embed.colour = discord.Colour.blurple()
+ embed.set_author(
+ icon_url=Icons.remind_blurple,
+ name="It has arrived!"
)
+ # Let's not use a codeblock to keep emojis and mentions working. Embeds are safe anyway.
+ embed.description = f"Here's your reminder: {reminder['content']}"
+
+ # Here the jump URL is in the format of base_url/guild_id/channel_id/message_id
additional_mentions = ' '.join(
mentionable.mention for mentionable in self.get_mentionables(reminder["mentions"])
)
- await channel.send(content=f"{user.mention} {additional_mentions}", embed=embed)
+ jump_url = reminder.get("jump_url")
+ embed.description += f"\n[Jump back to when you created the reminder]({jump_url})"
+ partial_message = channel.get_partial_message(int(jump_url.split("/")[-1]))
+ try:
+ await partial_message.reply(content=f"{additional_mentions}", embed=embed)
+ except discord.HTTPException as e:
+ log.info(
+ f"There was an error when trying to reply to a reminder invocation message, {e}, "
+ "fall back to using jump_url"
+ )
+ await channel.send(content=f"{user.mention} {additional_mentions}", embed=embed)
log.debug(f"Deleting reminder #{reminder['id']} (the user has been reminded).")
await self.bot.api_client.delete(f"bot/reminders/{reminder['id']}")
@group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True)
async def remind_group(
- self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str
+ self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: str
) -> None:
- """Commands for managing your reminders."""
+ """
+ Commands for managing your reminders.
+
+ The `expiration` duration of `!remind new` supports the following symbols for each unit of time:
+ - years: `Y`, `y`, `year`, `years`
+ - months: `m`, `month`, `months`
+ - weeks: `w`, `W`, `week`, `weeks`
+ - days: `d`, `D`, `day`, `days`
+ - hours: `H`, `h`, `hour`, `hours`
+ - minutes: `M`, `minute`, `minutes`
+ - seconds: `S`, `s`, `second`, `seconds`
+
+ For example, to set a reminder that expires in 3 days and 1 minute, you can do `!remind new 3d1M Do something`.
+ """
await self.new_reminder(ctx, mentions=mentions, expiration=expiration, content=content)
@remind_group.command(name="new", aliases=("add", "create"))
async def new_reminder(
- self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str
+ self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: str
) -> None:
"""
Set yourself a simple reminder.
- Expiration is parsed per: http://strftime.org/
+ The `expiration` duration supports the following symbols for each unit of time:
+ - years: `Y`, `y`, `year`, `years`
+ - months: `m`, `month`, `months`
+ - weeks: `w`, `W`, `week`, `weeks`
+ - days: `d`, `D`, `day`, `days`
+ - hours: `H`, `h`, `hour`, `hours`
+ - minutes: `M`, `minute`, `minutes`
+ - seconds: `S`, `s`, `second`, `seconds`
+
+ For example, to set a reminder that expires in 3 days and 1 minute, you can do `!remind new 3d1M Do something`.
"""
- # If the user is not staff, we need to verify whether or not to make a reminder at all.
- if await has_no_roles_check(ctx, *STAFF_ROLES):
+ # If the user is not staff, partner or part of the python community,
+ # we need to verify whether or not to make a reminder at all.
+ if await has_no_roles_check(ctx, *STAFF_PARTNERS_COMMUNITY_ROLES):
# If they don't have permission to set a reminder in this channel
if ctx.channel.id not in WHITELISTED_CHANNELS:
@@ -270,9 +297,7 @@ class Reminders(Cog):
}
)
- now = datetime.utcnow() - timedelta(seconds=1)
- humanized_delta = humanize_delta(relativedelta(expiration, now))
- mention_string = f"Your reminder will arrive in {humanized_delta}"
+ mention_string = f"Your reminder will arrive on {discord_timestamp(expiration, TimestampFormats.DAY_TIME)}"
if mentions:
mention_string += f" and will mention {len(mentions)} other(s)"
@@ -282,8 +307,7 @@ class Reminders(Cog):
await self._send_confirmation(
ctx,
on_success=mention_string,
- reminder_id=reminder["id"],
- delivery_dt=expiration,
+ reminder_id=reminder["id"]
)
self.schedule_reminder(reminder)
@@ -297,8 +321,6 @@ class Reminders(Cog):
params={'author__id': str(ctx.author.id)}
)
- now = datetime.utcnow()
-
# Make a list of tuples so it can be sorted by time.
reminders = sorted(
(
@@ -313,7 +335,7 @@ class Reminders(Cog):
for content, remind_at, id_, mentions in reminders:
# Parse and humanize the time, make it pretty :D
remind_datetime = isoparse(remind_at).replace(tzinfo=None)
- time = humanize_delta(relativedelta(remind_datetime, now))
+ time = discord_timestamp(remind_datetime, TimestampFormats.RELATIVE)
mentions = ", ".join(
# Both Role and User objects have the `name` attribute
@@ -322,7 +344,7 @@ class Reminders(Cog):
mention_string = f"\n**Mentions:** {mentions}" if mentions else ""
text = textwrap.dedent(f"""
- **Reminder #{id_}:** *expires in {time}* (ID: {id_}){mention_string}
+ **Reminder #{id_}:** *expires {time}* (ID: {id_}){mention_string}
{content}
""").strip()
@@ -350,7 +372,20 @@ class Reminders(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."""
+ """
+ Commands for modifying your current reminders.
+
+ The `expiration` duration supports the following symbols for each unit of time:
+ - years: `Y`, `y`, `year`, `years`
+ - months: `m`, `month`, `months`
+ - weeks: `w`, `W`, `week`, `weeks`
+ - days: `d`, `D`, `day`, `days`
+ - hours: `H`, `h`, `hour`, `hours`
+ - minutes: `M`, `minute`, `minutes`
+ - seconds: `S`, `s`, `second`, `seconds`
+
+ For example, to edit a reminder to expire in 3 days and 1 minute, you can do `!remind edit duration 1234 3d1M`.
+ """
await ctx.send_help(ctx.command)
@edit_reminder_group.command(name="duration", aliases=("time",))
@@ -358,7 +393,16 @@ class Reminders(Cog):
"""
Edit one of your reminder's expiration.
- Expiration is parsed per: http://strftime.org/
+ The `expiration` duration supports the following symbols for each unit of time:
+ - years: `Y`, `y`, `year`, `years`
+ - months: `m`, `month`, `months`
+ - weeks: `w`, `W`, `week`, `weeks`
+ - days: `d`, `D`, `day`, `days`
+ - hours: `H`, `h`, `hour`, `hours`
+ - minutes: `M`, `minute`, `minutes`
+ - seconds: `S`, `s`, `second`, `seconds`
+
+ For example, to edit a reminder to expire in 3 days and 1 minute, you can do `!remind edit duration 1234 3d1M`.
"""
await self.edit_reminder(ctx, id_, {'expiration': expiration.isoformat()})
@@ -368,7 +412,7 @@ class Reminders(Cog):
await self.edit_reminder(ctx, id_, {"content": content})
@edit_reminder_group.command(name="mentions", aliases=("pings",))
- async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[Mentionable]) -> None:
+ async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[ReminderMention]) -> None:
"""Edit one of your reminder's mentions."""
# Remove duplicate mentions
mentions = set(mentions)
@@ -388,15 +432,11 @@ class Reminders(Cog):
return
reminder = await self._edit_reminder(id_, payload)
- # Parse the reminder expiration back into a datetime
- expiration = isoparse(reminder["expiration"]).replace(tzinfo=None)
-
# Send a confirmation message to the channel
await self._send_confirmation(
ctx,
on_success="That reminder has been edited successfully!",
reminder_id=id_,
- delivery_dt=expiration,
)
await self._reschedule_reminder(reminder)
@@ -413,8 +453,7 @@ class Reminders(Cog):
await self._send_confirmation(
ctx,
on_success="That reminder has been deleted successfully!",
- reminder_id=id_,
- delivery_dt=None,
+ reminder_id=id_
)
async def _can_modify(self, ctx: Context, reminder_id: t.Union[str, int]) -> bool:
diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py
index 4c39a7c2a..0139a6ad3 100644
--- a/bot/exts/utils/utils.py
+++ b/bot/exts/utils/utils.py
@@ -9,12 +9,11 @@ from discord.ext.commands import BadArgument, Cog, Context, clean_content, comma
from discord.utils import snowflake_time
from bot.bot import Bot
-from bot.constants import Channels, MODERATION_ROLES, Roles, STAFF_ROLES
+from bot.constants import Channels, MODERATION_ROLES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES
from bot.converters import Snowflake
from bot.decorators import in_whitelist
from bot.pagination import LinePaginator
from bot.utils import messages
-from bot.utils.checks import has_no_roles_check
from bot.utils.time import time_since
log = logging.getLogger(__name__)
@@ -40,6 +39,7 @@ If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!
"""
+LEADS_AND_COMMUNITY = (Roles.project_leads, Roles.domain_leads, Roles.partners, Roles.python_community)
class Utils(Cog):
@@ -49,20 +49,22 @@ class Utils(Cog):
self.bot = bot
@command()
- @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES)
+ @in_whitelist(channels=(Channels.bot_commands, Channels.discord_py), roles=STAFF_PARTNERS_COMMUNITY_ROLES)
async def charinfo(self, ctx: Context, *, characters: str) -> None:
"""Shows you information on up to 50 unicode characters."""
match = re.match(r"<(a?):(\w+):(\d+)>", characters)
if match:
- return await messages.send_denial(
+ await messages.send_denial(
ctx,
"**Non-Character Detected**\n"
"Only unicode characters can be processed, but a custom Discord emoji "
"was found. Please remove it and try again."
)
+ return
if len(characters) > 50:
- return await messages.send_denial(ctx, f"Too many characters ({len(characters)}/50)")
+ await messages.send_denial(ctx, f"Too many characters ({len(characters)}/50)")
+ return
def get_info(char: str) -> Tuple[str, str]:
digit = f"{ord(char):x}"
@@ -156,12 +158,9 @@ class Utils(Cog):
await ctx.send(embed=embed)
@command(aliases=("snf", "snfl", "sf"))
- @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES)
+ @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_PARTNERS_COMMUNITY_ROLES)
async def snowflake(self, ctx: Context, *snowflakes: Snowflake) -> None:
"""Get Discord snowflake creation time."""
- if len(snowflakes) > 1 and await has_no_roles_check(ctx, *STAFF_ROLES):
- raise BadArgument("Cannot process more than one snowflake in one invocation.")
-
if not snowflakes:
raise BadArgument("At least one snowflake must be provided.")
@@ -174,7 +173,7 @@ class Utils(Cog):
lines = []
for snowflake in snowflakes:
created_at = snowflake_time(snowflake)
- lines.append(f"**{snowflake}**\nCreated at {created_at} ({time_since(created_at, max_units=3)}).")
+ lines.append(f"**{snowflake}**\nCreated at {created_at} ({time_since(created_at)}).")
await LinePaginator.paginate(
lines,
@@ -185,7 +184,7 @@ class Utils(Cog):
)
@command(aliases=("poll",))
- @has_any_role(*MODERATION_ROLES, Roles.project_leads, Roles.domain_leads)
+ @has_any_role(*MODERATION_ROLES, *LEADS_AND_COMMUNITY)
async def vote(self, ctx: Context, title: clean_content(fix_channel_mentions=True), *options: str) -> None:
"""
Build a quick voting poll with matching reactions with the provided options.
diff --git a/bot/pagination.py b/bot/pagination.py
index c5c84afd9..26caa7db0 100644
--- a/bot/pagination.py
+++ b/bot/pagination.py
@@ -22,7 +22,7 @@ PAGINATION_EMOJI = (FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, DELETE_EMO
log = logging.getLogger(__name__)
-class EmptyPaginatorEmbed(Exception):
+class EmptyPaginatorEmbedError(Exception):
"""Raised when attempting to paginate with empty contents."""
pass
@@ -49,30 +49,33 @@ class LinePaginator(Paginator):
self,
prefix: str = '```',
suffix: str = '```',
- max_size: int = 2000,
- scale_to_size: int = 2000,
- max_lines: t.Optional[int] = None
+ max_size: int = 4000,
+ scale_to_size: int = 4000,
+ max_lines: t.Optional[int] = None,
+ linesep: str = "\n"
) -> None:
"""
This function overrides the Paginator.__init__ from inside discord.ext.commands.
It overrides in order to allow us to configure the maximum number of lines per page.
"""
- self.prefix = prefix
- self.suffix = suffix
-
- # Embeds that exceed 2048 characters will result in an HTTPException
- # (Discord API limit), so we've set a limit of 2000
- if max_size > 2000:
- raise ValueError(f"max_size must be <= 2,000 characters. ({max_size} > 2000)")
-
- self.max_size = max_size - len(suffix)
+ # Embeds that exceed 4096 characters will result in an HTTPException
+ # (Discord API limit), so we've set a limit of 4000
+ if max_size > 4000:
+ raise ValueError(f"max_size must be <= 4,000 characters. ({max_size} > 4000)")
+
+ super().__init__(
+ prefix,
+ suffix,
+ max_size - len(suffix),
+ linesep
+ )
if scale_to_size < max_size:
raise ValueError(f"scale_to_size must be >= max_size. ({scale_to_size} < {max_size})")
- if scale_to_size > 2000:
- raise ValueError(f"scale_to_size must be <= 2,000 characters. ({scale_to_size} > 2000)")
+ if scale_to_size > 4000:
+ raise ValueError(f"scale_to_size must be <= 4,000 characters. ({scale_to_size} > 4000)")
self.scale_to_size = scale_to_size - len(suffix)
self.max_lines = max_lines
@@ -194,7 +197,7 @@ class LinePaginator(Paginator):
suffix: str = "",
max_lines: t.Optional[int] = None,
max_size: int = 500,
- scale_to_size: int = 2000,
+ scale_to_size: int = 4000,
empty: bool = True,
restrict_to_user: User = None,
timeout: int = 300,
@@ -230,7 +233,7 @@ class LinePaginator(Paginator):
if not lines:
if exception_on_empty_embed:
log.exception("Pagination asked for empty lines iterable")
- raise EmptyPaginatorEmbed("No lines to paginate")
+ raise EmptyPaginatorEmbedError("No lines to paginate")
log.debug("No lines to add to paginator, adding '(nothing to display)' message")
lines.append("(nothing to display)")
diff --git a/bot/resources/tags/blocking.md b/bot/resources/tags/blocking.md
index 31d91294c..5554d7eba 100644
--- a/bot/resources/tags/blocking.md
+++ b/bot/resources/tags/blocking.md
@@ -1,9 +1,7 @@
**Why do we need asynchronous programming?**
-
Imagine that you're coding a Discord bot and every time somebody uses a command, you need to get some information from a database. But there's a catch: the database servers are acting up today and take a whole 10 seconds to respond. If you do **not** use asynchronous methods, your whole bot will stop running until it gets a response from the database. How do you fix this? Asynchronous programming.
**What is asynchronous programming?**
-
An asynchronous program utilises the `async` and `await` keywords. An asynchronous program pauses what it's doing and does something else whilst it waits for some third-party service to complete whatever it's supposed to do. Any code within an `async` context manager or function marked with the `await` keyword indicates to Python, that whilst this operation is being completed, it can do something else. For example:
```py
@@ -14,13 +12,10 @@ import discord
async def ping(ctx):
await ctx.send("Pong!")
```
-
**What does the term "blocking" mean?**
-
A blocking operation is wherever you do something without `await`ing it. This tells Python that this step must be completed before it can do anything else. Common examples of blocking operations, as simple as they may seem, include: outputting text, adding two numbers and appending an item onto a list. Most common Python libraries have an asynchronous version available to use in asynchronous contexts.
**`async` libraries**
-
The standard async library - `asyncio`
Asynchronous web requests - `aiohttp`
Talking to PostgreSQL asynchronously - `asyncpg`
diff --git a/bot/resources/tags/bot_var.md b/bot/resources/tags/bot_var.md
new file mode 100644
index 000000000..6833b3cd8
--- /dev/null
+++ b/bot/resources/tags/bot_var.md
@@ -0,0 +1,23 @@
+Python allows you to set custom attributes to class instances, like your bot! By adding variables as attributes to your bot you can access them anywhere you access your bot. In the discord.py library, these custom attributes are commonly known as "bot variables" and can be a lifesaver if your bot is divided into many different files. An example on how to use custom attributes on your bot is shown below:
+
+```py
+bot = commands.Bot(command_prefix="!")
+# Set an attribute on our bot
+bot.test = "I am accessible everywhere!"
+
+async def get(ctx: commands.Context):
+ """A command to get the current value of `test`."""
+ # Send what the test attribute is currently set to
+ await ctx.send(ctx.bot.test)
+
+async def setval(ctx: commands.Context, *, new_text: str):
+ """A command to set a new value of `test`."""
+ # Here we change the attribute to what was specified in new_text
+ bot.test = new_text
+```
+
+This all applies to cogs as well! You can set attributes to `self` as you wish.
+
+*Be sure **not** to overwrite attributes discord.py uses, like `cogs` or `users`. Name your attributes carefully!*
diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md
new file mode 100644
index 000000000..20043131e
--- /dev/null
+++ b/bot/resources/tags/docstring.md
@@ -0,0 +1,18 @@
+A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string - always using triple quotes - that's placed at the top of files, classes and functions. A docstring should contain a clear explanation of what it's describing. You can also include descriptions of the subject's parameter(s) and what it returns, as shown below:
+```py
+def greet(name: str, age: int) -> str:
+ """
+ Return a string that greets the given person, using their name and age.
+
+ :param name: The name of the person to greet.
+ :param age: The age of the person to greet.
+
+ :return: The greeting.
+ """
+ return f"Hello {name}, you are {age} years old!"
+```
+You can get the docstring by using the [`inspect.getdoc`](https://docs.python.org/3/library/inspect.html#inspect.getdoc) function, from the built-in [`inspect`](https://docs.python.org/3/library/inspect.html) module, or by accessing the `.__doc__` attribute. `inspect.getdoc` is often preferred, as it clears indents from the docstring.
+
+For the last example, you can print it by doing this: `print(inspect.getdoc(greet))`.
+
+For more details about what a docstring is and its usage, check out this guide by [Real Python](https://realpython.com/documenting-python-code/#docstrings-background), or the [official docstring specification](https://www.python.org/dev/peps/pep-0257/#what-is-a-docstring).
diff --git a/bot/resources/tags/dunder-methods.md b/bot/resources/tags/dunder-methods.md
new file mode 100644
index 000000000..be2b97b7b
--- /dev/null
+++ b/bot/resources/tags/dunder-methods.md
@@ -0,0 +1,28 @@
+**Dunder methods**
+
+Double-underscore methods, or "dunder" methods, are special methods defined in a class that are invoked implicitly. Like the name suggests, they are prefixed and suffixed with dunders. You've probably already seen some, such as the `__init__` dunder method, also known as the "constructor" of a class, which is implicitly invoked when you instantiate an instance of a class.
+
+When you create a new class, there will be default dunder methods inherited from the `object` class. However, we can override them by redefining these methods within the new class. For example, the default `__init__` method from `object` doesn't take any arguments, so we almost always override that to fit our needs.
+
+Other common dunder methods to override are `__str__` and `__repr__`. `__repr__` is the developer-friendly string representation of an object - usually the syntax to recreate it - and is implicitly called on arguments passed into the `repr` function. `__str__` is the user-friendly string representation of an object, and is called by the `str` function. Note here that, if not overriden, the default `__str__` invokes `__repr__` as a fallback.
+
+```py
+class Foo:
+ def __init__(self, value): # constructor
+ self.value = value
+ def __str__(self):
+ return f"This is a Foo object, with a value of {self.value}!" # string representation
+ def __repr__(self):
+ return f"Foo({self.value!r})" # way to recreate this object
+
+
+bar = Foo(5)
+
+# print also implicitly calls __str__
+print(bar) # Output: This is a Foo object, with a value of 5!
+
+# dev-friendly representation
+print(repr(bar)) # Output: Foo(5)
+```
+
+Another example: did you know that when you use the `<left operand> + <right operand>` syntax, you're implicitly calling `<left operand>.__add__(<right operand>)`? The same applies to other operators, and you can look at the [`operator` built-in module documentation](https://docs.python.org/3/library/operator.html) for more information!
diff --git a/bot/resources/tags/for-else.md b/bot/resources/tags/for-else.md
new file mode 100644
index 000000000..e102e4e75
--- /dev/null
+++ b/bot/resources/tags/for-else.md
@@ -0,0 +1,17 @@
+**for-else**
+
+In Python it's possible to attach an `else` clause to a for loop. The code under the `else` block will be run when the iterable is exhausted (there are no more items to iterate over). Code within the else block will **not** run if the loop is broken out using `break`.
+
+Here's an example of its usage:
+```py
+numbers = [1, 3, 5, 7, 9, 11]
+
+for number in numbers:
+ if number % 2 == 0:
+ print(f"Found an even number: {number}")
+ break
+ print(f"{number} is odd.")
+else:
+ print("All numbers are odd. How odd.")
+```
+Try running this example but with an even number in the list, see how the output changes as you do so.
diff --git a/bot/resources/tags/modmail.md b/bot/resources/tags/modmail.md
index 412468174..8ac19c8a7 100644
--- a/bot/resources/tags/modmail.md
+++ b/bot/resources/tags/modmail.md
@@ -6,4 +6,4 @@ It supports attachments, codeblocks, and reactions. As communication happens ove
**To use it, simply send a direct message to the bot.**
-Should there be an urgent and immediate need for a moderator or admin to look at a channel, feel free to ping the <@&831776746206265384> or <@&267628507062992896> role instead.
+Should there be an urgent and immediate need for a moderator to look at a channel, feel free to ping the <@&831776746206265384> role instead.
diff --git a/bot/resources/tags/venv.md b/bot/resources/tags/venv.md
new file mode 100644
index 000000000..a4fc62151
--- /dev/null
+++ b/bot/resources/tags/venv.md
@@ -0,0 +1,20 @@
+**Virtual Environments**
+
+Virtual environments are isolated Python environments, which make it easier to keep your system clean and manage dependencies. By default, when activated, only libraries and scripts installed in the virtual environment are accessible, preventing cross-project dependency conflicts, and allowing easy isolation of requirements.
+
+To create a new virtual environment, you can use the standard library `venv` module: `python3 -m venv .venv` (replace `python3` with `python` or `py` on Windows)
+
+Then, to activate the new virtual environment:
+
+**Windows** (PowerShell): `.venv\Scripts\Activate.ps1`
+or (Command Prompt): `.venv\Scripts\activate.bat`
+**MacOS / Linux** (Bash): `source .venv/bin/activate`
+
+Packages can then be installed to the virtual environment using `pip`, as normal.
+
+For more information, take a read of the [documentation](https://docs.python.org/3/library/venv.html). If you run code through your editor, check its documentation on how to make it use your virtual environment. For example, see the [VSCode](https://code.visualstudio.com/docs/python/environments#_select-and-activate-an-environment) or [PyCharm](https://www.jetbrains.com/help/pycharm/creating-virtual-environment.html) docs.
+
+Tools such as [poetry](https://python-poetry.org/docs/basic-usage/) and [pipenv](https://pipenv.pypa.io/en/latest/) can manage the creation of virtual environments as well as project dependencies, making packaging and installing your project easier.
+
+**Note:** When using Windows PowerShell, you may need to change the [execution policy](https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_execution_policies) first. This is only required once:
+`Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser`
diff --git a/bot/rules/mentions.py b/bot/rules/mentions.py
index 79725a4b1..6f5addad1 100644
--- a/bot/rules/mentions.py
+++ b/bot/rules/mentions.py
@@ -13,7 +13,11 @@ async def apply(
if msg.author == last_message.author
)
- total_recent_mentions = sum(len(msg.mentions) for msg in relevant_messages)
+ total_recent_mentions = sum(
+ not user.bot
+ for msg in relevant_messages
+ for user in msg.mentions
+ )
if total_recent_mentions > config['max']:
return (
diff --git a/bot/utils/cache.py b/bot/utils/caching.py
index 68ce15607..68ce15607 100644
--- a/bot/utils/cache.py
+++ b/bot/utils/caching.py
diff --git a/bot/utils/message_cache.py b/bot/utils/message_cache.py
new file mode 100644
index 000000000..f68d280c9
--- /dev/null
+++ b/bot/utils/message_cache.py
@@ -0,0 +1,197 @@
+import typing as t
+from math import ceil
+
+from discord import Message
+
+
+class MessageCache:
+ """
+ A data structure for caching messages.
+
+ The cache is implemented as a circular buffer to allow constant time append, prepend, pop from either side,
+ and lookup by index. The cache therefore does not support removal at an arbitrary index (although it can be
+ implemented to work in linear time relative to the maximum size).
+
+ The object additionally holds a mapping from Discord message ID's to the index in which the corresponding message
+ is stored, to allow for constant time lookup by message ID.
+
+ The cache has a size limit operating the same as with a collections.deque, and most of its method names mirror those
+ of a deque.
+
+ The implementation is transparent to the user: to the user the first element is always at index 0, and there are
+ only as many elements as were inserted (meaning, without any pre-allocated placeholder values).
+ """
+
+ def __init__(self, maxlen: int, *, newest_first: bool = False):
+ if maxlen <= 0:
+ raise ValueError("maxlen must be positive")
+ self.maxlen = maxlen
+ self.newest_first = newest_first
+
+ self._start = 0
+ self._end = 0
+
+ self._messages: list[t.Optional[Message]] = [None] * self.maxlen
+ self._message_id_mapping = {}
+
+ def append(self, message: Message) -> None:
+ """Add the received message to the cache, depending on the order of messages defined by `newest_first`."""
+ if self.newest_first:
+ self._appendleft(message)
+ else:
+ self._appendright(message)
+
+ def _appendright(self, message: Message) -> None:
+ """Add the received message to the end of the cache."""
+ if self._is_full():
+ del self._message_id_mapping[self._messages[self._start].id]
+ self._start = (self._start + 1) % self.maxlen
+
+ self._messages[self._end] = message
+ self._message_id_mapping[message.id] = self._end
+ self._end = (self._end + 1) % self.maxlen
+
+ def _appendleft(self, message: Message) -> None:
+ """Add the received message to the beginning of the cache."""
+ if self._is_full():
+ self._end = (self._end - 1) % self.maxlen
+ del self._message_id_mapping[self._messages[self._end].id]
+
+ self._start = (self._start - 1) % self.maxlen
+ self._messages[self._start] = message
+ self._message_id_mapping[message.id] = self._start
+
+ def pop(self) -> Message:
+ """Remove the last message in the cache and return it."""
+ if self._is_empty():
+ raise IndexError("pop from an empty cache")
+
+ self._end = (self._end - 1) % self.maxlen
+ message = self._messages[self._end]
+ del self._message_id_mapping[message.id]
+ self._messages[self._end] = None
+
+ return message
+
+ def popleft(self) -> Message:
+ """Return the first message in the cache and return it."""
+ if self._is_empty():
+ raise IndexError("pop from an empty cache")
+
+ message = self._messages[self._start]
+ del self._message_id_mapping[message.id]
+ self._messages[self._start] = None
+ self._start = (self._start + 1) % self.maxlen
+
+ return message
+
+ def clear(self) -> None:
+ """Remove all messages from the cache."""
+ self._messages = [None] * self.maxlen
+ self._message_id_mapping = {}
+
+ self._start = 0
+ self._end = 0
+
+ def get_message(self, message_id: int) -> t.Optional[Message]:
+ """Return the message that has the given message ID, if it is cached."""
+ index = self._message_id_mapping.get(message_id, None)
+ return self._messages[index] if index is not None else None
+
+ def update(self, message: Message) -> bool:
+ """
+ Update a cached message with new contents.
+
+ Return True if the given message had a matching ID in the cache.
+ """
+ index = self._message_id_mapping.get(message.id, None)
+ if index is None:
+ return False
+ self._messages[index] = message
+ return True
+
+ def __contains__(self, message_id: int) -> bool:
+ """Return True if the cache contains a message with the given ID ."""
+ return message_id in self._message_id_mapping
+
+ def __getitem__(self, item: t.Union[int, slice]) -> t.Union[Message, list[Message]]:
+ """
+ Return the message(s) in the index or slice provided.
+
+ This method makes the circular buffer implementation transparent to the user.
+ Providing 0 will return the message at the position perceived by the user to be the beginning of the cache,
+ meaning at `self._start`.
+ """
+ # Keep in mind that for the modulo operator used throughout this function, Python modulo behaves similarly when
+ # the left operand is negative. E.g -1 % 5 == 4, because the closest number from the bottom that wholly divides
+ # by 5 is -5.
+ if isinstance(item, int):
+ if item >= len(self) or item < -len(self):
+ raise IndexError("cache index out of range")
+ return self._messages[(item + self._start) % self.maxlen]
+
+ elif isinstance(item, slice):
+ length = len(self)
+ start, stop, step = item.indices(length)
+
+ # This needs to be checked explicitly now, because otherwise self._start >= self._end is a valid state.
+ if (start >= stop and step >= 0) or (start <= stop and step <= 0):
+ return []
+
+ start = (start + self._start) % self.maxlen
+ stop = (stop + self._start) % self.maxlen
+
+ # Having empty cells is an implementation detail. To the user the cache contains as many elements as they
+ # inserted, therefore any empty cells should be ignored. There can only be Nones at the tail.
+ if step > 0:
+ if (
+ (self._start < self._end and not self._start < stop <= self._end)
+ or (self._start > self._end and self._end < stop <= self._start)
+ ):
+ stop = self._end
+ else:
+ lower_boundary = (self._start - 1) % self.maxlen
+ if (
+ (self._start < self._end and not self._start - 1 <= stop < self._end)
+ or (self._start > self._end and self._end < stop < lower_boundary)
+ ):
+ stop = lower_boundary
+
+ if (start < stop and step > 0) or (start > stop and step < 0):
+ return self._messages[start:stop:step]
+ # step != 1 may require a start offset in the second slicing.
+ if step > 0:
+ offset = ceil((self.maxlen - start) / step) * step + start - self.maxlen
+ return self._messages[start::step] + self._messages[offset:stop:step]
+ else:
+ offset = ceil((start + 1) / -step) * -step - start - 1
+ return self._messages[start::step] + self._messages[self.maxlen - 1 - offset:stop:step]
+
+ else:
+ raise TypeError(f"cache indices must be integers or slices, not {type(item)}")
+
+ def __iter__(self) -> t.Iterator[Message]:
+ if self._is_empty():
+ return
+
+ if self._start < self._end:
+ yield from self._messages[self._start:self._end]
+ else:
+ yield from self._messages[self._start:]
+ yield from self._messages[:self._end]
+
+ def __len__(self):
+ """Get the number of non-empty cells in the cache."""
+ if self._is_empty():
+ return 0
+ if self._end > self._start:
+ return self._end - self._start
+ return self.maxlen - self._start + self._end
+
+ def _is_empty(self) -> bool:
+ """Return True if the cache has no messages."""
+ return self._messages[self._start] is None
+
+ def _is_full(self) -> bool:
+ """Return True if every cell in the cache already contains a message."""
+ return self._messages[self._end] is not None
diff --git a/bot/utils/messages.py b/bot/utils/messages.py
index b6f6c1f66..abeb04021 100644
--- a/bot/utils/messages.py
+++ b/bot/utils/messages.py
@@ -1,5 +1,4 @@
import asyncio
-import contextlib
import logging
import random
import re
@@ -8,8 +7,6 @@ from io import BytesIO
from typing import Callable, List, Optional, Sequence, Union
import discord
-from discord import Message, MessageType, Reaction, User
-from discord.errors import HTTPException
from discord.ext.commands import Context
import bot
@@ -54,7 +51,7 @@ def reaction_check(
log.trace(f"Removing reaction {reaction} by {user} on {reaction.message.id}: disallowed user.")
scheduling.create_task(
reaction.message.remove_reaction(reaction.emoji, user),
- HTTPException, # Suppress the HTTPException if adding the reaction fails
+ suppressed_exceptions=(discord.HTTPException,),
name=f"remove_reaction-{reaction}-{reaction.message.id}-{user}"
)
return False
@@ -69,7 +66,9 @@ async def wait_for_deletion(
allow_mods: bool = True
) -> None:
"""
- Wait for up to `timeout` seconds for a reaction by any of the specified `user_ids` to delete the message.
+ Wait for any of `user_ids` to react with one of the `deletion_emojis` within `timeout` seconds to delete `message`.
+
+ If `timeout` expires then reactions are cleared to indicate the option to delete has expired.
An `attach_emojis` bool may be specified to determine whether to attach the given
`deletion_emojis` to the message in the given `context`.
@@ -95,9 +94,15 @@ async def wait_for_deletion(
allow_mods=allow_mods,
)
- with contextlib.suppress(asyncio.TimeoutError):
- await bot.instance.wait_for('reaction_add', check=check, timeout=timeout)
- await message.delete()
+ try:
+ try:
+ await bot.instance.wait_for('reaction_add', check=check, timeout=timeout)
+ except asyncio.TimeoutError:
+ await message.clear_reactions()
+ else:
+ await message.delete()
+ except discord.NotFound:
+ log.trace(f"wait_for_deletion: message {message.id} deleted prematurely.")
async def send_attachments(
@@ -146,7 +151,7 @@ async def send_attachments(
large.append(attachment)
else:
log.info(f"{failure_msg} because it's too large.")
- except HTTPException as e:
+ except discord.HTTPException as e:
if link_large and e.status == 413:
large.append(attachment)
else:
@@ -167,8 +172,8 @@ async def send_attachments(
async def count_unique_users_reaction(
message: discord.Message,
- reaction_predicate: Callable[[Reaction], bool] = lambda _: True,
- user_predicate: Callable[[User], bool] = lambda _: True,
+ reaction_predicate: Callable[[discord.Reaction], bool] = lambda _: True,
+ user_predicate: Callable[[discord.User], bool] = lambda _: True,
count_bots: bool = True
) -> int:
"""
@@ -188,7 +193,7 @@ async def count_unique_users_reaction(
return len(unique_users)
-async def pin_no_system_message(message: Message) -> bool:
+async def pin_no_system_message(message: discord.Message) -> bool:
"""Pin the given message, wait a couple of seconds and try to delete the system message."""
await message.pin()
@@ -196,7 +201,7 @@ async def pin_no_system_message(message: Message) -> bool:
await asyncio.sleep(2)
# Search for the system message in the last 10 messages
async for historical_message in message.channel.history(limit=10):
- if historical_message.type == MessageType.pins_add:
+ if historical_message.type == discord.MessageType.pins_add:
await historical_message.delete()
return True
diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py
index 2dc485f24..bb83b5c0d 100644
--- a/bot/utils/scheduling.py
+++ b/bot/utils/scheduling.py
@@ -161,9 +161,22 @@ class Scheduler:
self._log.error(f"Error in task #{task_id} {id(done_task)}!", exc_info=exception)
-def create_task(coro: t.Awaitable, *suppressed_exceptions: t.Type[Exception], **kwargs) -> asyncio.Task:
- """Wrapper for `asyncio.create_task` which logs exceptions raised in the task."""
- task = asyncio.create_task(coro, **kwargs)
+def create_task(
+ coro: t.Awaitable,
+ *,
+ suppressed_exceptions: tuple[t.Type[Exception]] = (),
+ event_loop: t.Optional[asyncio.AbstractEventLoop] = None,
+ **kwargs,
+) -> asyncio.Task:
+ """
+ Wrapper for creating asyncio `Task`s which logs exceptions raised in the task.
+
+ If the loop kwarg is provided, the task is created from that event loop, otherwise the running loop is used.
+ """
+ if event_loop is not None:
+ task = event_loop.create_task(coro, **kwargs)
+ else:
+ task = asyncio.create_task(coro, **kwargs)
task.add_done_callback(partial(_log_task_exception, suppressed_exceptions=suppressed_exceptions))
return task
diff --git a/bot/utils/time.py b/bot/utils/time.py
index d55a0e532..8cf7d623b 100644
--- a/bot/utils/time.py
+++ b/bot/utils/time.py
@@ -1,12 +1,13 @@
import datetime
import re
-from typing import Optional
+from enum import Enum
+from typing import Optional, Union
import dateutil.parser
from dateutil.relativedelta import relativedelta
RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT"
-INFRACTION_FORMAT = "%Y-%m-%d %H:%M"
+DISCORD_TIMESTAMP_REGEX = re.compile(r"<t:(\d+):f>")
_DURATION_REGEX = re.compile(
r"((?P<years>\d+?) ?(years|year|Y|y) ?)?"
@@ -19,6 +20,25 @@ _DURATION_REGEX = re.compile(
)
+ValidTimestamp = Union[int, datetime.datetime, datetime.date, datetime.timedelta, relativedelta]
+
+
+class TimestampFormats(Enum):
+ """
+ Represents the different formats possible for Discord timestamps.
+
+ Examples are given in epoch time.
+ """
+
+ DATE_TIME = "f" # January 1, 1970 1:00 AM
+ DAY_TIME = "F" # Thursday, January 1, 1970 1:00 AM
+ DATE_SHORT = "d" # 01/01/1970
+ DATE = "D" # January 1, 1970
+ TIME = "t" # 1:00 AM
+ TIME_SECONDS = "T" # 1:00:00 AM
+ RELATIVE = "R" # 52 years ago
+
+
def _stringify_time_unit(value: int, unit: str) -> str:
"""
Returns a string to represent a value and time unit, ensuring that it uses the right plural form of the unit.
@@ -40,6 +60,24 @@ def _stringify_time_unit(value: int, unit: str) -> str:
return f"{value} {unit}"
+def discord_timestamp(timestamp: ValidTimestamp, format: TimestampFormats = TimestampFormats.DATE_TIME) -> str:
+ """Create and format a Discord flavored markdown timestamp."""
+ if format not in TimestampFormats:
+ raise ValueError(f"Format can only be one of {', '.join(TimestampFormats.args)}, not {format}.")
+
+ # Convert each possible timestamp class to an integer.
+ if isinstance(timestamp, datetime.datetime):
+ timestamp = (timestamp.replace(tzinfo=None) - datetime.datetime.utcfromtimestamp(0)).total_seconds()
+ elif isinstance(timestamp, datetime.date):
+ timestamp = (timestamp - datetime.date.fromtimestamp(0)).total_seconds()
+ elif isinstance(timestamp, datetime.timedelta):
+ timestamp = timestamp.total_seconds()
+ elif isinstance(timestamp, relativedelta):
+ timestamp = timestamp.seconds
+
+ return f"<t:{int(timestamp)}:{format.value}>"
+
+
def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6) -> str:
"""
Returns a human-readable version of the relativedelta.
@@ -87,7 +125,7 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units:
def get_time_delta(time_string: str) -> str:
"""Returns the time in human-readable time delta format."""
date_time = dateutil.parser.isoparse(time_string).replace(tzinfo=None)
- time_delta = time_since(date_time, precision="minutes", max_units=1)
+ time_delta = time_since(date_time)
return time_delta
@@ -123,19 +161,9 @@ def relativedelta_to_timedelta(delta: relativedelta) -> datetime.timedelta:
return utcnow + delta - utcnow
-def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max_units: int = 6) -> str:
- """
- Takes a datetime and returns a human-readable string that describes how long ago that datetime was.
-
- precision specifies the smallest unit of time to include (e.g. "seconds", "minutes").
- max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours).
- """
- now = datetime.datetime.utcnow()
- delta = abs(relativedelta(now, past_datetime))
-
- humanized = humanize_delta(delta, precision, max_units)
-
- return f"{humanized} ago"
+def time_since(past_datetime: datetime.datetime) -> str:
+ """Takes a datetime and returns a discord timestamp that describes how long ago that datetime was."""
+ return discord_timestamp(past_datetime, TimestampFormats.RELATIVE)
def parse_rfc1123(stamp: str) -> datetime.datetime:
@@ -144,8 +172,8 @@ def parse_rfc1123(stamp: str) -> datetime.datetime:
def format_infraction(timestamp: str) -> str:
- """Format an infraction timestamp to a more readable ISO 8601 format."""
- return dateutil.parser.isoparse(timestamp).strftime(INFRACTION_FORMAT)
+ """Format an infraction timestamp to a discord timestamp."""
+ return discord_timestamp(dateutil.parser.isoparse(timestamp))
def format_infraction_with_duration(
@@ -155,11 +183,7 @@ def format_infraction_with_duration(
absolute: bool = True
) -> Optional[str]:
"""
- Return `date_to` formatted as a readable ISO-8601 with the humanized duration since `date_from`.
-
- `date_from` must be an ISO-8601 formatted timestamp. The duration is calculated as from
- `date_from` until `date_to` with a precision of seconds. If `date_from` is unspecified, the
- current time is used.
+ Return `date_to` formatted as a discord timestamp with the timestamp duration since `date_from`.
`max_units` specifies the maximum number of units of time to include in the duration. For
example, a value of 1 may include days but not hours.
@@ -186,25 +210,22 @@ def format_infraction_with_duration(
def until_expiration(
- expiry: Optional[str],
- now: Optional[datetime.datetime] = None,
- max_units: int = 2
+ expiry: Optional[str]
) -> Optional[str]:
"""
- Get the remaining time until infraction's expiration, in a human-readable version of the relativedelta.
+ Get the remaining time until infraction's expiration, in a discord timestamp.
Returns a human-readable version of the remaining duration between datetime.utcnow() and an expiry.
- Unlike `humanize_delta`, this function will force the `precision` to be `seconds` by not passing it.
- `max_units` specifies the maximum number of units of time to include (e.g. 1 may include days but not hours).
- By default, max_units is 2.
+ Similar to time_since, except that this function doesn't error on a null input
+ and return null if the expiry is in the paste
"""
if not expiry:
return None
- now = now or datetime.datetime.utcnow()
+ now = datetime.datetime.utcnow()
since = dateutil.parser.isoparse(expiry).replace(tzinfo=None, microsecond=0)
if since < now:
return None
- return humanize_delta(relativedelta(since, now), max_units=max_units)
+ return discord_timestamp(since, TimestampFormats.RELATIVE)
diff --git a/config-default.yml b/config-default.yml
index 3afe9ba3c..a18fdafa5 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -1,3 +1,6 @@
+debug: !ENV ["BOT_DEBUG", "true"]
+
+
bot:
prefix: "!"
sentry_dsn: !ENV "BOT_SENTRY_DSN"
@@ -142,6 +145,7 @@ guild:
moderators: &MODS_CATEGORY 749736277464842262
modmail: &MODMAIL 714494672835444826
voice: 356013253765234688
+ summer_code_jam: 861692638540857384
channels:
# Public announcement and news channels
@@ -176,6 +180,9 @@ guild:
user_log: 528976905546760203
voice_log: 640292421988646961
+ # Open Source Projects
+ black_formatter: &BLACK_FORMATTER 846434317021741086
+
# Off-topic
off_topic_0: 291284109232308226
off_topic_1: 463035241142026251
@@ -185,6 +192,7 @@ guild:
bot_commands: &BOT_CMD 267659945086812160
esoteric: 470884583684964352
voice_gate: 764802555427029012
+ code_jam_planning: 490217981872177157
# Staff
admins: &ADMINS 365960823622991872
@@ -195,6 +203,7 @@ guild:
incidents: 714214212200562749
incidents_archive: 720668923636351037
mod_alerts: 473092532147060736
+ mods: &MODS 305126844661760000
nominations: 822920136150745168
nomination_voting: 822853512709931008
organisation: &ORGANISATION 551789653284356126
@@ -208,16 +217,18 @@ guild:
# Voice Channels
admins_voice: &ADMINS_VOICE 500734494840717332
- code_help_voice_1: 751592231726481530
- code_help_voice_2: 764232549840846858
- general_voice: 751591688538947646
+ code_help_voice_0: 751592231726481530
+ code_help_voice_1: 764232549840846858
+ general_voice_0: 751591688538947646
+ general_voice_1: 799641437645701151
staff_voice: &STAFF_VOICE 412375055910043655
# Voice Chat
- code_help_chat_1: 755154969761677312
- code_help_chat_2: 766330079135268884
+ code_help_chat_0: 755154969761677312
+ code_help_chat_1: 766330079135268884
staff_voice_chat: 541638762007101470
- voice_chat: 412357430186344448
+ voice_chat_0: 412357430186344448
+ voice_chat_1: 799647045886541885
# Watch
big_brother_logs: &BB_LOGS 468507907357409333
@@ -230,6 +241,7 @@ guild:
moderation_channels:
- *ADMINS
- *ADMIN_SPAM
+ - *MODS
# Modlog cog ignores events which occur in these channels
modlog_blacklist:
@@ -243,13 +255,14 @@ guild:
reminder_whitelist:
- *BOT_CMD
- *DEV_CONTRIB
+ - *BLACK_FORMATTER
roles:
announcements: 463658397560995840
contributors: 295488872404484098
help_cooldown: 699189276025421825
muted: &MUTED_ROLE 277914926603829249
- partners: 323426753857191936
+ partners: &PY_PARTNER_ROLE 323426753857191936
python_community: &PY_COMMUNITY_ROLE 458226413825294336
sprinters: &SPRINTERS 758422482289426471
voice_verified: 764802720779337729
@@ -257,8 +270,10 @@ guild:
# Staff
admins: &ADMINS_ROLE 267628507062992896
core_developers: 587606783669829632
+ code_jam_event_team: 787816728474288181
devops: 409416496733880320
domain_leads: 807415650778742785
+ events_lead: 778361735739998228
helpers: &HELPERS_ROLE 267630620367257601
moderators: &MODS_ROLE 831776746206265384
mod_team: &MOD_TEAM_ROLE 267629731250176001
@@ -267,7 +282,6 @@ guild:
# Code Jam
jammers: 737249140966162473
- team_leaders: 737250302834638889
# Streaming
video: 764245844798079016
@@ -328,6 +342,7 @@ filter:
- *OWNERS_ROLE
- *PY_COMMUNITY_ROLE
- *SPRINTERS
+ - *PY_PARTNER_ROLE
keys:
@@ -362,6 +377,8 @@ urls:
anti_spam:
+ cache_size: 100
+
# Clean messages that violate a rule.
clean_offending: true
ping_everyone: true
@@ -389,7 +406,7 @@ anti_spam:
chars:
interval: 5
- max: 3_000
+ max: 4_200
discord_emojis:
interval: 10
@@ -417,14 +434,13 @@ anti_spam:
max: 3
-
metabase:
- username: !ENV "METABASE_USERNAME"
- password: !ENV "METABASE_PASSWORD"
- url: "http://metabase.default.svc.cluster.local/api"
+ username: !ENV "METABASE_USERNAME"
+ password: !ENV "METABASE_PASSWORD"
+ base_url: "http://metabase.default.svc.cluster.local"
+ public_url: "https://metabase.pythondiscord.com"
# 14 days, see https://www.metabase.com/docs/latest/operations-guide/environment-variables.html#max_session_age
- max_session_age: 20160
-
+ max_session_age: 20160
big_brother:
diff --git a/docker-compose.yml b/docker-compose.yml
index bdfedf5c2..0f0355dac 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -12,13 +12,13 @@ x-logging: &logging
max-size: "10m"
x-restart-policy: &restart_policy
- restart: always
+ restart: unless-stopped
services:
postgres:
<< : *logging
<< : *restart_policy
- image: postgres:12-alpine
+ image: postgres:13-alpine
environment:
POSTGRES_DB: pysite
POSTGRES_PASSWORD: pysite
diff --git a/poetry.lock b/poetry.lock
index ba8b7af4b..a4ce5d1a9 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -44,18 +44,6 @@ yarl = ">=1.0,<2.0"
speedups = ["aiodns", "brotlipy", "cchardet"]
[[package]]
-name = "aioping"
-version = "0.3.1"
-description = "Asyncio ping implementation"
-category = "main"
-optional = false
-python-versions = "*"
-
-[package.dependencies]
-aiodns = "*"
-async-timeout = "*"
-
-[[package]]
name = "aioredis"
version = "1.3.1"
description = "asyncio (PEP 3156) Redis support"
@@ -83,14 +71,6 @@ yarl = "*"
develop = ["aiomisc (>=11.0,<12.0)", "async-generator", "coverage (!=4.3)", "coveralls", "pylava", "pytest", "pytest-cov", "tox (>=2.4)"]
[[package]]
-name = "appdirs"
-version = "1.4.4"
-description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
-category = "dev"
-optional = false
-python-versions = "*"
-
-[[package]]
name = "arrow"
version = "1.0.3"
description = "Better dates & times for Python"
@@ -125,6 +105,14 @@ optional = false
python-versions = ">=3.5.3"
[[package]]
+name = "atomicwrites"
+version = "1.4.0"
+description = "Atomic file writes."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
name = "attrs"
version = "21.2.0"
description = "Classes Without Boilerplate"
@@ -139,6 +127,18 @@ tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)"
tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"]
[[package]]
+name = "backports.entry-points-selectable"
+version = "1.1.0"
+description = "Compatibility shim providing selectable entry points for older implementations"
+category = "dev"
+optional = false
+python-versions = ">=2.7"
+
+[package.extras]
+docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
+testing = ["pytest (>=4.6)", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"]
+
+[[package]]
name = "beautifulsoup4"
version = "4.9.3"
description = "Screen-scraping library"
@@ -155,7 +155,7 @@ lxml = ["lxml"]
[[package]]
name = "certifi"
-version = "2020.12.5"
+version = "2021.5.30"
description = "Python package for providing Mozilla's CA Bundle."
category = "main"
optional = false
@@ -163,7 +163,7 @@ python-versions = "*"
[[package]]
name = "cffi"
-version = "1.14.5"
+version = "1.14.6"
description = "Foreign Function Interface for Python calling C code."
category = "main"
optional = false
@@ -174,7 +174,7 @@ pycparser = "*"
[[package]]
name = "cfgv"
-version = "3.2.0"
+version = "3.3.0"
description = "Validate configuration and produce human readable error messages."
category = "dev"
optional = false
@@ -189,6 +189,17 @@ optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
+name = "charset-normalizer"
+version = "2.0.4"
+description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
+category = "dev"
+optional = false
+python-versions = ">=3.5.0"
+
+[package.extras]
+unicode_backport = ["unicodedata2"]
+
+[[package]]
name = "colorama"
version = "0.4.4"
description = "Cross-platform colored terminal text."
@@ -253,7 +264,7 @@ murmur = ["mmh3"]
[[package]]
name = "discord.py"
-version = "1.6.0"
+version = "1.7.3"
description = "A Python wrapper for the Discord API"
category = "main"
optional = false
@@ -268,7 +279,7 @@ voice = ["PyNaCl (>=1.3.0,<1.5)"]
[[package]]
name = "distlib"
-version = "0.3.1"
+version = "0.3.2"
description = "Distribution utilities"
category = "dev"
optional = false
@@ -294,8 +305,19 @@ python-versions = "*"
dev = ["pytest", "coverage", "coveralls"]
[[package]]
+name = "execnet"
+version = "1.9.0"
+description = "execnet: rapid multi-Python deployment"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.extras]
+testing = ["pre-commit"]
+
+[[package]]
name = "fakeredis"
-version = "1.5.0"
+version = "1.5.2"
description = "Fake implementation of redis API for testing purposes."
category = "main"
optional = false
@@ -307,12 +329,12 @@ six = ">=1.12"
sortedcontainers = "*"
[package.extras]
-aioredis = ["aioredis"]
+aioredis = ["aioredis (<2)"]
lua = ["lupa"]
[[package]]
name = "feedparser"
-version = "6.0.2"
+version = "6.0.8"
description = "Universal feed parser, handles RSS 0.9x, RSS 1.0, RSS 2.0, CDF, Atom 0.3, and Atom 1.0 feeds"
category = "main"
optional = false
@@ -436,17 +458,6 @@ python-versions = "*"
pycodestyle = ">=2.0.0,<3.0.0"
[[package]]
-name = "fuzzywuzzy"
-version = "0.18.0"
-description = "Fuzzy string matching in python"
-category = "main"
-optional = false
-python-versions = "*"
-
-[package.extras]
-speedup = ["python-levenshtein (>=0.12)"]
-
-[[package]]
name = "hiredis"
version = "2.0.0"
description = "Python wrapper for hiredis"
@@ -456,7 +467,7 @@ python-versions = ">=3.6"
[[package]]
name = "humanfriendly"
-version = "9.1"
+version = "9.2"
description = "Human friendly output for text interfaces using Python"
category = "main"
optional = false
@@ -467,7 +478,7 @@ pyreadline = {version = "*", markers = "sys_platform == \"win32\""}
[[package]]
name = "identify"
-version = "2.2.4"
+version = "2.2.11"
description = "File identification library for Python"
category = "dev"
optional = false
@@ -478,11 +489,19 @@ license = ["editdistance-s"]
[[package]]
name = "idna"
-version = "3.1"
+version = "3.2"
description = "Internationalized Domain Names in Applications (IDNA)"
category = "main"
optional = false
-python-versions = ">=3.4"
+python-versions = ">=3.5"
+
+[[package]]
+name = "iniconfig"
+version = "1.1.1"
+description = "iniconfig: brain-dead simple config-ini parsing"
+category = "dev"
+optional = false
+python-versions = "*"
[[package]]
name = "lxml"
@@ -520,7 +539,7 @@ python-versions = "*"
[[package]]
name = "more-itertools"
-version = "8.7.0"
+version = "8.8.0"
description = "More routines for operating on iterables, beyond itertools"
category = "main"
optional = false
@@ -559,6 +578,17 @@ optional = false
python-versions = ">=3.5"
[[package]]
+name = "packaging"
+version = "21.0"
+description = "Core utilities for Python packages"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+pyparsing = ">=2.0.2"
+
+[[package]]
name = "pamqp"
version = "2.3.0"
description = "RabbitMQ Focused AMQP low-level library"
@@ -571,18 +601,42 @@ codegen = ["lxml"]
[[package]]
name = "pep8-naming"
-version = "0.11.1"
+version = "0.12.0"
description = "Check PEP-8 naming conventions, plugin for flake8"
category = "dev"
optional = false
python-versions = "*"
[package.dependencies]
+flake8 = ">=3.9.1"
flake8-polyfill = ">=1.0.2,<2"
[[package]]
+name = "platformdirs"
+version = "2.2.0"
+description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"]
+test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"]
+
+[[package]]
+name = "pluggy"
+version = "0.13.1"
+description = "plugin and hook calling mechanisms for python"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+
+[[package]]
name = "pre-commit"
-version = "2.12.1"
+version = "2.13.0"
description = "A framework for managing and maintaining multi-language pre-commit hooks."
category = "dev"
optional = false
@@ -608,8 +662,16 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
test = ["ipaddress", "mock", "unittest2", "enum34", "pywin32", "wmi"]
[[package]]
+name = "py"
+version = "1.10.0"
+description = "library with cross-python path, ini-parsing, io, code, log facilities"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
name = "pycares"
-version = "3.2.3"
+version = "4.0.0"
description = "Python interface for c-ares"
category = "main"
optional = false
@@ -639,7 +701,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "pydocstyle"
-version = "6.0.0"
+version = "6.1.1"
description = "Python docstring style checker"
category = "dev"
optional = false
@@ -648,6 +710,9 @@ python-versions = ">=3.6"
[package.dependencies]
snowballstemmer = "*"
+[package.extras]
+toml = ["toml"]
+
[[package]]
name = "pyflakes"
version = "2.3.1"
@@ -657,6 +722,14 @@ optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
+name = "pyparsing"
+version = "2.4.7"
+description = "Python parsing module"
+category = "dev"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+
+[[package]]
name = "pyreadline"
version = "2.1"
description = "A python implmementation of GNU readline."
@@ -665,8 +738,75 @@ optional = false
python-versions = "*"
[[package]]
+name = "pytest"
+version = "6.2.4"
+description = "pytest: simple powerful testing with Python"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
+attrs = ">=19.2.0"
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=0.12,<1.0.0a1"
+py = ">=1.8.2"
+toml = "*"
+
+[package.extras]
+testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
+
+[[package]]
+name = "pytest-cov"
+version = "2.12.1"
+description = "Pytest plugin for measuring coverage."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.dependencies]
+coverage = ">=5.2.1"
+pytest = ">=4.6"
+toml = "*"
+
+[package.extras]
+testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"]
+
+[[package]]
+name = "pytest-forked"
+version = "1.3.0"
+description = "run tests in isolated forked subprocesses"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.dependencies]
+py = "*"
+pytest = ">=3.10"
+
+[[package]]
+name = "pytest-xdist"
+version = "2.3.0"
+description = "pytest xdist plugin for distributed testing and loop-on-failing modes"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+execnet = ">=1.1"
+psutil = {version = ">=3.0", optional = true, markers = "extra == \"psutil\""}
+pytest = ">=6.0.0"
+pytest-forked = "*"
+
+[package.extras]
+psutil = ["psutil (>=3.0)"]
+testing = ["filelock"]
+
+[[package]]
name = "python-dateutil"
-version = "2.8.1"
+version = "2.8.2"
description = "Extensions to the standard Python datetime module"
category = "main"
optional = false
@@ -710,6 +850,14 @@ optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
[[package]]
+name = "rapidfuzz"
+version = "1.4.1"
+description = "rapid fuzzy string matching"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[[package]]
name = "redis"
version = "3.5.3"
description = "Python client for Redis key-value store"
@@ -730,19 +878,25 @@ python-versions = "*"
[[package]]
name = "requests"
-version = "2.15.1"
+version = "2.26.0"
description = "Python HTTP for Humans."
category = "dev"
optional = false
-python-versions = "*"
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
+
+[package.dependencies]
+certifi = ">=2017.4.17"
+charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""}
+idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""}
+urllib3 = ">=1.21.1,<1.27"
[package.extras]
-security = ["cryptography (>=1.3.4)", "idna (>=2.0.0)", "pyOpenSSL (>=0.14)"]
socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
+use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
[[package]]
name = "sentry-sdk"
-version = "0.20.3"
+version = "1.3.1"
description = "Python client for Sentry (https://sentry.io)"
category = "main"
optional = false
@@ -761,6 +915,7 @@ chalice = ["chalice (>=1.16.0)"]
django = ["django (>=1.8)"]
falcon = ["falcon (>=1.4)"]
flask = ["flask (>=0.11)", "blinker (>=1.1)"]
+httpx = ["httpx (>=0.16.0)"]
pure_eval = ["pure-eval", "executing", "asttokens"]
pyspark = ["pyspark (>=2.4.4)"]
rq = ["rq (>=0.6)"]
@@ -794,7 +949,7 @@ python-versions = "*"
[[package]]
name = "sortedcontainers"
-version = "2.3.0"
+version = "2.4.0"
description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set"
category = "main"
optional = false
@@ -847,34 +1002,35 @@ python-versions = "*"
[[package]]
name = "urllib3"
-version = "1.26.4"
+version = "1.26.6"
description = "HTTP library with thread-safe connection pooling, file post, and more."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
[package.extras]
+brotli = ["brotlipy (>=0.6.0)"]
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
-brotli = ["brotlipy (>=0.6.0)"]
[[package]]
name = "virtualenv"
-version = "20.4.6"
+version = "20.7.0"
description = "Virtual Python Environment builder"
category = "dev"
optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
[package.dependencies]
-appdirs = ">=1.4.3,<2"
+"backports.entry-points-selectable" = ">=1.0.4"
distlib = ">=0.3.1,<1"
filelock = ">=3.0.0,<4"
+platformdirs = ">=2,<3"
six = ">=1.9.0,<2"
[package.extras]
docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"]
-testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)", "xonsh (>=0.9.16)"]
+testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"]
[[package]]
name = "yarl"
@@ -891,7 +1047,7 @@ multidict = ">=4.0"
[metadata]
lock-version = "1.1"
python-versions = "3.9.*"
-content-hash = "ece3b915901a62911ff7ff4a616b3972e815c0e1c7097c8994163af13cadde0e"
+content-hash = "f46fe1d2d9e0621e4e06d4c2ba5f6190ec4574ac6ca809abe8bf542a3b55204e"
[metadata.files]
aio-pika = [
@@ -941,10 +1097,6 @@ aiohttp = [
{file = "aiohttp-3.7.4.post0-cp39-cp39-win_amd64.whl", hash = "sha256:02f46fc0e3c5ac58b80d4d56eb0a7c7d97fcef69ace9326289fb9f1955e65cfe"},
{file = "aiohttp-3.7.4.post0.tar.gz", hash = "sha256:493d3299ebe5f5a7c66b9819eacdcfbbaaf1a8e84911ddffcdc48888497afecf"},
]
-aioping = [
- {file = "aioping-0.3.1-py3-none-any.whl", hash = "sha256:8900ef2f5a589ba0c12aaa9c2d586f5371820d468d21b374ddb47ef5fc8f297c"},
- {file = "aioping-0.3.1.tar.gz", hash = "sha256:f983d86acab3a04c322731ce88d42c55d04d2842565fc8532fe10c838abfd275"},
-]
aioredis = [
{file = "aioredis-1.3.1-py3-none-any.whl", hash = "sha256:b61808d7e97b7cd5a92ed574937a079c9387fdadd22bfbfa7ad2fd319ecc26e3"},
{file = "aioredis-1.3.1.tar.gz", hash = "sha256:15f8af30b044c771aee6787e5ec24694c048184c7b9e54c3b60c750a4b93273a"},
@@ -953,10 +1105,6 @@ aiormq = [
{file = "aiormq-3.3.1-py3-none-any.whl", hash = "sha256:e584dac13a242589aaf42470fd3006cb0dc5aed6506cbd20357c7ec8bbe4a89e"},
{file = "aiormq-3.3.1.tar.gz", hash = "sha256:8218dd9f7198d6e7935855468326bbacf0089f926c70baa8dd92944cb2496573"},
]
-appdirs = [
- {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
- {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
-]
arrow = [
{file = "arrow-1.0.3-py3-none-any.whl", hash = "sha256:3515630f11a15c61dcb4cdd245883270dd334c83f3e639824e65a4b79cc48543"},
{file = "arrow-1.0.3.tar.gz", hash = "sha256:399c9c8ae732270e1aa58ead835a79a40d7be8aa109c579898eb41029b5a231d"},
@@ -969,66 +1117,86 @@ async-timeout = [
{file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"},
{file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"},
]
+atomicwrites = [
+ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
+ {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
+]
attrs = [
{file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"},
{file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"},
]
+"backports.entry-points-selectable" = [
+ {file = "backports.entry_points_selectable-1.1.0-py2.py3-none-any.whl", hash = "sha256:a6d9a871cde5e15b4c4a53e3d43ba890cc6861ec1332c9c2428c92f977192acc"},
+ {file = "backports.entry_points_selectable-1.1.0.tar.gz", hash = "sha256:988468260ec1c196dab6ae1149260e2f5472c9110334e5d51adcb77867361f6a"},
+]
beautifulsoup4 = [
{file = "beautifulsoup4-4.9.3-py2-none-any.whl", hash = "sha256:4c98143716ef1cb40bf7f39a8e3eec8f8b009509e74904ba3a7b315431577e35"},
{file = "beautifulsoup4-4.9.3-py3-none-any.whl", hash = "sha256:fff47e031e34ec82bf17e00da8f592fe7de69aeea38be00523c04623c04fb666"},
{file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"},
]
certifi = [
- {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"},
- {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"},
+ {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"},
+ {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"},
]
cffi = [
- {file = "cffi-1.14.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991"},
- {file = "cffi-1.14.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1"},
- {file = "cffi-1.14.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa"},
- {file = "cffi-1.14.5-cp27-cp27m-win32.whl", hash = "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3"},
- {file = "cffi-1.14.5-cp27-cp27m-win_amd64.whl", hash = "sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5"},
- {file = "cffi-1.14.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482"},
- {file = "cffi-1.14.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6"},
- {file = "cffi-1.14.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045"},
- {file = "cffi-1.14.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa"},
- {file = "cffi-1.14.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406"},
- {file = "cffi-1.14.5-cp35-cp35m-win32.whl", hash = "sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369"},
- {file = "cffi-1.14.5-cp35-cp35m-win_amd64.whl", hash = "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315"},
- {file = "cffi-1.14.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892"},
- {file = "cffi-1.14.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058"},
- {file = "cffi-1.14.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5"},
- {file = "cffi-1.14.5-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132"},
- {file = "cffi-1.14.5-cp36-cp36m-win32.whl", hash = "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53"},
- {file = "cffi-1.14.5-cp36-cp36m-win_amd64.whl", hash = "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813"},
- {file = "cffi-1.14.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73"},
- {file = "cffi-1.14.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06"},
- {file = "cffi-1.14.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1"},
- {file = "cffi-1.14.5-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49"},
- {file = "cffi-1.14.5-cp37-cp37m-win32.whl", hash = "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62"},
- {file = "cffi-1.14.5-cp37-cp37m-win_amd64.whl", hash = "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4"},
- {file = "cffi-1.14.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053"},
- {file = "cffi-1.14.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0"},
- {file = "cffi-1.14.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e"},
- {file = "cffi-1.14.5-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827"},
- {file = "cffi-1.14.5-cp38-cp38-win32.whl", hash = "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e"},
- {file = "cffi-1.14.5-cp38-cp38-win_amd64.whl", hash = "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396"},
- {file = "cffi-1.14.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea"},
- {file = "cffi-1.14.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322"},
- {file = "cffi-1.14.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c"},
- {file = "cffi-1.14.5-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee"},
- {file = "cffi-1.14.5-cp39-cp39-win32.whl", hash = "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396"},
- {file = "cffi-1.14.5-cp39-cp39-win_amd64.whl", hash = "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d"},
- {file = "cffi-1.14.5.tar.gz", hash = "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c"},
+ {file = "cffi-1.14.6-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:22b9c3c320171c108e903d61a3723b51e37aaa8c81255b5e7ce102775bd01e2c"},
+ {file = "cffi-1.14.6-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:f0c5d1acbfca6ebdd6b1e3eded8d261affb6ddcf2186205518f1428b8569bb99"},
+ {file = "cffi-1.14.6-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:99f27fefe34c37ba9875f224a8f36e31d744d8083e00f520f133cab79ad5e819"},
+ {file = "cffi-1.14.6-cp27-cp27m-win32.whl", hash = "sha256:55af55e32ae468e9946f741a5d51f9896da6b9bf0bbdd326843fec05c730eb20"},
+ {file = "cffi-1.14.6-cp27-cp27m-win_amd64.whl", hash = "sha256:7bcac9a2b4fdbed2c16fa5681356d7121ecabf041f18d97ed5b8e0dd38a80224"},
+ {file = "cffi-1.14.6-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:ed38b924ce794e505647f7c331b22a693bee1538fdf46b0222c4717b42f744e7"},
+ {file = "cffi-1.14.6-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:e22dcb48709fc51a7b58a927391b23ab37eb3737a98ac4338e2448bef8559b33"},
+ {file = "cffi-1.14.6-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:aedb15f0a5a5949ecb129a82b72b19df97bbbca024081ed2ef88bd5c0a610534"},
+ {file = "cffi-1.14.6-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:48916e459c54c4a70e52745639f1db524542140433599e13911b2f329834276a"},
+ {file = "cffi-1.14.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f627688813d0a4140153ff532537fbe4afea5a3dffce1f9deb7f91f848a832b5"},
+ {file = "cffi-1.14.6-cp35-cp35m-win32.whl", hash = "sha256:f0010c6f9d1a4011e429109fda55a225921e3206e7f62a0c22a35344bfd13cca"},
+ {file = "cffi-1.14.6-cp35-cp35m-win_amd64.whl", hash = "sha256:57e555a9feb4a8460415f1aac331a2dc833b1115284f7ded7278b54afc5bd218"},
+ {file = "cffi-1.14.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e8c6a99be100371dbb046880e7a282152aa5d6127ae01783e37662ef73850d8f"},
+ {file = "cffi-1.14.6-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:19ca0dbdeda3b2615421d54bef8985f72af6e0c47082a8d26122adac81a95872"},
+ {file = "cffi-1.14.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d950695ae4381ecd856bcaf2b1e866720e4ab9a1498cba61c602e56630ca7195"},
+ {file = "cffi-1.14.6-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9dc245e3ac69c92ee4c167fbdd7428ec1956d4e754223124991ef29eb57a09d"},
+ {file = "cffi-1.14.6-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8661b2ce9694ca01c529bfa204dbb144b275a31685a075ce123f12331be790b"},
+ {file = "cffi-1.14.6-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b315d709717a99f4b27b59b021e6207c64620790ca3e0bde636a6c7f14618abb"},
+ {file = "cffi-1.14.6-cp36-cp36m-win32.whl", hash = "sha256:80b06212075346b5546b0417b9f2bf467fea3bfe7352f781ffc05a8ab24ba14a"},
+ {file = "cffi-1.14.6-cp36-cp36m-win_amd64.whl", hash = "sha256:a9da7010cec5a12193d1af9872a00888f396aba3dc79186604a09ea3ee7c029e"},
+ {file = "cffi-1.14.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4373612d59c404baeb7cbd788a18b2b2a8331abcc84c3ba40051fcd18b17a4d5"},
+ {file = "cffi-1.14.6-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:f10afb1004f102c7868ebfe91c28f4a712227fe4cb24974350ace1f90e1febbf"},
+ {file = "cffi-1.14.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fd4305f86f53dfd8cd3522269ed7fc34856a8ee3709a5e28b2836b2db9d4cd69"},
+ {file = "cffi-1.14.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d6169cb3c6c2ad50db5b868db6491a790300ade1ed5d1da29289d73bbe40b56"},
+ {file = "cffi-1.14.6-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d4b68e216fc65e9fe4f524c177b54964af043dde734807586cf5435af84045c"},
+ {file = "cffi-1.14.6-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33791e8a2dc2953f28b8d8d300dde42dd929ac28f974c4b4c6272cb2955cb762"},
+ {file = "cffi-1.14.6-cp37-cp37m-win32.whl", hash = "sha256:0c0591bee64e438883b0c92a7bed78f6290d40bf02e54c5bf0978eaf36061771"},
+ {file = "cffi-1.14.6-cp37-cp37m-win_amd64.whl", hash = "sha256:8eb687582ed7cd8c4bdbff3df6c0da443eb89c3c72e6e5dcdd9c81729712791a"},
+ {file = "cffi-1.14.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ba6f2b3f452e150945d58f4badd92310449876c4c954836cfb1803bdd7b422f0"},
+ {file = "cffi-1.14.6-cp38-cp38-manylinux1_i686.whl", hash = "sha256:64fda793737bc4037521d4899be780534b9aea552eb673b9833b01f945904c2e"},
+ {file = "cffi-1.14.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9f3e33c28cd39d1b655ed1ba7247133b6f7fc16fa16887b120c0c670e35ce346"},
+ {file = "cffi-1.14.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26bb2549b72708c833f5abe62b756176022a7b9a7f689b571e74c8478ead51dc"},
+ {file = "cffi-1.14.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb687a11f0a7a1839719edd80f41e459cc5366857ecbed383ff376c4e3cc6afd"},
+ {file = "cffi-1.14.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2ad4d668a5c0645d281dcd17aff2be3212bc109b33814bbb15c4939f44181cc"},
+ {file = "cffi-1.14.6-cp38-cp38-win32.whl", hash = "sha256:487d63e1454627c8e47dd230025780e91869cfba4c753a74fda196a1f6ad6548"},
+ {file = "cffi-1.14.6-cp38-cp38-win_amd64.whl", hash = "sha256:c33d18eb6e6bc36f09d793c0dc58b0211fccc6ae5149b808da4a62660678b156"},
+ {file = "cffi-1.14.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:06c54a68935738d206570b20da5ef2b6b6d92b38ef3ec45c5422c0ebaf338d4d"},
+ {file = "cffi-1.14.6-cp39-cp39-manylinux1_i686.whl", hash = "sha256:f174135f5609428cc6e1b9090f9268f5c8935fddb1b25ccb8255a2d50de6789e"},
+ {file = "cffi-1.14.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f3ebe6e73c319340830a9b2825d32eb6d8475c1dac020b4f0aa774ee3b898d1c"},
+ {file = "cffi-1.14.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c8d896becff2fa653dc4438b54a5a25a971d1f4110b32bd3068db3722c80202"},
+ {file = "cffi-1.14.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4922cd707b25e623b902c86188aca466d3620892db76c0bdd7b99a3d5e61d35f"},
+ {file = "cffi-1.14.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c9e005e9bd57bc987764c32a1bee4364c44fdc11a3cc20a40b93b444984f2b87"},
+ {file = "cffi-1.14.6-cp39-cp39-win32.whl", hash = "sha256:eb9e2a346c5238a30a746893f23a9535e700f8192a68c07c0258e7ece6ff3728"},
+ {file = "cffi-1.14.6-cp39-cp39-win_amd64.whl", hash = "sha256:818014c754cd3dba7229c0f5884396264d51ffb87ec86e927ef0be140bfdb0d2"},
+ {file = "cffi-1.14.6.tar.gz", hash = "sha256:c9a875ce9d7fe32887784274dd533c57909b7b1dcadcc128a2ac21331a9765dd"},
]
cfgv = [
- {file = "cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d"},
- {file = "cfgv-3.2.0.tar.gz", hash = "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"},
+ {file = "cfgv-3.3.0-py2.py3-none-any.whl", hash = "sha256:b449c9c6118fe8cca7fa5e00b9ec60ba08145d281d52164230a69211c5d597a1"},
+ {file = "cfgv-3.3.0.tar.gz", hash = "sha256:9e600479b3b99e8af981ecdfc80a0296104ee610cab48a5ae4ffd0b668650eb1"},
]
chardet = [
{file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"},
{file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"},
]
+charset-normalizer = [
+ {file = "charset-normalizer-2.0.4.tar.gz", hash = "sha256:f23667ebe1084be45f6ae0538e4a5a865206544097e4e8bbcacf42cd02a348f3"},
+ {file = "charset_normalizer-2.0.4-py3-none-any.whl", hash = "sha256:0c8911edd15d19223366a194a513099a302055a962bca2cec0f54b8b63175d8b"},
+]
colorama = [
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
{file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
@@ -1100,12 +1268,12 @@ deepdiff = [
{file = "deepdiff-4.3.2.tar.gz", hash = "sha256:91360be1d9d93b1d9c13ae9c5048fa83d9cff17a88eb30afaa0d7ff2d0fee17d"},
]
"discord.py" = [
- {file = "discord.py-1.6.0-py3-none-any.whl", hash = "sha256:3df148daf6fbcc7ab5b11042368a3cd5f7b730b62f09fb5d3cbceff59bcfbb12"},
- {file = "discord.py-1.6.0.tar.gz", hash = "sha256:ba8be99ff1b8c616f7b6dcb700460d0222b29d4c11048e74366954c465fdd05f"},
+ {file = "discord.py-1.7.3-py3-none-any.whl", hash = "sha256:c6f64db136de0e18e090f6752ea68bdd4ab0a61b82dfe7acecefa22d6477bb0c"},
+ {file = "discord.py-1.7.3.tar.gz", hash = "sha256:462cd0fe307aef8b29cbfa8dd613e548ae4b2cb581d46da9ac0d46fb6ea19408"},
]
distlib = [
- {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"},
- {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"},
+ {file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"},
+ {file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"},
]
docopt = [
{file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"},
@@ -1113,13 +1281,17 @@ docopt = [
emoji = [
{file = "emoji-0.6.0.tar.gz", hash = "sha256:e42da4f8d648f8ef10691bc246f682a1ec6b18373abfd9be10ec0b398823bd11"},
]
+execnet = [
+ {file = "execnet-1.9.0-py2.py3-none-any.whl", hash = "sha256:a295f7cc774947aac58dde7fdc85f4aa00c42adf5d8f5468fc630c1acf30a142"},
+ {file = "execnet-1.9.0.tar.gz", hash = "sha256:8f694f3ba9cc92cab508b152dcfe322153975c29bda272e2fd7f3f00f36e47c5"},
+]
fakeredis = [
- {file = "fakeredis-1.5.0-py3-none-any.whl", hash = "sha256:e0416e4941cecd3089b0d901e60c8dc3c944f6384f5e29e2261c0d3c5fa99669"},
- {file = "fakeredis-1.5.0.tar.gz", hash = "sha256:1ac0cef767c37f51718874a33afb5413e69d132988cb6a80c6e6dbeddf8c7623"},
+ {file = "fakeredis-1.5.2-py3-none-any.whl", hash = "sha256:f1ffdb134538e6d7c909ddfb4fc5edeb4a73d0ea07245bc69b8135fbc4144b04"},
+ {file = "fakeredis-1.5.2.tar.gz", hash = "sha256:18fc1808d2ce72169d3f11acdb524a00ef96bd29970c6d34cfeb2edb3fc0c020"},
]
feedparser = [
- {file = "feedparser-6.0.2-py3-none-any.whl", hash = "sha256:f596c4b34fb3e2dc7e6ac3a8191603841e8d5d267210064e94d4238737452ddd"},
- {file = "feedparser-6.0.2.tar.gz", hash = "sha256:1b00a105425f492f3954fd346e5b524ca9cef3a4bbf95b8809470e9857aa1074"},
+ {file = "feedparser-6.0.8-py3-none-any.whl", hash = "sha256:1b7f57841d9cf85074deb316ed2c795091a238adb79846bc46dccdaf80f9c59a"},
+ {file = "feedparser-6.0.8.tar.gz", hash = "sha256:5ce0410a05ab248c8c7cfca3a0ea2203968ee9ff4486067379af4827a59f9661"},
]
filelock = [
{file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"},
@@ -1160,10 +1332,6 @@ flake8-tidy-imports = [
flake8-todo = [
{file = "flake8-todo-0.7.tar.gz", hash = "sha256:6e4c5491ff838c06fe5a771b0e95ee15fc005ca57196011011280fc834a85915"},
]
-fuzzywuzzy = [
- {file = "fuzzywuzzy-0.18.0-py2.py3-none-any.whl", hash = "sha256:928244b28db720d1e0ee7587acf660ea49d7e4c632569cad4f1cd7e68a5f0993"},
- {file = "fuzzywuzzy-0.18.0.tar.gz", hash = "sha256:45016e92264780e58972dca1b3d939ac864b78437422beecebb3095f8efd00e8"},
-]
hiredis = [
{file = "hiredis-2.0.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b4c8b0bc5841e578d5fb32a16e0c305359b987b850a06964bd5a62739d688048"},
{file = "hiredis-2.0.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0adea425b764a08270820531ec2218d0508f8ae15a448568109ffcae050fee26"},
@@ -1208,16 +1376,20 @@ hiredis = [
{file = "hiredis-2.0.0.tar.gz", hash = "sha256:81d6d8e39695f2c37954d1011c0480ef7cf444d4e3ae24bc5e89ee5de360139a"},
]
humanfriendly = [
- {file = "humanfriendly-9.1-py2.py3-none-any.whl", hash = "sha256:d5c731705114b9ad673754f3317d9fa4c23212f36b29bdc4272a892eafc9bc72"},
- {file = "humanfriendly-9.1.tar.gz", hash = "sha256:066562956639ab21ff2676d1fda0b5987e985c534fc76700a19bd54bcb81121d"},
+ {file = "humanfriendly-9.2-py2.py3-none-any.whl", hash = "sha256:332da98c24cc150efcc91b5508b19115209272bfdf4b0764a56795932f854271"},
+ {file = "humanfriendly-9.2.tar.gz", hash = "sha256:f7dba53ac7935fd0b4a2fc9a29e316ddd9ea135fb3052d3d0279d10c18ff9c48"},
]
identify = [
- {file = "identify-2.2.4-py2.py3-none-any.whl", hash = "sha256:ad9f3fa0c2316618dc4d840f627d474ab6de106392a4f00221820200f490f5a8"},
- {file = "identify-2.2.4.tar.gz", hash = "sha256:9bcc312d4e2fa96c7abebcdfb1119563b511b5e3985ac52f60d9116277865b2e"},
+ {file = "identify-2.2.11-py2.py3-none-any.whl", hash = "sha256:7abaecbb414e385752e8ce02d8c494f4fbc780c975074b46172598a28f1ab839"},
+ {file = "identify-2.2.11.tar.gz", hash = "sha256:a0e700637abcbd1caae58e0463861250095dfe330a8371733a471af706a4a29a"},
]
idna = [
- {file = "idna-3.1-py3-none-any.whl", hash = "sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16"},
- {file = "idna-3.1.tar.gz", hash = "sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1"},
+ {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"},
+ {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"},
+]
+iniconfig = [
+ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
+ {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
]
lxml = [
{file = "lxml-4.6.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:df7c53783a46febb0e70f6b05df2ba104610f2fb0d27023409734a3ecbb78fb2"},
@@ -1276,8 +1448,8 @@ mccabe = [
{file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
]
more-itertools = [
- {file = "more-itertools-8.7.0.tar.gz", hash = "sha256:c5d6da9ca3ff65220c3bfd2a8db06d698f05d4d2b9be57e1deb2be5a45019713"},
- {file = "more_itertools-8.7.0-py3-none-any.whl", hash = "sha256:5652a9ac72209ed7df8d9c15daf4e1aa0e3d2ccd3c87f8265a0673cd9cbc9ced"},
+ {file = "more-itertools-8.8.0.tar.gz", hash = "sha256:83f0308e05477c68f56ea3a888172c78ed5d5b3c282addb67508e7ba6c8f813a"},
+ {file = "more_itertools-8.8.0-py3-none-any.whl", hash = "sha256:2cf89ec599962f2ddc4d568a05defc40e0a587fbc10d5989713638864c36be4d"},
]
mslex = [
{file = "mslex-0.3.0-py2.py3-none-any.whl", hash = "sha256:380cb14abf8fabf40e56df5c8b21a6d533dc5cbdcfe42406bbf08dda8f42e42a"},
@@ -1329,17 +1501,29 @@ nodeenv = [
ordered-set = [
{file = "ordered-set-4.0.2.tar.gz", hash = "sha256:ba93b2df055bca202116ec44b9bead3df33ea63a7d5827ff8e16738b97f33a95"},
]
+packaging = [
+ {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"},
+ {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"},
+]
pamqp = [
{file = "pamqp-2.3.0-py2.py3-none-any.whl", hash = "sha256:2f81b5c186f668a67f165193925b6bfd83db4363a6222f599517f29ecee60b02"},
{file = "pamqp-2.3.0.tar.gz", hash = "sha256:5cd0f5a85e89f20d5f8e19285a1507788031cfca4a9ea6f067e3cf18f5e294e8"},
]
pep8-naming = [
- {file = "pep8-naming-0.11.1.tar.gz", hash = "sha256:a1dd47dd243adfe8a83616e27cf03164960b507530f155db94e10b36a6cd6724"},
- {file = "pep8_naming-0.11.1-py2.py3-none-any.whl", hash = "sha256:f43bfe3eea7e0d73e8b5d07d6407ab47f2476ccaeff6937c84275cd30b016738"},
+ {file = "pep8-naming-0.12.0.tar.gz", hash = "sha256:1f9a3ecb2f3fd83240fd40afdd70acc89695c49c333413e49788f93b61827e12"},
+ {file = "pep8_naming-0.12.0-py2.py3-none-any.whl", hash = "sha256:2321ac2b7bf55383dd19a6a9c8ae2ebf05679699927a3af33e60dd7d337099d3"},
+]
+platformdirs = [
+ {file = "platformdirs-2.2.0-py3-none-any.whl", hash = "sha256:4666d822218db6a262bdfdc9c39d21f23b4cfdb08af331a81e92751daf6c866c"},
+ {file = "platformdirs-2.2.0.tar.gz", hash = "sha256:632daad3ab546bd8e6af0537d09805cec458dce201bccfe23012df73332e181e"},
+]
+pluggy = [
+ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
+ {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},
]
pre-commit = [
- {file = "pre_commit-2.12.1-py2.py3-none-any.whl", hash = "sha256:70c5ec1f30406250b706eda35e868b87e3e4ba099af8787e3e8b4b01e84f4712"},
- {file = "pre_commit-2.12.1.tar.gz", hash = "sha256:900d3c7e1bf4cf0374bb2893c24c23304952181405b4d88c9c40b72bda1bb8a9"},
+ {file = "pre_commit-2.13.0-py2.py3-none-any.whl", hash = "sha256:b679d0fddd5b9d6d98783ae5f10fd0c4c59954f375b70a58cbe1ce9bcf9809a4"},
+ {file = "pre_commit-2.13.0.tar.gz", hash = "sha256:764972c60693dc668ba8e86eb29654ec3144501310f7198742a767bec385a378"},
]
psutil = [
{file = "psutil-5.8.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:0066a82f7b1b37d334e68697faba68e5ad5e858279fd6351c8ca6024e8d6ba64"},
@@ -1371,40 +1555,44 @@ psutil = [
{file = "psutil-5.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:f4634b033faf0d968bb9220dd1c793b897ab7f1189956e1aa9eae752527127d3"},
{file = "psutil-5.8.0.tar.gz", hash = "sha256:0c9ccb99ab76025f2f0bbecf341d4656e9c1351db8cc8a03ccd62e318ab4b5c6"},
]
+py = [
+ {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"},
+ {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"},
+]
pycares = [
- {file = "pycares-3.2.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ebff743643e54aa70dce0b7098094edefd371641cf79d9c944e9f4a25e9242b0"},
- {file = "pycares-3.2.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:55272411b46787936e8db475b9b6e9b81a8d8cdc253fa8779a45ef979f554fab"},
- {file = "pycares-3.2.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:f33ed0e403f98e746f721aeacde917f1bdc7558cb714d713c264848bddff660f"},
- {file = "pycares-3.2.3-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:72807e0c80b705e21c3a39347c12edf43aa4f80373bb37777facf810169372ed"},
- {file = "pycares-3.2.3-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:a51df0a8b3eaf225e0dae3a737fd6ce6f3cb2a3bc947e884582fdda9a159d55f"},
- {file = "pycares-3.2.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:663b5c7bd0f66436adac7257ee22ccfe185c3e7830b9bada3d19b79870e1d134"},
- {file = "pycares-3.2.3-cp36-cp36m-win32.whl", hash = "sha256:c2b1e19262ce91c3288b1905b0d41f7ad0fff4b258ce37b517aa2c8d22eb82f1"},
- {file = "pycares-3.2.3-cp36-cp36m-win_amd64.whl", hash = "sha256:e16399654a6c81cfaee2745857c119c20357b5d93de2f169f506b048b5e75d1d"},
- {file = "pycares-3.2.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:88e5131570d7323b29866aa5ac245a9a5788d64677111daa1bde5817acdf012f"},
- {file = "pycares-3.2.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1552ffd823dc595fa8744c996926097a594f4f518d7c147657234b22cf17649d"},
- {file = "pycares-3.2.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f9e28b917373818817aca746238fcd621ec7e4ae9cbc8615f1a045e234eec298"},
- {file = "pycares-3.2.3-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:206d5a652990f10a1f1f3f62bc23d7fe46d99c2dc4b8b8a5101e5a472986cd02"},
- {file = "pycares-3.2.3-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:b8c9670225cdeeeb2b85ea92a807484622ca59f8f578ec73e8ec292515f35a91"},
- {file = "pycares-3.2.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:6329160885fc318f80692d4d0a83a8854f9144e7a80c4f25245d0c26f11a4b84"},
- {file = "pycares-3.2.3-cp37-cp37m-win32.whl", hash = "sha256:cd0f7fb40e1169f00b26a12793136bf5c711f155e647cd045a0ce6c98a527b57"},
- {file = "pycares-3.2.3-cp37-cp37m-win_amd64.whl", hash = "sha256:a5d419215543d154587590d9d4485e985387ca10c7d3e1a2e5689dd6c0f20e5f"},
- {file = "pycares-3.2.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e54f1c0642935515f27549f09486e72b6b2b1d51ad27a90ce17b760e9ce5e86d"},
- {file = "pycares-3.2.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6ce80eed538dd6106cd7e6136ceb3af10178d1254f07096a827c12e82e5e45c8"},
- {file = "pycares-3.2.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:ed972a04067e91f552da84945d38b94c3984c898f699faa8bb066e9f3a114c32"},
- {file = "pycares-3.2.3-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:99a62b101cfb36ab6ebf19cb1ad60db2f9b080dc52db4ca985fe90924f60c758"},
- {file = "pycares-3.2.3-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:2246adcbc948dd31925c9bff5cc41c06fc640f7d982e6b41b6d09e4f201e5c11"},
- {file = "pycares-3.2.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:7fd15d3f32be5548f38f95f4762ca73eef9fd623b101218a35d433ee0d4e3b58"},
- {file = "pycares-3.2.3-cp38-cp38-win32.whl", hash = "sha256:4bb0c708d8713741af7c4649d2f11e47c5f4e43131831243aeb18cff512c5469"},
- {file = "pycares-3.2.3-cp38-cp38-win_amd64.whl", hash = "sha256:a53d921956d1e985e510ca0ffa84fbd7ecc6ac7d735d8355cba4395765efcd31"},
- {file = "pycares-3.2.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0312d25fa9d7c242f66115c4b3ae6ed8aedb457513ba33acef31fa265fc602b4"},
- {file = "pycares-3.2.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9960de8254525d9c3b485141809910c39d5eb1bb8119b1453702aacf72234934"},
- {file = "pycares-3.2.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:929f708a7bb4b2548cbbfc2094b2f90c4d8712056cdc0204788b570ab69c8838"},
- {file = "pycares-3.2.3-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4dd1237f01037cf5b90dd599c7fa79d9d8fb2ab2f401e19213d24228b2d17838"},
- {file = "pycares-3.2.3-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:5eea61a74097976502ce377bb75c4fed381d4986bc7fb85e70b691165133d3da"},
- {file = "pycares-3.2.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:1c72c0fda4b08924fe04680475350e09b8d210365d950a6dcdde8c449b8d5b98"},
- {file = "pycares-3.2.3-cp39-cp39-win32.whl", hash = "sha256:b1555d51ce29510ffd20f9e0339994dff8c5d1cb093c8e81d5d98f474e345aa7"},
- {file = "pycares-3.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:43c15138f620ed28e61e51b884490eb8387e5954668f919313753f88dd8134fd"},
- {file = "pycares-3.2.3.tar.gz", hash = "sha256:da1899fde778f9b8736712283eccbf7b654248779b349d139cd28eb30b0fa8cd"},
+ {file = "pycares-4.0.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:db5a533111a3cfd481e7e4fb2bf8bef69f4fa100339803e0504dd5aecafb96a5"},
+ {file = "pycares-4.0.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:fdff88393c25016f417770d82678423fc7a56995abb2df3d2a1e55725db6977d"},
+ {file = "pycares-4.0.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0aa97f900a7ffb259be77d640006585e2a907b0cd4edeee0e85cf16605995d5a"},
+ {file = "pycares-4.0.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:a34b0e3e693dceb60b8a1169668d606c75cb100ceba0a2df53c234a0eb067fbc"},
+ {file = "pycares-4.0.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7661d6bbd51a337e7373cb356efa8be9b4655fda484e068f9455e939aec8d54e"},
+ {file = "pycares-4.0.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:57315b8eb8fdbc56b3ad4932bc4b17132bb7c7fd2bd590f7fb84b6b522098aa9"},
+ {file = "pycares-4.0.0-cp36-cp36m-win32.whl", hash = "sha256:dca9dc58845a9d083f302732a3130c68ded845ad5d463865d464e53c75a3dd45"},
+ {file = "pycares-4.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:c95c964d5dd307e104b44b193095c67bb6b10c9eda1ffe7d44ab7a9e84c476d9"},
+ {file = "pycares-4.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:26e67e4f81c80a5955dcf6193f3d9bee3c491fc0056299b383b84d792252fba4"},
+ {file = "pycares-4.0.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:cd3011ffd5e1ad55880f7256791dbab9c43ebeda260474a968f19cd0319e1aef"},
+ {file = "pycares-4.0.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1b959dd5921d207d759d421eece1b60416df33a7f862465739d5f2c363c2f523"},
+ {file = "pycares-4.0.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:6f258c1b74c048a9501a25f732f11b401564005e5e3c18f1ca6cad0c3dc0fb19"},
+ {file = "pycares-4.0.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:b17ef48729786e62b574c6431f675f4cb02b27691b49e7428a605a50cd59c072"},
+ {file = "pycares-4.0.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:82b3259cb590ddd107a6d2dc52da2a2e9a986bf242e893d58c786af2f8191047"},
+ {file = "pycares-4.0.0-cp37-cp37m-win32.whl", hash = "sha256:4876fc790ae32832ae270c4a010a1a77e12ddf8d8e6ad70ad0b0a9d506c985f7"},
+ {file = "pycares-4.0.0-cp37-cp37m-win_amd64.whl", hash = "sha256:f60c04c5561b1ddf85ca4e626943cc09d7fb684e1adb22abb632095415a40fd7"},
+ {file = "pycares-4.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:615406013cdcd1b445e5d1a551d276c6200b3abe77e534f8a7f7e1551208d14f"},
+ {file = "pycares-4.0.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6580aef5d1b29a88c3d72fe73c691eacfd454f86e74d3fdd18f4bad8e8def98b"},
+ {file = "pycares-4.0.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8ebb3ba0485f66cae8eed7ce3e9ed6f2c0bfd5e7319d5d0fbbb511064f17e1d4"},
+ {file = "pycares-4.0.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:c5362b7690ca481440f6b98395ac6df06aa50518ccb183c560464d1e5e2ab5d4"},
+ {file = "pycares-4.0.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:eb60be66accc9a9ea1018b591a1f5800cba83491d07e9acc8c56bc6e6607ab54"},
+ {file = "pycares-4.0.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:44896d6e191a6b5a914dbe3aa7c748481bf6ad19a9df33c1e76f8f2dc33fc8f0"},
+ {file = "pycares-4.0.0-cp38-cp38-win32.whl", hash = "sha256:09b28fc7bc2cc05f7f69bf1636ddf46086e0a1837b62961e2092fcb40477320d"},
+ {file = "pycares-4.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:d4a5081e232c1d181883dcac4675807f3a6cf33911c4173fbea00c0523687ed4"},
+ {file = "pycares-4.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:103353577a6266a53e71bfee4cf83825f1401fefa60f0fb8bdec35f13be6a5f2"},
+ {file = "pycares-4.0.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:ad6caf580ee69806fc6534be93ddbb6e99bf94296d79ab351c37b2992b17abfd"},
+ {file = "pycares-4.0.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:3d5e50c95849f6905d2a9dbf02ed03f82580173e3c5604a39e2ad054185631f1"},
+ {file = "pycares-4.0.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:53bc4f181b19576499b02cea4b45391e8dcbe30abd4cd01492f66bfc15615a13"},
+ {file = "pycares-4.0.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:d52f9c725d2a826d5ffa37681eb07ffb996bfe21788590ef257664a3898fc0b5"},
+ {file = "pycares-4.0.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:3c7fb8d34ee11971c39acfaf98d0fac66725385ccef3bfe1b174c92b210e1aa4"},
+ {file = "pycares-4.0.0-cp39-cp39-win32.whl", hash = "sha256:e9773e07684a55f54657df05237267611a77b294ec3bacb5f851c4ffca38a465"},
+ {file = "pycares-4.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:38e54037f36c149146ff15f17a4a963fbdd0f9871d4a21cd94ff9f368140f57e"},
+ {file = "pycares-4.0.0.tar.gz", hash = "sha256:d0154fc5753b088758fbec9bc137e1b24bb84fc0c6a09725c8bac25a342311cd"},
]
pycodestyle = [
{file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"},
@@ -1415,21 +1603,41 @@ pycparser = [
{file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"},
]
pydocstyle = [
- {file = "pydocstyle-6.0.0-py3-none-any.whl", hash = "sha256:d4449cf16d7e6709f63192146706933c7a334af7c0f083904799ccb851c50f6d"},
- {file = "pydocstyle-6.0.0.tar.gz", hash = "sha256:164befb520d851dbcf0e029681b91f4f599c62c5cd8933fd54b1bfbd50e89e1f"},
+ {file = "pydocstyle-6.1.1-py3-none-any.whl", hash = "sha256:6987826d6775056839940041beef5c08cc7e3d71d63149b48e36727f70144dc4"},
+ {file = "pydocstyle-6.1.1.tar.gz", hash = "sha256:1d41b7c459ba0ee6c345f2eb9ae827cab14a7533a88c5c6f7e94923f72df92dc"},
]
pyflakes = [
{file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"},
{file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"},
]
+pyparsing = [
+ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},
+ {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
+]
pyreadline = [
{file = "pyreadline-2.1.win-amd64.exe", hash = "sha256:9ce5fa65b8992dfa373bddc5b6e0864ead8f291c94fbfec05fbd5c836162e67b"},
{file = "pyreadline-2.1.win32.exe", hash = "sha256:65540c21bfe14405a3a77e4c085ecfce88724743a4ead47c66b84defcf82c32e"},
{file = "pyreadline-2.1.zip", hash = "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1"},
]
+pytest = [
+ {file = "pytest-6.2.4-py3-none-any.whl", hash = "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"},
+ {file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"},
+]
+pytest-cov = [
+ {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"},
+ {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"},
+]
+pytest-forked = [
+ {file = "pytest-forked-1.3.0.tar.gz", hash = "sha256:6aa9ac7e00ad1a539c41bec6d21011332de671e938c7637378ec9710204e37ca"},
+ {file = "pytest_forked-1.3.0-py2.py3-none-any.whl", hash = "sha256:dc4147784048e70ef5d437951728825a131b81714b398d5d52f17c7c144d8815"},
+]
+pytest-xdist = [
+ {file = "pytest-xdist-2.3.0.tar.gz", hash = "sha256:e8ecde2f85d88fbcadb7d28cb33da0fa29bca5cf7d5967fa89fc0e97e5299ea5"},
+ {file = "pytest_xdist-2.3.0-py3-none-any.whl", hash = "sha256:ed3d7da961070fce2a01818b51f6888327fb88df4379edeb6b9d990e789d9c8d"},
+]
python-dateutil = [
- {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"},
- {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"},
+ {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
+ {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
]
python-dotenv = [
{file = "python-dotenv-0.17.1.tar.gz", hash = "sha256:b1ae5e9643d5ed987fc57cc2583021e38db531946518130777734f9589b3141f"},
@@ -1446,22 +1654,93 @@ pyyaml = [
{file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"},
{file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"},
{file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"},
+ {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"},
+ {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541"},
{file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"},
{file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"},
{file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"},
{file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"},
+ {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa"},
+ {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"},
{file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"},
{file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"},
{file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"},
{file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"},
+ {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247"},
+ {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc"},
{file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"},
{file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"},
{file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"},
{file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"},
+ {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122"},
+ {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6"},
{file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"},
{file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"},
{file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"},
]
+rapidfuzz = [
+ {file = "rapidfuzz-1.4.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:72878878d6744883605b5453c382361716887e9e552f677922f76d93d622d8cb"},
+ {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:56a67a5b3f783e9af73940f6945366408b3a2060fc6ab18466e5a2894fd85617"},
+ {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f5d396b64f8ae3a793633911a1fb5d634ac25bf8f13d440139fa729131be42d8"},
+ {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:4990698233e7eda7face7c09f5874a09760c7524686045cbb10317e3a7f3225f"},
+ {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:a87e212855b18a951e79ec71d71dbd856d98cd2019d0c2bd46ec30688a8aa68a"},
+ {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1897d2ef03f5b51bc19bdb2d0398ae968766750fa319843733f0a8f12ddde986"},
+ {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2014_ppc64le.whl", hash = "sha256:e1fc4fd219057f5f1fa40bb9bc5e880f8ef45bf19350d4f5f15ca2ce7f61c99b"},
+ {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2014_s390x.whl", hash = "sha256:21300c4d048798985c271a8bf1ed1611902ebd4479fcacda1a3eaaebbad2f744"},
+ {file = "rapidfuzz-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:d2659967c6ac74211a87a1109e79253e4bc179641057c64800ef4e2dc0534fdb"},
+ {file = "rapidfuzz-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:26ac4bfe564c516e053fc055f1543d2b2433338806738c7582e1f75ed0485f7e"},
+ {file = "rapidfuzz-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3b485c98ad1ce3c04556f65aaab5d6d6d72121cde656d43505169c71ae956476"},
+ {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:59db06356eaf22c83f44b0dded964736cbb137291cdf2cf7b4974c0983b94932"},
+ {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fef95249af9a535854b617a68788c38cd96308d97ee14d44bc598cc73e986167"},
+ {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:7d8c186e8270e103d339b26ef498581cf3178470ccf238dfd5fd0e47d80e4c7d"},
+ {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:9246b9c5c8992a83a08ac7813c8bbff2e674ad0b681f9b3fb1ec7641eff6c21f"},
+ {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:f58c17f7a82b1bcc2ce304942cae14287223e6b6eead7071241273da7d9b9770"},
+ {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:ed708620b23a09ac52eaaec0761943c1bbc9a62d19ecd2feb4da8c3f79ef9d37"},
+ {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:bdec9ae5fd8a8d4d8813b4aac3505c027b922b4033a32a7aab66a9b2f03a7b47"},
+ {file = "rapidfuzz-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:fc668fd706ad1162ce14f26ca2957b4690d47770d23609756536c918a855ced0"},
+ {file = "rapidfuzz-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f9f35df5dd9b02669ff6b1d4a386607ff56982c86a7e57d95eb08c6afbab4ddd"},
+ {file = "rapidfuzz-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8427310ea29ce2968e1c6f6779ae5a458b3a4984f9150fc4d16f92b96456f848"},
+ {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1430dc745476e3798742ad835f61f6e6bf5d3e9a22cf9cd0288b28b7440a9872"},
+ {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1d20311da611c8f4638a09e2bc5e04b327bae010cb265ef9628d9c13c6d5da7b"},
+ {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7881965e428cf6fe248d6e702e6d5857da02278ab9b21313bee717c080e443e"},
+ {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:f76c965f15861ec4d39e904bd65b84a39121334439ac17bfb8b900d1e6779a93"},
+ {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:61167f989415e701ac379de247e6b0a21ea62afc86c54d8a79f485b4f0173c02"},
+ {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:645cfb9456229f0bd5752b3eda69f221d825fbb8cbb8855433516bc185111506"},
+ {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:c28be57c9bc47b3d7f484340fab1bec8ed4393dee1090892c2774a4584435eb8"},
+ {file = "rapidfuzz-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:3c94b6d3513c693f253ff762112cc4580d3bd377e4abacb96af31a3d606fbe14"},
+ {file = "rapidfuzz-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:506d50a066451502ee2f8bf016bc3ba3e3b04eede7a4059d7956248e2dd96179"},
+ {file = "rapidfuzz-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:80b375098658bb3db14215a975d354f6573d3943ac2ae0c4627c7760d57ce075"},
+ {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:ba8f7cbd8fdbd3ae115f4484888f3cb94bc2ac7cbd4eb1ca95a3d4f874261ff8"},
+ {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:5fa8570720b0fdfc52f24f5663d66c52ea88ba19cb8b1ff6a39a8bc0b925b33b"},
+ {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:f35c8a4c690447fd335bfd77df4da42dfea37cfa06a8ecbf22543d86dc720e12"},
+ {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:27f9eef48e212d73e78f0f5ceedc62180b68f6a25fa0752d2ccfaedc3a840bec"},
+ {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:31e99216e2a04aec4f281d472b28a683921f1f669a429cf605d11526623eaeed"},
+ {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:f22bf7ba6eddd59764457f74c637ab5c3ed976c5fcfaf827e1d320cc0478e12b"},
+ {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:c43ddb354abd00e56f024ce80affb3023fa23206239bb81916d5877cba7f2d1e"},
+ {file = "rapidfuzz-1.4.1-cp38-cp38-win32.whl", hash = "sha256:62c1f4ac20c8019ce8d481fb27235306ef3912a8d0b9a60b17905699f43ff072"},
+ {file = "rapidfuzz-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:2963f356c70b710dc6337b012ec976ce2fc2b81c2a9918a686838fead6eb4e1d"},
+ {file = "rapidfuzz-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c07f301fd549b266410654850c6918318d7dcde8201350e9ac0819f0542cf147"},
+ {file = "rapidfuzz-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa4c8b6fc7e93e3a3fb9be9566f1fe7ef920735eadcee248a0d70f3ca8941341"},
+ {file = "rapidfuzz-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c200bd813bbd3b146ba0fd284a9ad314bbad9d95ed542813273bdb9d0ee4e796"},
+ {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2cccc84e1f0c6217747c09cafe93164e57d3644e18a334845a2dfbdd2073cd2c"},
+ {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f2033e3d61d1e498f618123b54dc7436d50510b0d18fd678d867720e8d7b2f23"},
+ {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:26b7f48b3ddd9d97cf8482a88f0f6cba47ac13ff16e63386ea7ce06178174770"},
+ {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:bf18614f87fe3bfff783f0a3d0fad0eb59c92391e52555976e55570a651d2330"},
+ {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:8cb5c2502ff06028a1468bdf61323b53cc3a37f54b5d62d62c5371795b81086a"},
+ {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:f37f80c1541d6e0a30547261900086b8c0bac519ebc12c9cd6b61a9a43a7e195"},
+ {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:c13cd1e840aa93639ac1d131fbfa740a609fd20dfc2a462d5cd7bce747a2398d"},
+ {file = "rapidfuzz-1.4.1-cp39-cp39-win32.whl", hash = "sha256:0ec346f271e96c485716c091c8b0b78ba52da33f7c6ebb52a349d64094566c2d"},
+ {file = "rapidfuzz-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:5208ce1b1989a10e6fc5b5ef5d0bb7d1ffe5408838f3106abde241aff4dab08c"},
+ {file = "rapidfuzz-1.4.1-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4fa195ea9ca35bacfa2a4319c6d4ab03aa6a283ad2089b70d2dfa0f6a7d9c1bc"},
+ {file = "rapidfuzz-1.4.1-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:6e336cfd8103b0b38e107e01502e9d6bf7c7f04e49b970fb11a4bf6c7a932b94"},
+ {file = "rapidfuzz-1.4.1-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:c798c5b87efe8a7e63f408e07ff3bc03ba8b94f4498a89b48eaab3a9f439d52c"},
+ {file = "rapidfuzz-1.4.1-pp36-pypy36_pp73-win32.whl", hash = "sha256:bb16a10b40f5bd3c645f7748fbd36f49699a03f550c010a2c665905cc8937de8"},
+ {file = "rapidfuzz-1.4.1-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2278001924031d9d75f821bff2c5fef565c8376f252562e04d8eec8857475c36"},
+ {file = "rapidfuzz-1.4.1-pp37-pypy37_pp73-manylinux1_x86_64.whl", hash = "sha256:a89d11f3b5da35fdf3e839186203b9367d56e2be792e8dccb098f47634ec6eb9"},
+ {file = "rapidfuzz-1.4.1-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:f8c79cd11b4778d387366a59aa747f5268433f9d68be37b00d16f4fb08fdf850"},
+ {file = "rapidfuzz-1.4.1-pp37-pypy37_pp73-win32.whl", hash = "sha256:4364db793ed4b439f9dd28a335bee14e2a828283d3b93c2d2686cc645eeafdd5"},
+ {file = "rapidfuzz-1.4.1.tar.gz", hash = "sha256:de20550178376d21bfe1b34a7dc42ab107bb282ef82069cf6dfe2805a0029e26"},
+]
redis = [
{file = "redis-3.5.3-py2.py3-none-any.whl", hash = "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"},
{file = "redis-3.5.3.tar.gz", hash = "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2"},
@@ -1510,12 +1789,12 @@ regex = [
{file = "regex-2021.4.4.tar.gz", hash = "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb"},
]
requests = [
- {file = "requests-2.15.1-py2.py3-none-any.whl", hash = "sha256:ff753b2196cd18b1bbeddc9dcd5c864056599f7a7d9a4fb5677e723efa2b7fb9"},
- {file = "requests-2.15.1.tar.gz", hash = "sha256:e5659b9315a0610505e050bb7190bf6fa2ccee1ac295f2b760ef9d8a03ebbb2e"},
+ {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"},
+ {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"},
]
sentry-sdk = [
- {file = "sentry-sdk-0.20.3.tar.gz", hash = "sha256:4ae8d1ced6c67f1c8ea51d82a16721c166c489b76876c9f2c202b8a50334b237"},
- {file = "sentry_sdk-0.20.3-py2.py3-none-any.whl", hash = "sha256:e75c8c58932bda8cd293ea8e4b242527129e1caaec91433d21b8b2f20fee030b"},
+ {file = "sentry-sdk-1.3.1.tar.gz", hash = "sha256:ebe99144fa9618d4b0e7617e7929b75acd905d258c3c779edcd34c0adfffe26c"},
+ {file = "sentry_sdk-1.3.1-py2.py3-none-any.whl", hash = "sha256:f33d34c886d0ba24c75ea8885a8b3a172358853c7cbde05979fc99c29ef7bc52"},
]
sgmllib3k = [
{file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"},
@@ -1529,8 +1808,8 @@ snowballstemmer = [
{file = "snowballstemmer-2.1.0.tar.gz", hash = "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"},
]
sortedcontainers = [
- {file = "sortedcontainers-2.3.0-py2.py3-none-any.whl", hash = "sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f"},
- {file = "sortedcontainers-2.3.0.tar.gz", hash = "sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1"},
+ {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"},
+ {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"},
]
soupsieve = [
{file = "soupsieve-2.2.1-py3-none-any.whl", hash = "sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b"},
@@ -1554,12 +1833,12 @@ typing-extensions = [
{file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"},
]
urllib3 = [
- {file = "urllib3-1.26.4-py2.py3-none-any.whl", hash = "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df"},
- {file = "urllib3-1.26.4.tar.gz", hash = "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"},
+ {file = "urllib3-1.26.6-py2.py3-none-any.whl", hash = "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4"},
+ {file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"},
]
virtualenv = [
- {file = "virtualenv-20.4.6-py2.py3-none-any.whl", hash = "sha256:307a555cf21e1550885c82120eccaf5acedf42978fd362d32ba8410f9593f543"},
- {file = "virtualenv-20.4.6.tar.gz", hash = "sha256:72cf267afc04bf9c86ec932329b7e94db6a0331ae9847576daaa7ca3c86b29a4"},
+ {file = "virtualenv-20.7.0-py2.py3-none-any.whl", hash = "sha256:fdfdaaf0979ac03ae7f76d5224a05b58165f3c804f8aa633f3dd6f22fbd435d5"},
+ {file = "virtualenv-20.7.0.tar.gz", hash = "sha256:97066a978431ec096d163e72771df5357c5c898ffdd587048f45e0aecc228094"},
]
yarl = [
{file = "yarl-1.6.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434"},
diff --git a/pyproject.toml b/pyproject.toml
index 320bf88cc..2ae79f9e4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,7 +10,6 @@ python = "3.9.*"
aio-pika = "~=6.1"
aiodns = "~=2.0"
aiohttp = "~=3.7"
-aioping = "~=0.3.1"
aioredis = "~=1.3.1"
arrow = "~=1.0.3"
async-rediscache = { version = "~=0.1.2", extras = ["fakeredis"] }
@@ -18,10 +17,10 @@ beautifulsoup4 = "~=4.9"
colorama = { version = "~=0.4.3", markers = "sys_platform == 'win32'" }
coloredlogs = "~=14.0"
deepdiff = "~=4.0"
-"discord.py" = "~=1.6.0"
+"discord.py" = "~=1.7.3"
emoji = "~=0.6"
feedparser = "~=6.0.2"
-fuzzywuzzy = "~=0.17"
+rapidfuzz = "~=1.4"
lxml = "~=4.4"
markdownify = "==0.6.1"
more_itertools = "~=8.2"
@@ -29,7 +28,7 @@ python-dateutil = "~=2.8"
python-frontmatter = "~=1.0.0"
pyyaml = "~=5.1"
regex = "==2021.4.4"
-sentry-sdk = "~=0.19"
+sentry-sdk = "~=1.3"
statsd = "~=3.3"
[tool.poetry.dev-dependencies]
@@ -47,6 +46,9 @@ pep8-naming = "~=0.9"
pre-commit = "~=2.1"
taskipy = "~=1.7.0"
python-dotenv = "~=0.17.1"
+pytest = "~=6.2.4"
+pytest-cov = "~=2.12.1"
+pytest-xdist = { version = "~=2.3.0", extras = ["psutil"] }
[build-system]
requires = ["poetry-core>=1.0.0"]
@@ -58,6 +60,12 @@ lint = "pre-commit run --all-files"
precommit = "pre-commit install"
build = "docker build -t ghcr.io/python-discord/bot:latest -f Dockerfile ."
push = "docker push ghcr.io/python-discord/bot:latest"
-test = "coverage run -m unittest"
+test-nocov = "pytest -n auto"
+test = "pytest -n auto --cov-report= --cov"
html = "coverage html"
report = "coverage report"
+
+[tool.coverage.run]
+branch = true
+source_pkgs = ["bot"]
+source = ["tests"]
diff --git a/tests/README.md b/tests/README.md
index 1a17c09bd..b7fddfaa2 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -4,6 +4,14 @@ Our bot is one of the most important tools we have for running our community. As
_**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you can take a look at the [Additional resources](#additional-resources) section at the bottom of this page._
+### Table of contents:
+- [Tools](#tools)
+- [Running tests](#running-tests)
+- [Writing tests](#writing-tests)
+- [Mocking](#mocking)
+- [Some considerations](#some-considerations)
+- [Additional resources](#additional-resources)
+
## Tools
We are using the following modules and packages for our unit tests:
@@ -11,15 +19,43 @@ We are using the following modules and packages for our unit tests:
- [unittest](https://docs.python.org/3/library/unittest.html) (standard library)
- [unittest.mock](https://docs.python.org/3/library/unittest.mock.html) (standard library)
- [coverage.py](https://coverage.readthedocs.io/en/stable/)
+- [pytest-cov](https://pytest-cov.readthedocs.io/en/latest/index.html)
+
+We also use the following package as a test runner:
+- [pytest](https://docs.pytest.org/en/6.2.x/)
-To ensure the results you obtain on your personal machine are comparable to those generated in the CI, please make sure to run your tests with the virtual environment defined by our [Poetry Project](/pyproject.toml). To run your tests with `poetry`, we've provided two "scripts" shortcuts:
+To ensure the results you obtain on your personal machine are comparable to those generated in the CI, please make sure to run your tests with the virtual environment defined by our [Poetry Project](/pyproject.toml). To run your tests with `poetry`, we've provided the following "script" shortcuts:
-- `poetry run task test` will run `unittest` with `coverage.py`
+- `poetry run task test-nocov` will run `pytest`.
+- `poetry run task test` will run `pytest` with `pytest-cov`.
- `poetry run task test path/to/test.py` will run a specific test.
- `poetry run task report` will generate a coverage report of the tests you've run with `poetry run task test`. If you append the `-m` flag to this command, the report will include the lines and branches not covered by tests in addition to the test coverage report.
If you want a coverage report, make sure to run the tests with `poetry run task test` *first*.
+## Running tests
+There are multiple ways to run the tests, which one you use will be determined by your goal, and stage in development.
+
+When actively developing, you'll most likely be working on one portion of the codebase, and as a result, won't need to run the entire test suite.
+To run just one file, and save time, you can use the following command:
+```shell
+poetry run task test-nocov <path/to/file.py>
+```
+
+For example:
+```shell
+poetry run task test-nocov tests/bot/exts/test_cogs.py
+```
+will run the test suite in the `test_cogs` file.
+
+If you'd like to collect coverage as well, you can append `--cov` to the command above.
+
+
+If you're done and are preparing to commit and push your code, it's a good idea to run the entire test suite as a sanity check:
+```shell
+poetry run task test
+```
+
## Writing tests
Since consistency is an important consideration for collaborative projects, we have written some guidelines on writing tests for the bot. In addition to these guidelines, it's a good idea to look at the existing code base for examples (e.g., [`test_converters.py`](/tests/bot/test_converters.py)).
diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py
index bd4fb5942..2b0549b98 100644
--- a/tests/bot/exts/backend/test_error_handler.py
+++ b/tests/bot/exts/backend/test_error_handler.py
@@ -4,12 +4,12 @@ from unittest.mock import AsyncMock, MagicMock, call, patch
from discord.ext.commands import errors
from bot.api import ResponseCodeError
-from bot.errors import InvalidInfractedUser, LockedResourceError
+from bot.errors import InvalidInfractedUserError, LockedResourceError
from bot.exts.backend.error_handler import ErrorHandler, setup
from bot.exts.info.tags import Tags
from bot.exts.moderation.silence import Silence
from bot.utils.checks import InWhitelistCheckFailure
-from tests.helpers import MockBot, MockContext, MockGuild, MockRole
+from tests.helpers import MockBot, MockContext, MockGuild, MockRole, MockTextChannel
class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase):
@@ -130,7 +130,7 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase):
"expect_mock_call": "send"
},
{
- "args": (self.ctx, errors.CommandInvokeError(InvalidInfractedUser(self.ctx.author))),
+ "args": (self.ctx, errors.CommandInvokeError(InvalidInfractedUserError(self.ctx.author))),
"expect_mock_call": "send"
}
)
@@ -226,8 +226,8 @@ class TrySilenceTests(unittest.IsolatedAsyncioTestCase):
self.bot.get_command.return_value.can_run = AsyncMock(side_effect=errors.CommandError())
self.assertFalse(await self.cog.try_silence(self.ctx))
- async def test_try_silence_silencing(self):
- """Should run silence command with correct arguments."""
+ async def test_try_silence_silence_duration(self):
+ """Should run silence command with correct duration argument."""
self.bot.get_command.return_value.can_run = AsyncMock(return_value=True)
test_cases = ("shh", "shhh", "shhhhhh", "shhhhhhhhhhhhhhhhhhh")
@@ -238,21 +238,85 @@ class TrySilenceTests(unittest.IsolatedAsyncioTestCase):
self.assertTrue(await self.cog.try_silence(self.ctx))
self.ctx.invoke.assert_awaited_once_with(
self.bot.get_command.return_value,
- duration=min(case.count("h")*2, 15)
+ duration_or_channel=None,
+ duration=min(case.count("h")*2, 15),
+ kick=False
)
+ async def test_try_silence_silence_arguments(self):
+ """Should run silence with the correct channel, duration, and kick arguments."""
+ self.bot.get_command.return_value.can_run = AsyncMock(return_value=True)
+
+ test_cases = (
+ (MockTextChannel(), None), # None represents the case when no argument is passed
+ (MockTextChannel(), False),
+ (MockTextChannel(), True)
+ )
+
+ for channel, kick in test_cases:
+ with self.subTest(kick=kick, channel=channel):
+ self.ctx.reset_mock()
+ self.ctx.invoked_with = "shh"
+
+ self.ctx.message.content = f"!shh {channel.name} {kick if kick is not None else ''}"
+ self.ctx.guild.text_channels = [channel]
+
+ self.assertTrue(await self.cog.try_silence(self.ctx))
+ self.ctx.invoke.assert_awaited_once_with(
+ self.bot.get_command.return_value,
+ duration_or_channel=channel,
+ duration=4,
+ kick=(kick if kick is not None else False)
+ )
+
+ async def test_try_silence_silence_message(self):
+ """If the words after the command could not be converted to a channel, None should be passed as channel."""
+ self.bot.get_command.return_value.can_run = AsyncMock(return_value=True)
+ self.ctx.invoked_with = "shh"
+ self.ctx.message.content = "!shh not_a_channel true"
+
+ self.assertTrue(await self.cog.try_silence(self.ctx))
+ self.ctx.invoke.assert_awaited_once_with(
+ self.bot.get_command.return_value,
+ duration_or_channel=None,
+ duration=4,
+ kick=False
+ )
+
async def test_try_silence_unsilence(self):
- """Should call unsilence command."""
+ """Should call unsilence command with correct duration and channel arguments."""
self.silence.silence.can_run = AsyncMock(return_value=True)
- test_cases = ("unshh", "unshhhhh", "unshhhhhhhhh")
+ test_cases = (
+ ("unshh", None),
+ ("unshhhhh", None),
+ ("unshhhhhhhhh", None),
+ ("unshh", MockTextChannel())
+ )
- for case in test_cases:
- with self.subTest(message=case):
+ for invoke, channel in test_cases:
+ with self.subTest(message=invoke, channel=channel):
self.bot.get_command.side_effect = (self.silence.silence, self.silence.unsilence)
self.ctx.reset_mock()
- self.ctx.invoked_with = case
+
+ self.ctx.invoked_with = invoke
+ self.ctx.message.content = f"!{invoke}"
+ if channel is not None:
+ self.ctx.message.content += f" {channel.name}"
+ self.ctx.guild.text_channels = [channel]
+
self.assertTrue(await self.cog.try_silence(self.ctx))
- self.ctx.invoke.assert_awaited_once_with(self.silence.unsilence)
+ self.ctx.invoke.assert_awaited_once_with(self.silence.unsilence, channel=channel)
+
+ async def test_try_silence_unsilence_message(self):
+ """If the words after the command could not be converted to a channel, None should be passed as channel."""
+ self.silence.silence.can_run = AsyncMock(return_value=True)
+ self.bot.get_command.side_effect = (self.silence.silence, self.silence.unsilence)
+
+ self.ctx.invoked_with = "unshh"
+ self.ctx.message.content = "!unshh not_a_channel"
+
+ self.assertTrue(await self.cog.try_silence(self.ctx))
+ self.ctx.invoke.assert_awaited_once_with(self.silence.unsilence, channel=None)
async def test_try_silence_no_match(self):
"""Should return `False` when message don't match."""
diff --git a/tests/bot/exts/events/__init__.py b/tests/bot/exts/events/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/exts/events/__init__.py
diff --git a/tests/bot/exts/events/test_code_jams.py b/tests/bot/exts/events/test_code_jams.py
new file mode 100644
index 000000000..b9ee1e363
--- /dev/null
+++ b/tests/bot/exts/events/test_code_jams.py
@@ -0,0 +1,170 @@
+import unittest
+from unittest.mock import AsyncMock, MagicMock, create_autospec, patch
+
+from discord import CategoryChannel
+from discord.ext.commands import BadArgument
+
+from bot.constants import Roles
+from bot.exts.events import code_jams
+from bot.exts.events.code_jams import _channels, _cog
+from tests.helpers import (
+ MockAttachment, MockBot, MockCategoryChannel, MockContext,
+ MockGuild, MockMember, MockRole, MockTextChannel, autospec
+)
+
+TEST_CSV = b"""\
+Team Name,Team Member Discord ID,Team Leader
+Annoyed Alligators,12345,Y
+Annoyed Alligators,54321,N
+Oscillating Otters,12358,Y
+Oscillating Otters,74832,N
+Oscillating Otters,19903,N
+Annoyed Alligators,11111,N
+"""
+
+
+def get_mock_category(channel_count: int, name: str) -> CategoryChannel:
+ """Return a mocked code jam category."""
+ category = create_autospec(CategoryChannel, spec_set=True, instance=True)
+ category.name = name
+ category.channels = [MockTextChannel() for _ in range(channel_count)]
+
+ return category
+
+
+class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase):
+ """Tests for `codejam create` command."""
+
+ def setUp(self):
+ self.bot = MockBot()
+ self.admin_role = MockRole(name="Admins", id=Roles.admins)
+ self.command_user = MockMember([self.admin_role])
+ self.guild = MockGuild([self.admin_role])
+ self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild)
+ self.cog = _cog.CodeJams(self.bot)
+
+ async def test_message_without_attachments(self):
+ """If no link or attachments are provided, commands.BadArgument should be raised."""
+ self.ctx.message.attachments = []
+
+ with self.assertRaises(BadArgument):
+ await self.cog.create(self.cog, self.ctx, None)
+
+ @patch.object(_channels, "create_team_channel")
+ @patch.object(_channels, "create_team_leader_channel")
+ async def test_result_sending(self, create_leader_channel, create_team_channel):
+ """Should call `ctx.send` when everything goes right."""
+ self.ctx.message.attachments = [MockAttachment()]
+ self.ctx.message.attachments[0].read = AsyncMock()
+ self.ctx.message.attachments[0].read.return_value = TEST_CSV
+
+ team_leaders = MockRole()
+
+ self.guild.get_member.return_value = MockMember()
+
+ self.ctx.guild.create_role = AsyncMock()
+ self.ctx.guild.create_role.return_value = team_leaders
+ self.cog.add_roles = AsyncMock()
+
+ await self.cog.create(self.cog, self.ctx, None)
+
+ create_team_channel.assert_awaited()
+ create_leader_channel.assert_awaited_once_with(
+ self.ctx.guild, team_leaders
+ )
+ self.ctx.send.assert_awaited_once()
+
+ async def test_link_returning_non_200_status(self):
+ """When the URL passed returns a non 200 status, it should send a message informing them."""
+ self.bot.http_session.get.return_value = mock = MagicMock()
+ mock.status = 404
+ await self.cog.create(self.cog, self.ctx, "https://not-a-real-link.com")
+
+ self.ctx.send.assert_awaited_once()
+
+ @patch.object(_channels, "_send_status_update")
+ async def test_category_doesnt_exist(self, update):
+ """Should create a new code jam category."""
+ subtests = (
+ [],
+ [get_mock_category(_channels.MAX_CHANNELS, _channels.CATEGORY_NAME)],
+ [get_mock_category(_channels.MAX_CHANNELS - 2, "other")],
+ )
+
+ for categories in subtests:
+ update.reset_mock()
+ self.guild.reset_mock()
+ self.guild.categories = categories
+
+ with self.subTest(categories=categories):
+ actual_category = await _channels._get_category(self.guild)
+
+ update.assert_called_once()
+ self.guild.create_category_channel.assert_awaited_once()
+ category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"]
+
+ self.assertFalse(category_overwrites[self.guild.default_role].read_messages)
+ self.assertTrue(category_overwrites[self.guild.me].read_messages)
+ self.assertEqual(self.guild.create_category_channel.return_value, actual_category)
+
+ async def test_category_channel_exist(self):
+ """Should not try to create category channel."""
+ expected_category = get_mock_category(_channels.MAX_CHANNELS - 2, _channels.CATEGORY_NAME)
+ self.guild.categories = [
+ get_mock_category(_channels.MAX_CHANNELS - 2, "other"),
+ expected_category,
+ get_mock_category(0, _channels.CATEGORY_NAME),
+ ]
+
+ actual_category = await _channels._get_category(self.guild)
+ self.assertEqual(expected_category, actual_category)
+
+ async def test_channel_overwrites(self):
+ """Should have correct permission overwrites for users and roles."""
+ leader = (MockMember(), True)
+ members = [leader] + [(MockMember(), False) for _ in range(4)]
+ overwrites = _channels._get_overwrites(members, self.guild)
+
+ for member, _ in members:
+ self.assertTrue(overwrites[member].read_messages)
+
+ @patch.object(_channels, "_get_overwrites")
+ @patch.object(_channels, "_get_category")
+ @autospec(_channels, "_add_team_leader_roles", pass_mocks=False)
+ async def test_team_channels_creation(self, get_category, get_overwrites):
+ """Should create a text channel for a team."""
+ team_leaders = MockRole()
+ members = [(MockMember(), True)] + [(MockMember(), False) for _ in range(5)]
+ category = MockCategoryChannel()
+ category.create_text_channel = AsyncMock()
+
+ get_category.return_value = category
+ await _channels.create_team_channel(self.guild, "my-team", members, team_leaders)
+
+ category.create_text_channel.assert_awaited_once_with(
+ "my-team",
+ overwrites=get_overwrites.return_value
+ )
+
+ async def test_jam_roles_adding(self):
+ """Should add team leader role to leader and jam role to every team member."""
+ leader_role = MockRole(name="Team Leader")
+
+ leader = MockMember()
+ members = [(leader, True)] + [(MockMember(), False) for _ in range(4)]
+ await _channels._add_team_leader_roles(members, leader_role)
+
+ leader.add_roles.assert_awaited_once_with(leader_role)
+ for member, is_leader in members:
+ if not is_leader:
+ member.add_roles.assert_not_awaited()
+
+
+class CodeJamSetup(unittest.TestCase):
+ """Test for `setup` function of `CodeJam` cog."""
+
+ def test_setup(self):
+ """Should call `bot.add_cog`."""
+ bot = MockBot()
+ code_jams.setup(bot)
+ bot.add_cog.assert_called_once()
diff --git a/tests/bot/exts/filters/test_antimalware.py b/tests/bot/exts/filters/test_antimalware.py
index 3393c6cdc..06d78de9d 100644
--- a/tests/bot/exts/filters/test_antimalware.py
+++ b/tests/bot/exts/filters/test_antimalware.py
@@ -104,24 +104,39 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(embed.description, antimalware.PY_EMBED_DESCRIPTION)
async def test_txt_file_redirect_embed_description(self):
- """A message containing a .txt file should result in the correct embed."""
- attachment = MockAttachment(filename="python.txt")
- self.message.attachments = [attachment]
- self.message.channel.send = AsyncMock()
- antimalware.TXT_EMBED_DESCRIPTION = Mock()
- antimalware.TXT_EMBED_DESCRIPTION.format.return_value = "test"
-
- await self.cog.on_message(self.message)
- self.message.channel.send.assert_called_once()
- args, kwargs = self.message.channel.send.call_args
- embed = kwargs.pop("embed")
- cmd_channel = self.bot.get_channel(Channels.bot_commands)
+ """A message containing a .txt/.json/.csv file should result in the correct embed."""
+ test_values = (
+ ("text", ".txt"),
+ ("json", ".json"),
+ ("csv", ".csv"),
+ )
- self.assertEqual(embed.description, antimalware.TXT_EMBED_DESCRIPTION.format.return_value)
- antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with(cmd_channel_mention=cmd_channel.mention)
+ for file_name, disallowed_extension in test_values:
+ with self.subTest(file_name=file_name, disallowed_extension=disallowed_extension):
+
+ attachment = MockAttachment(filename=f"{file_name}{disallowed_extension}")
+ self.message.attachments = [attachment]
+ self.message.channel.send = AsyncMock()
+ antimalware.TXT_EMBED_DESCRIPTION = Mock()
+ antimalware.TXT_EMBED_DESCRIPTION.format.return_value = "test"
+
+ await self.cog.on_message(self.message)
+ self.message.channel.send.assert_called_once()
+ args, kwargs = self.message.channel.send.call_args
+ embed = kwargs.pop("embed")
+ cmd_channel = self.bot.get_channel(Channels.bot_commands)
+
+ self.assertEqual(
+ embed.description,
+ antimalware.TXT_EMBED_DESCRIPTION.format.return_value
+ )
+ antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with(
+ blocked_extension=disallowed_extension,
+ cmd_channel_mention=cmd_channel.mention
+ )
async def test_other_disallowed_extension_embed_description(self):
- """Test the description for a non .py/.txt disallowed extension."""
+ """Test the description for a non .py/.txt/.json/.csv disallowed extension."""
attachment = MockAttachment(filename="python.disallowed")
self.message.attachments = [attachment]
self.message.channel.send = AsyncMock()
diff --git a/tests/bot/exts/info/test_help.py b/tests/bot/exts/info/test_help.py
new file mode 100644
index 000000000..604c69671
--- /dev/null
+++ b/tests/bot/exts/info/test_help.py
@@ -0,0 +1,23 @@
+import unittest
+
+import rapidfuzz
+
+from bot.exts.info import help
+from tests.helpers import MockBot, MockContext, autospec
+
+
+class HelpCogTests(unittest.IsolatedAsyncioTestCase):
+ def setUp(self) -> None:
+ """Attach an instance of the cog to the class for tests."""
+ self.bot = MockBot()
+ self.cog = help.Help(self.bot)
+ self.ctx = MockContext(bot=self.bot)
+ self.bot.help_command.context = self.ctx
+
+ @autospec(help.CustomHelpCommand, "get_all_help_choices", return_value={"help"}, pass_mocks=False)
+ async def test_help_fuzzy_matching(self):
+ """Test fuzzy matching of commands when called from help."""
+ result = await self.bot.help_command.command_not_found("holp")
+
+ match = {"help": rapidfuzz.fuzz.ratio("help", "holp")}
+ self.assertEqual(match, result.possible_matches)
diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py
index 770660fe3..d8250befb 100644
--- a/tests/bot/exts/info/test_information.py
+++ b/tests/bot/exts/info/test_information.py
@@ -262,7 +262,6 @@ class UserInfractionHelperMethodTests(unittest.IsolatedAsyncioTestCase):
await self._method_subtests(self.cog.user_nomination_counts, test_values, header)
[email protected]("bot.exts.info.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago"))
@unittest.mock.patch("bot.exts.info.information.constants.MODERATION_CHANNELS", new=[50])
class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
"""Tests for the creation of the `!user` embed."""
@@ -347,7 +346,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(
textwrap.dedent(f"""
- Created: {"1 year ago"}
+ Created: {"<t:1:R>"}
Profile: {user.mention}
ID: {user.id}
""").strip(),
@@ -356,7 +355,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(
textwrap.dedent(f"""
- Joined: {"1 year ago"}
+ Joined: {"<t:1:R>"}
Verified: {"True"}
Roles: &Moderators
""").strip(),
@@ -379,7 +378,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(
textwrap.dedent(f"""
- Created: {"1 year ago"}
+ Created: {"<t:1:R>"}
Profile: {user.mention}
ID: {user.id}
""").strip(),
@@ -388,7 +387,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(
textwrap.dedent(f"""
- Joined: {"1 year ago"}
+ Joined: {"<t:1:R>"}
Roles: &Moderators
""").strip(),
embed.fields[1].value
@@ -508,7 +507,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase):
@unittest.mock.patch("bot.exts.info.information.Information.create_user_embed")
async def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants):
"""Staff members should be able to bypass the bot-commands channel restriction."""
- constants.STAFF_ROLES = [self.moderator_role.id]
+ constants.STAFF_PARTNERS_COMMUNITY_ROLES = [self.moderator_role.id]
ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=200))
await self.cog.user_info(self.cog, ctx)
@@ -520,7 +519,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase):
async def test_moderators_can_target_another_member(self, create_embed, constants):
"""A moderator should be able to use `!user` targeting another user."""
constants.MODERATION_ROLES = [self.moderator_role.id]
- constants.STAFF_ROLES = [self.moderator_role.id]
+ constants.STAFF_PARTNERS_COMMUNITY_ROLES = [self.moderator_role.id]
ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=50))
await self.cog.user_info(self.cog, ctx, self.target)
diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py
index b9d527770..f844a9181 100644
--- a/tests/bot/exts/moderation/infraction/test_infractions.py
+++ b/tests/bot/exts/moderation/infraction/test_infractions.py
@@ -195,7 +195,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):
async def test_voice_unban_user_not_found(self):
"""Should include info to return dict when user was not found from guild."""
self.guild.get_member.return_value = None
- result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar")
+ result = await self.cog.pardon_voice_ban(self.user.id, self.guild)
self.assertEqual(result, {"Info": "User was not found in the guild."})
@patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon")
@@ -206,7 +206,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):
notify_pardon_mock.return_value = True
format_user_mock.return_value = "my-user"
- result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar")
+ result = await self.cog.pardon_voice_ban(self.user.id, self.guild)
self.assertEqual(result, {
"Member": "my-user",
"DM": "Sent"
@@ -221,7 +221,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):
notify_pardon_mock.return_value = False
format_user_mock.return_value = "my-user"
- result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar")
+ result = await self.cog.pardon_voice_ban(self.user.id, self.guild)
self.assertEqual(result, {
"Member": "my-user",
"DM": "**Failed**"
diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py
index ee9ff650c..eb256f1fd 100644
--- a/tests/bot/exts/moderation/infraction/test_utils.py
+++ b/tests/bot/exts/moderation/infraction/test_utils.py
@@ -94,8 +94,8 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
test_case = namedtuple("test_case", ["get_return_value", "expected_output", "infraction_nr", "send_msg"])
test_cases = [
test_case([], None, None, True),
- test_case([{"id": 123987}], {"id": 123987}, "123987", False),
- test_case([{"id": 123987}], {"id": 123987}, "123987", True)
+ test_case([{"id": 123987, "type": "ban"}], {"id": 123987, "type": "ban"}, "123987", False),
+ test_case([{"id": 123987, "type": "ban"}], {"id": 123987, "type": "ban"}, "123987", True)
]
for case in test_cases:
@@ -213,7 +213,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
type="Mute",
expires="N/A",
reason="foo bar" * 4000
- )[:2045] + "...",
+ )[:4093] + "...",
colour=Colours.soft_red,
url=utils.RULES_URL
).set_author(
diff --git a/tests/bot/exts/moderation/test_modlog.py b/tests/bot/exts/moderation/test_modlog.py
index f8f142484..79e04837d 100644
--- a/tests/bot/exts/moderation/test_modlog.py
+++ b/tests/bot/exts/moderation/test_modlog.py
@@ -25,5 +25,5 @@ class ModLogTests(unittest.IsolatedAsyncioTestCase):
)
embed = self.channel.send.call_args[1]["embed"]
self.assertEqual(
- embed.description, ("foo bar" * 3000)[:2045] + "..."
+ embed.description, ("foo bar" * 3000)[:4093] + "..."
)
diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py
index fa5fc9e81..59a5893ef 100644
--- a/tests/bot/exts/moderation/test_silence.py
+++ b/tests/bot/exts/moderation/test_silence.py
@@ -1,15 +1,26 @@
import asyncio
+import itertools
import unittest
from datetime import datetime, timezone
+from typing import List, Tuple
from unittest import mock
-from unittest.mock import Mock
+from unittest.mock import AsyncMock, Mock
from async_rediscache import RedisSession
from discord import PermissionOverwrite
-from bot.constants import Channels, Guild, Roles
+from bot.constants import Channels, Guild, MODERATION_ROLES, Roles
from bot.exts.moderation import silence
-from tests.helpers import MockBot, MockContext, MockTextChannel, autospec
+from tests.helpers import (
+ MockBot,
+ MockContext,
+ MockGuild,
+ MockMember,
+ MockRole,
+ MockTextChannel,
+ MockVoiceChannel,
+ autospec
+)
redis_session = None
redis_loop = asyncio.get_event_loop()
@@ -149,7 +160,7 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase):
self.assertTrue(self.cog._init_task.cancelled())
@autospec("discord.ext.commands", "has_any_role")
- @mock.patch.object(silence, "MODERATION_ROLES", new=(1, 2, 3))
+ @mock.patch.object(silence.constants, "MODERATION_ROLES", new=(1, 2, 3))
async def test_cog_check(self, role_check):
"""Role check was called with `MODERATION_ROLES`"""
ctx = MockContext()
@@ -159,6 +170,170 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase):
role_check.assert_called_once_with(*(1, 2, 3))
role_check.return_value.predicate.assert_awaited_once_with(ctx)
+ async def test_force_voice_sync(self):
+ """Tests the _force_voice_sync helper function."""
+ await self.cog._async_init()
+
+ # Create a regular member, and one member for each of the moderation roles
+ moderation_members = [MockMember(roles=[MockRole(id=role)]) for role in MODERATION_ROLES]
+ members = [MockMember(), *moderation_members]
+
+ channel = MockVoiceChannel(members=members)
+
+ await self.cog._force_voice_sync(channel)
+ for member in members:
+ if member in moderation_members:
+ member.move_to.assert_not_called()
+ else:
+ self.assertEqual(member.move_to.call_count, 2)
+ calls = member.move_to.call_args_list
+
+ # Tests that the member was moved to the afk channel, and back.
+ self.assertEqual((channel.guild.afk_channel,), calls[0].args)
+ self.assertEqual((channel,), calls[1].args)
+
+ async def test_force_voice_sync_no_channel(self):
+ """Test to ensure _force_voice_sync can create its own voice channel if one is not available."""
+ await self.cog._async_init()
+
+ channel = MockVoiceChannel(guild=MockGuild(afk_channel=None))
+ new_channel = MockVoiceChannel(delete=AsyncMock())
+ channel.guild.create_voice_channel.return_value = new_channel
+
+ await self.cog._force_voice_sync(channel)
+
+ # Check channel creation
+ overwrites = {
+ channel.guild.default_role: PermissionOverwrite(speak=False, connect=False, view_channel=False)
+ }
+ channel.guild.create_voice_channel.assert_awaited_once_with("mute-temp", overwrites=overwrites)
+
+ # Check bot deleted channel
+ new_channel.delete.assert_awaited_once()
+
+ async def test_voice_kick(self):
+ """Test to ensure kick function can remove all members from a voice channel."""
+ await self.cog._async_init()
+
+ # Create a regular member, and one member for each of the moderation roles
+ moderation_members = [MockMember(roles=[MockRole(id=role)]) for role in MODERATION_ROLES]
+ members = [MockMember(), *moderation_members]
+
+ channel = MockVoiceChannel(members=members)
+ await self.cog._kick_voice_members(channel)
+
+ for member in members:
+ if member in moderation_members:
+ member.move_to.assert_not_called()
+ else:
+ self.assertEqual((None,), member.move_to.call_args_list[0].args)
+
+ @staticmethod
+ def create_erroneous_members() -> Tuple[List[MockMember], List[MockMember]]:
+ """
+ Helper method to generate a list of members that error out on move_to call.
+
+ Returns the list of erroneous members,
+ as well as a list of regular and erroneous members combined, in that order.
+ """
+ erroneous_member = MockMember(move_to=AsyncMock(side_effect=Exception()))
+ members = [MockMember(), erroneous_member]
+
+ return erroneous_member, members
+
+ async def test_kick_move_to_error(self):
+ """Test to ensure move_to gets called on all members during kick, even if some fail."""
+ await self.cog._async_init()
+ _, members = self.create_erroneous_members()
+
+ await self.cog._kick_voice_members(MockVoiceChannel(members=members))
+ for member in members:
+ member.move_to.assert_awaited_once()
+
+ async def test_sync_move_to_error(self):
+ """Test to ensure move_to gets called on all members during sync, even if some fail."""
+ await self.cog._async_init()
+ failing_member, members = self.create_erroneous_members()
+
+ await self.cog._force_voice_sync(MockVoiceChannel(members=members))
+ for member in members:
+ self.assertEqual(member.move_to.call_count, 1 if member == failing_member else 2)
+
+
+class SilenceArgumentParserTests(unittest.IsolatedAsyncioTestCase):
+ """Tests for the silence argument parser utility function."""
+
+ def setUp(self):
+ self.bot = MockBot()
+ self.cog = silence.Silence(self.bot)
+ self.cog._init_task = asyncio.Future()
+ self.cog._init_task.set_result(None)
+
+ @autospec(silence.Silence, "send_message", pass_mocks=False)
+ @autospec(silence.Silence, "_set_silence_overwrites", return_value=False, pass_mocks=False)
+ @autospec(silence.Silence, "parse_silence_args")
+ async def test_command(self, parser_mock):
+ """Test that the command passes in the correct arguments for different calls."""
+ test_cases = (
+ (),
+ (15, ),
+ (MockTextChannel(),),
+ (MockTextChannel(), 15),
+ )
+
+ ctx = MockContext()
+ parser_mock.return_value = (ctx.channel, 10)
+
+ for case in test_cases:
+ with self.subTest("Test command converters", args=case):
+ await self.cog.silence.callback(self.cog, ctx, *case)
+
+ try:
+ first_arg = case[0]
+ except IndexError:
+ # Default value when the first argument is not passed
+ first_arg = None
+
+ try:
+ second_arg = case[1]
+ except IndexError:
+ # Default value when the second argument is not passed
+ second_arg = 10
+
+ parser_mock.assert_called_with(ctx, first_arg, second_arg)
+
+ async def test_no_arguments(self):
+ """Test the parser when no arguments are passed to the command."""
+ ctx = MockContext()
+ channel, duration = self.cog.parse_silence_args(ctx, None, 10)
+
+ self.assertEqual(ctx.channel, channel)
+ self.assertEqual(10, duration)
+
+ async def test_channel_only(self):
+ """Test the parser when just the channel argument is passed."""
+ expected_channel = MockTextChannel()
+ actual_channel, duration = self.cog.parse_silence_args(MockContext(), expected_channel, 10)
+
+ self.assertEqual(expected_channel, actual_channel)
+ self.assertEqual(10, duration)
+
+ async def test_duration_only(self):
+ """Test the parser when just the duration argument is passed."""
+ ctx = MockContext()
+ channel, duration = self.cog.parse_silence_args(ctx, 15, 10)
+
+ self.assertEqual(ctx.channel, channel)
+ self.assertEqual(15, duration)
+
+ async def test_all_args(self):
+ """Test the parser when both channel and duration are passed."""
+ expected_channel = MockTextChannel()
+ actual_channel, duration = self.cog.parse_silence_args(MockContext(), expected_channel, 15)
+
+ self.assertEqual(expected_channel, actual_channel)
+ self.assertEqual(15, duration)
+
@autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False)
class RescheduleTests(unittest.IsolatedAsyncioTestCase):
@@ -235,6 +410,16 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase):
self.cog.notifier.add_channel.assert_not_called()
+def voice_sync_helper(function):
+ """Helper wrapper to test the sync and kick functions for voice channels."""
+ @autospec(silence.Silence, "_force_voice_sync", "_kick_voice_members", "_set_silence_overwrites")
+ async def inner(self, sync, kick, overwrites):
+ overwrites.return_value = True
+ await function(self, MockContext(), sync, kick)
+
+ return inner
+
+
@autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False)
class SilenceTests(unittest.IsolatedAsyncioTestCase):
"""Tests for the silence command and its related helper methods."""
@@ -242,7 +427,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase):
@autospec(silence.Silence, "_reschedule", pass_mocks=False)
@autospec(silence, "Scheduler", "SilenceNotifier", pass_mocks=False)
def setUp(self) -> None:
- self.bot = MockBot()
+ self.bot = MockBot(get_channel=lambda _: MockTextChannel())
self.cog = silence.Silence(self.bot)
self.cog._init_task = asyncio.Future()
self.cog._init_task.set_result(None)
@@ -252,56 +437,127 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase):
asyncio.run(self.cog._async_init()) # Populate instance attributes.
- self.channel = MockTextChannel()
- self.overwrite = PermissionOverwrite(stream=True, send_messages=True, add_reactions=False)
- self.channel.overwrites_for.return_value = self.overwrite
+ self.text_channel = MockTextChannel()
+ self.text_overwrite = PermissionOverwrite(send_messages=True, add_reactions=False)
+ self.text_channel.overwrites_for.return_value = self.text_overwrite
+
+ self.voice_channel = MockVoiceChannel()
+ self.voice_overwrite = PermissionOverwrite(connect=True, speak=True)
+ self.voice_channel.overwrites_for.return_value = self.voice_overwrite
async def test_sent_correct_message(self):
- """Appropriate failure/success message was sent by the command."""
+ """Appropriate failure/success message was sent by the command to the correct channel."""
+ # The following test tuples are made up of:
+ # duration, expected message, and the success of the _set_silence_overwrites function
test_cases = (
(0.0001, silence.MSG_SILENCE_SUCCESS.format(duration=0.0001), True,),
(None, silence.MSG_SILENCE_PERMANENT, True,),
(5, silence.MSG_SILENCE_FAIL, False,),
)
- for duration, message, was_silenced in test_cases:
- ctx = MockContext()
+
+ targets = (MockTextChannel(), MockVoiceChannel(), None)
+
+ for (duration, message, was_silenced), target in itertools.product(test_cases, targets):
with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=was_silenced):
- with self.subTest(was_silenced=was_silenced, message=message, duration=duration):
- await self.cog.silence.callback(self.cog, ctx, duration)
- ctx.send.assert_called_once_with(message)
+ with self.subTest(was_silenced=was_silenced, target=target, message=message):
+ with mock.patch.object(self.cog, "send_message") as send_message:
+ ctx = MockContext()
+ await self.cog.silence.callback(self.cog, ctx, target, duration)
+ send_message.assert_called_once_with(
+ message,
+ ctx.channel,
+ target or ctx.channel,
+ alert_target=was_silenced
+ )
+
+ @voice_sync_helper
+ async def test_sync_called(self, ctx, sync, kick):
+ """Tests if silence command calls sync on a voice channel."""
+ channel = MockVoiceChannel()
+ await self.cog.silence.callback(self.cog, ctx, channel, 10, kick=False)
+
+ sync.assert_awaited_once_with(self.cog, channel)
+ kick.assert_not_called()
+
+ @voice_sync_helper
+ async def test_kick_called(self, ctx, sync, kick):
+ """Tests if silence command calls kick on a voice channel."""
+ channel = MockVoiceChannel()
+ await self.cog.silence.callback(self.cog, ctx, channel, 10, kick=True)
+
+ kick.assert_awaited_once_with(channel)
+ sync.assert_not_called()
+
+ @voice_sync_helper
+ async def test_sync_not_called(self, ctx, sync, kick):
+ """Tests that silence command does not call sync on a text channel."""
+ channel = MockTextChannel()
+ await self.cog.silence.callback(self.cog, ctx, channel, 10, kick=False)
+
+ sync.assert_not_called()
+ kick.assert_not_called()
+
+ @voice_sync_helper
+ async def test_kick_not_called(self, ctx, sync, kick):
+ """Tests that silence command does not call kick on a text channel."""
+ channel = MockTextChannel()
+ await self.cog.silence.callback(self.cog, ctx, channel, 10, kick=True)
+
+ sync.assert_not_called()
+ kick.assert_not_called()
async def test_skipped_already_silenced(self):
"""Permissions were not set and `False` was returned for an already silenced channel."""
subtests = (
- (False, PermissionOverwrite(send_messages=False, add_reactions=False)),
- (True, PermissionOverwrite(send_messages=True, add_reactions=True)),
- (True, PermissionOverwrite(send_messages=False, add_reactions=False)),
+ (False, MockTextChannel(), PermissionOverwrite(send_messages=False, add_reactions=False)),
+ (True, MockTextChannel(), PermissionOverwrite(send_messages=True, add_reactions=True)),
+ (True, MockTextChannel(), PermissionOverwrite(send_messages=False, add_reactions=False)),
+ (False, MockVoiceChannel(), PermissionOverwrite(connect=False, speak=False)),
+ (True, MockVoiceChannel(), PermissionOverwrite(connect=True, speak=True)),
+ (True, MockVoiceChannel(), PermissionOverwrite(connect=False, speak=False)),
)
- for contains, overwrite in subtests:
- with self.subTest(contains=contains, overwrite=overwrite):
+ for contains, channel, overwrite in subtests:
+ with self.subTest(contains=contains, is_text=isinstance(channel, MockTextChannel), overwrite=overwrite):
self.cog.scheduler.__contains__.return_value = contains
- channel = MockTextChannel()
channel.overwrites_for.return_value = overwrite
self.assertFalse(await self.cog._set_silence_overwrites(channel))
channel.set_permissions.assert_not_called()
- async def test_silenced_channel(self):
+ async def test_silenced_text_channel(self):
"""Channel had `send_message` and `add_reactions` permissions revoked for verified role."""
- self.assertTrue(await self.cog._set_silence_overwrites(self.channel))
- self.assertFalse(self.overwrite.send_messages)
- self.assertFalse(self.overwrite.add_reactions)
- self.channel.set_permissions.assert_awaited_once_with(
+ self.assertTrue(await self.cog._set_silence_overwrites(self.text_channel))
+ self.assertFalse(self.text_overwrite.send_messages)
+ self.assertFalse(self.text_overwrite.add_reactions)
+ self.text_channel.set_permissions.assert_awaited_once_with(
self.cog._everyone_role,
- overwrite=self.overwrite
+ overwrite=self.text_overwrite
)
- async def test_preserved_other_overwrites(self):
- """Channel's other unrelated overwrites were not changed."""
- prev_overwrite_dict = dict(self.overwrite)
- await self.cog._set_silence_overwrites(self.channel)
- new_overwrite_dict = dict(self.overwrite)
+ async def test_silenced_voice_channel_speak(self):
+ """Channel had `speak` permissions revoked for verified role."""
+ self.assertTrue(await self.cog._set_silence_overwrites(self.voice_channel))
+ self.assertFalse(self.voice_overwrite.speak)
+ self.voice_channel.set_permissions.assert_awaited_once_with(
+ self.cog._verified_voice_role,
+ overwrite=self.voice_overwrite
+ )
+
+ async def test_silenced_voice_channel_full(self):
+ """Channel had `speak` and `connect` permissions revoked for verified role."""
+ self.assertTrue(await self.cog._set_silence_overwrites(self.voice_channel, kick=True))
+ self.assertFalse(self.voice_overwrite.speak or self.voice_overwrite.connect)
+ self.voice_channel.set_permissions.assert_awaited_once_with(
+ self.cog._verified_voice_role,
+ overwrite=self.voice_overwrite
+ )
+
+ async def test_preserved_other_overwrites_text(self):
+ """Channel's other unrelated overwrites were not changed for a text channel mute."""
+ prev_overwrite_dict = dict(self.text_overwrite)
+ await self.cog._set_silence_overwrites(self.text_channel)
+ new_overwrite_dict = dict(self.text_overwrite)
# Remove 'send_messages' & 'add_reactions' keys because they were changed by the method.
del prev_overwrite_dict['send_messages']
@@ -311,6 +567,20 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase):
self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict)
+ async def test_preserved_other_overwrites_voice(self):
+ """Channel's other unrelated overwrites were not changed for a voice channel mute."""
+ prev_overwrite_dict = dict(self.voice_overwrite)
+ await self.cog._set_silence_overwrites(self.voice_channel)
+ new_overwrite_dict = dict(self.voice_overwrite)
+
+ # Remove 'connect' & 'speak' keys because they were changed by the method.
+ del prev_overwrite_dict['connect']
+ del prev_overwrite_dict['speak']
+ del new_overwrite_dict['connect']
+ del new_overwrite_dict['speak']
+
+ self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict)
+
async def test_temp_not_added_to_notifier(self):
"""Channel was not added to notifier if a duration was set for the silence."""
with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=True):
@@ -320,7 +590,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase):
async def test_indefinite_added_to_notifier(self):
"""Channel was added to notifier if a duration was not set for the silence."""
with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=True):
- await self.cog.silence.callback(self.cog, MockContext(), None)
+ await self.cog.silence.callback(self.cog, MockContext(), None, None)
self.cog.notifier.add_channel.assert_called_once()
async def test_silenced_not_added_to_notifier(self):
@@ -332,8 +602,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase):
async def test_cached_previous_overwrites(self):
"""Channel's previous overwrites were cached."""
overwrite_json = '{"send_messages": true, "add_reactions": false}'
- await self.cog._set_silence_overwrites(self.channel)
- self.cog.previous_overwrites.set.assert_called_once_with(self.channel.id, overwrite_json)
+ await self.cog._set_silence_overwrites(self.text_channel)
+ self.cog.previous_overwrites.set.assert_awaited_once_with(self.text_channel.id, overwrite_json)
@autospec(silence, "datetime")
async def test_cached_unsilence_time(self, datetime_mock):
@@ -343,7 +613,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase):
timestamp = now_timestamp + duration * 60
datetime_mock.now.return_value = datetime.fromtimestamp(now_timestamp, tz=timezone.utc)
- ctx = MockContext(channel=self.channel)
+ ctx = MockContext(channel=self.text_channel)
await self.cog.silence.callback(self.cog, ctx, duration)
self.cog.unsilence_timestamps.set.assert_awaited_once_with(ctx.channel.id, timestamp)
@@ -351,26 +621,33 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase):
async def test_cached_indefinite_time(self):
"""A value of -1 was cached for a permanent silence."""
- ctx = MockContext(channel=self.channel)
- await self.cog.silence.callback(self.cog, ctx, None)
+ ctx = MockContext(channel=self.text_channel)
+ await self.cog.silence.callback(self.cog, ctx, None, None)
self.cog.unsilence_timestamps.set.assert_awaited_once_with(ctx.channel.id, -1)
async def test_scheduled_task(self):
"""An unsilence task was scheduled."""
- ctx = MockContext(channel=self.channel, invoke=mock.MagicMock())
+ ctx = MockContext(channel=self.text_channel, invoke=mock.MagicMock())
await self.cog.silence.callback(self.cog, ctx, 5)
args = (300, ctx.channel.id, ctx.invoke.return_value)
self.cog.scheduler.schedule_later.assert_called_once_with(*args)
- ctx.invoke.assert_called_once_with(self.cog.unsilence)
+ ctx.invoke.assert_called_once_with(self.cog.unsilence, channel=ctx.channel)
async def test_permanent_not_scheduled(self):
"""A task was not scheduled for a permanent silence."""
- ctx = MockContext(channel=self.channel)
- await self.cog.silence.callback(self.cog, ctx, None)
+ ctx = MockContext(channel=self.text_channel)
+ await self.cog.silence.callback(self.cog, ctx, None, None)
self.cog.scheduler.schedule_later.assert_not_called()
+ async def test_indefinite_silence(self):
+ """Test silencing a channel forever."""
+ with mock.patch.object(self.cog, "_schedule_unsilence") as unsilence:
+ ctx = MockContext(channel=self.text_channel)
+ await self.cog.silence.callback(self.cog, ctx, -1)
+ unsilence.assert_awaited_once_with(ctx, ctx.channel, None)
+
@autospec(silence.Silence, "unsilence_timestamps", pass_mocks=False)
class UnsilenceTests(unittest.IsolatedAsyncioTestCase):
@@ -391,9 +668,13 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase):
self.cog.scheduler.__contains__.return_value = True
overwrites_cache.get.return_value = '{"send_messages": true, "add_reactions": false}'
- self.channel = MockTextChannel()
- self.overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False)
- self.channel.overwrites_for.return_value = self.overwrite
+ self.text_channel = MockTextChannel()
+ self.text_overwrite = PermissionOverwrite(send_messages=False, add_reactions=False)
+ self.text_channel.overwrites_for.return_value = self.text_overwrite
+
+ self.voice_channel = MockVoiceChannel()
+ self.voice_overwrite = PermissionOverwrite(connect=True, speak=True)
+ self.voice_channel.overwrites_for.return_value = self.voice_overwrite
async def test_sent_correct_message(self):
"""Appropriate failure/success message was sent by the command."""
@@ -401,88 +682,128 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase):
test_cases = (
(True, silence.MSG_UNSILENCE_SUCCESS, unsilenced_overwrite),
(False, silence.MSG_UNSILENCE_FAIL, unsilenced_overwrite),
- (False, silence.MSG_UNSILENCE_MANUAL, self.overwrite),
+ (False, silence.MSG_UNSILENCE_MANUAL, self.text_overwrite),
(False, silence.MSG_UNSILENCE_MANUAL, PermissionOverwrite(send_messages=False)),
(False, silence.MSG_UNSILENCE_MANUAL, PermissionOverwrite(add_reactions=False)),
)
- for was_unsilenced, message, overwrite in test_cases:
+
+ targets = (None, MockTextChannel())
+
+ for (was_unsilenced, message, overwrite), target in itertools.product(test_cases, targets):
ctx = MockContext()
- with self.subTest(was_unsilenced=was_unsilenced, message=message, overwrite=overwrite):
- with mock.patch.object(self.cog, "_unsilence", return_value=was_unsilenced):
- ctx.channel.overwrites_for.return_value = overwrite
- await self.cog.unsilence.callback(self.cog, ctx)
- ctx.channel.send.assert_called_once_with(message)
+ ctx.channel.overwrites_for.return_value = overwrite
+ if target:
+ target.overwrites_for.return_value = overwrite
+
+ with mock.patch.object(self.cog, "_unsilence", return_value=was_unsilenced):
+ with mock.patch.object(self.cog, "send_message") as send_message:
+ with self.subTest(was_unsilenced=was_unsilenced, overwrite=overwrite, target=target):
+ await self.cog.unsilence.callback(self.cog, ctx, channel=target)
+
+ call_args = (message, ctx.channel, target or ctx.channel)
+ send_message.assert_awaited_once_with(*call_args, alert_target=was_unsilenced)
async def test_skipped_already_unsilenced(self):
"""Permissions were not set and `False` was returned for an already unsilenced channel."""
self.cog.scheduler.__contains__.return_value = False
self.cog.previous_overwrites.get.return_value = None
- channel = MockTextChannel()
- self.assertFalse(await self.cog._unsilence(channel))
- channel.set_permissions.assert_not_called()
+ for channel in (MockVoiceChannel(), MockTextChannel()):
+ with self.subTest(channel=channel):
+ self.assertFalse(await self.cog._unsilence(channel))
+ channel.set_permissions.assert_not_called()
- async def test_restored_overwrites(self):
- """Channel's `send_message` and `add_reactions` overwrites were restored."""
- await self.cog._unsilence(self.channel)
- self.channel.set_permissions.assert_awaited_once_with(
+ async def test_restored_overwrites_text(self):
+ """Text channel's `send_message` and `add_reactions` overwrites were restored."""
+ await self.cog._unsilence(self.text_channel)
+ self.text_channel.set_permissions.assert_awaited_once_with(
self.cog._everyone_role,
- overwrite=self.overwrite,
+ overwrite=self.text_overwrite,
+ )
+
+ # Recall that these values are determined by the fixture.
+ self.assertTrue(self.text_overwrite.send_messages)
+ self.assertFalse(self.text_overwrite.add_reactions)
+
+ async def test_restored_overwrites_voice(self):
+ """Voice channel's `connect` and `speak` overwrites were restored."""
+ await self.cog._unsilence(self.voice_channel)
+ self.voice_channel.set_permissions.assert_awaited_once_with(
+ self.cog._verified_voice_role,
+ overwrite=self.voice_overwrite,
)
# Recall that these values are determined by the fixture.
- self.assertTrue(self.overwrite.send_messages)
- self.assertFalse(self.overwrite.add_reactions)
+ self.assertTrue(self.voice_overwrite.connect)
+ self.assertTrue(self.voice_overwrite.speak)
- async def test_cache_miss_used_default_overwrites(self):
- """Both overwrites were set to None due previous values not being found in the cache."""
+ async def test_cache_miss_used_default_overwrites_text(self):
+ """Text overwrites were set to None due previous values not being found in the cache."""
self.cog.previous_overwrites.get.return_value = None
- await self.cog._unsilence(self.channel)
- self.channel.set_permissions.assert_awaited_once_with(
+ await self.cog._unsilence(self.text_channel)
+ self.text_channel.set_permissions.assert_awaited_once_with(
self.cog._everyone_role,
- overwrite=self.overwrite,
+ overwrite=self.text_overwrite,
+ )
+
+ self.assertIsNone(self.text_overwrite.send_messages)
+ self.assertIsNone(self.text_overwrite.add_reactions)
+
+ async def test_cache_miss_used_default_overwrites_voice(self):
+ """Voice overwrites were set to None due previous values not being found in the cache."""
+ self.cog.previous_overwrites.get.return_value = None
+
+ await self.cog._unsilence(self.voice_channel)
+ self.voice_channel.set_permissions.assert_awaited_once_with(
+ self.cog._verified_voice_role,
+ overwrite=self.voice_overwrite,
)
- self.assertIsNone(self.overwrite.send_messages)
- self.assertIsNone(self.overwrite.add_reactions)
+ self.assertIsNone(self.voice_overwrite.connect)
+ self.assertIsNone(self.voice_overwrite.speak)
- async def test_cache_miss_sent_mod_alert(self):
- """A message was sent to the mod alerts channel."""
+ async def test_cache_miss_sent_mod_alert_text(self):
+ """A message was sent to the mod alerts channel upon muting a text channel."""
self.cog.previous_overwrites.get.return_value = None
+ await self.cog._unsilence(self.text_channel)
+ self.cog._mod_alerts_channel.send.assert_awaited_once()
- await self.cog._unsilence(self.channel)
+ async def test_cache_miss_sent_mod_alert_voice(self):
+ """A message was sent to the mod alerts channel upon muting a voice channel."""
+ self.cog.previous_overwrites.get.return_value = None
+ await self.cog._unsilence(MockVoiceChannel())
self.cog._mod_alerts_channel.send.assert_awaited_once()
async def test_removed_notifier(self):
"""Channel was removed from `notifier`."""
- await self.cog._unsilence(self.channel)
- self.cog.notifier.remove_channel.assert_called_once_with(self.channel)
+ await self.cog._unsilence(self.text_channel)
+ self.cog.notifier.remove_channel.assert_called_once_with(self.text_channel)
async def test_deleted_cached_overwrite(self):
"""Channel was deleted from the overwrites cache."""
- await self.cog._unsilence(self.channel)
- self.cog.previous_overwrites.delete.assert_awaited_once_with(self.channel.id)
+ await self.cog._unsilence(self.text_channel)
+ self.cog.previous_overwrites.delete.assert_awaited_once_with(self.text_channel.id)
async def test_deleted_cached_time(self):
"""Channel was deleted from the timestamp cache."""
- await self.cog._unsilence(self.channel)
- self.cog.unsilence_timestamps.delete.assert_awaited_once_with(self.channel.id)
+ await self.cog._unsilence(self.text_channel)
+ self.cog.unsilence_timestamps.delete.assert_awaited_once_with(self.text_channel.id)
async def test_cancelled_task(self):
"""The scheduled unsilence task should be cancelled."""
- await self.cog._unsilence(self.channel)
- self.cog.scheduler.cancel.assert_called_once_with(self.channel.id)
+ await self.cog._unsilence(self.text_channel)
+ self.cog.scheduler.cancel.assert_called_once_with(self.text_channel.id)
- async def test_preserved_other_overwrites(self):
- """Channel's other unrelated overwrites were not changed, including cache misses."""
+ async def test_preserved_other_overwrites_text(self):
+ """Text channel's other unrelated overwrites were not changed, including cache misses."""
for overwrite_json in ('{"send_messages": true, "add_reactions": null}', None):
with self.subTest(overwrite_json=overwrite_json):
self.cog.previous_overwrites.get.return_value = overwrite_json
- prev_overwrite_dict = dict(self.overwrite)
- await self.cog._unsilence(self.channel)
- new_overwrite_dict = dict(self.overwrite)
+ prev_overwrite_dict = dict(self.text_overwrite)
+ await self.cog._unsilence(self.text_channel)
+ new_overwrite_dict = dict(self.text_overwrite)
# Remove these keys because they were modified by the unsilence.
del prev_overwrite_dict['send_messages']
@@ -491,3 +812,114 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase):
del new_overwrite_dict['add_reactions']
self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict)
+
+ async def test_preserved_other_overwrites_voice(self):
+ """Voice channel's other unrelated overwrites were not changed, including cache misses."""
+ for overwrite_json in ('{"connect": true, "speak": true}', None):
+ with self.subTest(overwrite_json=overwrite_json):
+ self.cog.previous_overwrites.get.return_value = overwrite_json
+
+ prev_overwrite_dict = dict(self.voice_overwrite)
+ await self.cog._unsilence(self.voice_channel)
+ new_overwrite_dict = dict(self.voice_overwrite)
+
+ # Remove these keys because they were modified by the unsilence.
+ del prev_overwrite_dict['connect']
+ del prev_overwrite_dict['speak']
+ del new_overwrite_dict['connect']
+ del new_overwrite_dict['speak']
+
+ self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict)
+
+ async def test_unsilence_role(self):
+ """Tests unsilence_wrapper applies permission to the correct role."""
+ test_cases = (
+ (MockTextChannel(), self.cog.bot.get_guild(Guild.id).default_role),
+ (MockVoiceChannel(), self.cog.bot.get_guild(Guild.id).get_role(Roles.voice_verified))
+ )
+
+ for channel, role in test_cases:
+ with self.subTest(channel=channel, role=role):
+ await self.cog._unsilence_wrapper(channel, MockContext())
+ channel.overwrites_for.assert_called_with(role)
+
+
+class SendMessageTests(unittest.IsolatedAsyncioTestCase):
+ """Unittests for the send message helper function."""
+
+ def setUp(self) -> None:
+ self.bot = MockBot()
+ self.cog = silence.Silence(self.bot)
+
+ self.text_channels = [MockTextChannel() for _ in range(2)]
+ self.bot.get_channel.return_value = self.text_channels[1]
+
+ self.voice_channel = MockVoiceChannel()
+
+ async def test_send_to_channel(self):
+ """Tests a basic case for the send function."""
+ message = "Test basic message."
+ await self.cog.send_message(message, *self.text_channels, alert_target=False)
+
+ self.text_channels[0].send.assert_awaited_once_with(message)
+ self.text_channels[1].send.assert_not_called()
+
+ async def test_send_to_multiple_channels(self):
+ """Tests sending messages to two channels."""
+ message = "Test basic message."
+ await self.cog.send_message(message, *self.text_channels, alert_target=True)
+
+ self.text_channels[0].send.assert_awaited_once_with(message)
+ self.text_channels[1].send.assert_awaited_once_with(message)
+
+ async def test_duration_replacement(self):
+ """Tests that the channel name was set correctly for one target channel."""
+ message = "Current. The following should be replaced: {channel}."
+ await self.cog.send_message(message, *self.text_channels, alert_target=False)
+
+ updated_message = message.format(channel=self.text_channels[0].mention)
+ self.text_channels[0].send.assert_awaited_once_with(updated_message)
+ self.text_channels[1].send.assert_not_called()
+
+ async def test_name_replacement_multiple_channels(self):
+ """Tests that the channel name was set correctly for two channels."""
+ message = "Current. The following should be replaced: {channel}."
+ await self.cog.send_message(message, *self.text_channels, alert_target=True)
+
+ self.text_channels[0].send.assert_awaited_once_with(message.format(channel=self.text_channels[0].mention))
+ self.text_channels[1].send.assert_awaited_once_with(message.format(channel="current channel"))
+
+ async def test_silence_voice(self):
+ """Tests that the correct message was sent when a voice channel is muted without alerting."""
+ message = "This should show up just here."
+ await self.cog.send_message(message, self.text_channels[0], self.voice_channel, alert_target=False)
+ self.text_channels[0].send.assert_awaited_once_with(message)
+ self.text_channels[1].send.assert_not_called()
+
+ async def test_silence_voice_alert(self):
+ """Tests that the correct message was sent when a voice channel is muted with alerts."""
+ with unittest.mock.patch.object(silence, "VOICE_CHANNELS") as mock_voice_channels:
+ mock_voice_channels.get.return_value = self.text_channels[1].id
+
+ message = "This should show up as {channel}."
+ await self.cog.send_message(message, self.text_channels[0], self.voice_channel, alert_target=True)
+
+ updated_message = message.format(channel=self.voice_channel.mention)
+ self.text_channels[0].send.assert_awaited_once_with(updated_message)
+ self.text_channels[1].send.assert_awaited_once_with(updated_message)
+
+ mock_voice_channels.get.assert_called_once_with(self.voice_channel.id)
+
+ async def test_silence_voice_sibling_channel(self):
+ """Tests silencing a voice channel from the related text channel."""
+ with unittest.mock.patch.object(silence, "VOICE_CHANNELS") as mock_voice_channels:
+ mock_voice_channels.get.return_value = self.text_channels[1].id
+
+ message = "This should show up as {channel}."
+ await self.cog.send_message(message, self.text_channels[1], self.voice_channel, alert_target=True)
+
+ updated_message = message.format(channel=self.voice_channel.mention)
+ self.text_channels[1].send.assert_awaited_once_with(updated_message)
+
+ mock_voice_channels.get.assert_called_once_with(self.voice_channel.id)
+ self.bot.get_channel.assert_called_once_with(self.text_channels[1].id)
diff --git a/tests/bot/exts/utils/test_jams.py b/tests/bot/exts/utils/test_jams.py
deleted file mode 100644
index 85d6a1173..000000000
--- a/tests/bot/exts/utils/test_jams.py
+++ /dev/null
@@ -1,171 +0,0 @@
-import unittest
-from unittest.mock import AsyncMock, MagicMock, create_autospec
-
-from discord import CategoryChannel
-
-from bot.constants import Roles
-from bot.exts.utils import jams
-from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockTextChannel
-
-
-def get_mock_category(channel_count: int, name: str) -> CategoryChannel:
- """Return a mocked code jam category."""
- category = create_autospec(CategoryChannel, spec_set=True, instance=True)
- category.name = name
- category.channels = [MockTextChannel() for _ in range(channel_count)]
-
- return category
-
-
-class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase):
- """Tests for `createteam` command."""
-
- def setUp(self):
- self.bot = MockBot()
- self.admin_role = MockRole(name="Admins", id=Roles.admins)
- self.command_user = MockMember([self.admin_role])
- self.guild = MockGuild([self.admin_role])
- self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild)
- self.cog = jams.CodeJams(self.bot)
-
- async def test_too_small_amount_of_team_members_passed(self):
- """Should `ctx.send` and exit early when too small amount of members."""
- for case in (1, 2):
- with self.subTest(amount_of_members=case):
- self.cog.create_channels = AsyncMock()
- self.cog.add_roles = AsyncMock()
-
- self.ctx.reset_mock()
- members = (MockMember() for _ in range(case))
- await self.cog.createteam(self.cog, self.ctx, "foo", members)
-
- self.ctx.send.assert_awaited_once()
- self.cog.create_channels.assert_not_awaited()
- self.cog.add_roles.assert_not_awaited()
-
- async def test_duplicate_members_provided(self):
- """Should `ctx.send` and exit early because duplicate members provided and total there is only 1 member."""
- self.cog.create_channels = AsyncMock()
- self.cog.add_roles = AsyncMock()
-
- member = MockMember()
- await self.cog.createteam(self.cog, self.ctx, "foo", (member for _ in range(5)))
-
- self.ctx.send.assert_awaited_once()
- self.cog.create_channels.assert_not_awaited()
- self.cog.add_roles.assert_not_awaited()
-
- async def test_result_sending(self):
- """Should call `ctx.send` when everything goes right."""
- self.cog.create_channels = AsyncMock()
- self.cog.add_roles = AsyncMock()
-
- members = [MockMember() for _ in range(5)]
- await self.cog.createteam(self.cog, self.ctx, "foo", members)
-
- self.cog.create_channels.assert_awaited_once()
- self.cog.add_roles.assert_awaited_once()
- self.ctx.send.assert_awaited_once()
-
- async def test_category_doesnt_exist(self):
- """Should create a new code jam category."""
- subtests = (
- [],
- [get_mock_category(jams.MAX_CHANNELS - 1, jams.CATEGORY_NAME)],
- [get_mock_category(jams.MAX_CHANNELS - 2, "other")],
- )
-
- for categories in subtests:
- self.guild.reset_mock()
- self.guild.categories = categories
-
- with self.subTest(categories=categories):
- actual_category = await self.cog.get_category(self.guild)
-
- self.guild.create_category_channel.assert_awaited_once()
- category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"]
-
- self.assertFalse(category_overwrites[self.guild.default_role].read_messages)
- self.assertTrue(category_overwrites[self.guild.me].read_messages)
- self.assertEqual(self.guild.create_category_channel.return_value, actual_category)
-
- async def test_category_channel_exist(self):
- """Should not try to create category channel."""
- expected_category = get_mock_category(jams.MAX_CHANNELS - 2, jams.CATEGORY_NAME)
- self.guild.categories = [
- get_mock_category(jams.MAX_CHANNELS - 2, "other"),
- expected_category,
- get_mock_category(0, jams.CATEGORY_NAME),
- ]
-
- actual_category = await self.cog.get_category(self.guild)
- self.assertEqual(expected_category, actual_category)
-
- async def test_channel_overwrites(self):
- """Should have correct permission overwrites for users and roles."""
- leader = MockMember()
- members = [leader] + [MockMember() for _ in range(4)]
- overwrites = self.cog.get_overwrites(members, self.guild)
-
- # Leader permission overwrites
- self.assertTrue(overwrites[leader].manage_messages)
- self.assertTrue(overwrites[leader].read_messages)
- self.assertTrue(overwrites[leader].manage_webhooks)
- self.assertTrue(overwrites[leader].connect)
-
- # Other members permission overwrites
- for member in members[1:]:
- self.assertTrue(overwrites[member].read_messages)
- self.assertTrue(overwrites[member].connect)
-
- # Everyone role overwrite
- self.assertFalse(overwrites[self.guild.default_role].read_messages)
- self.assertFalse(overwrites[self.guild.default_role].connect)
-
- async def test_team_channels_creation(self):
- """Should create new voice and text channel for team."""
- members = [MockMember() for _ in range(5)]
-
- self.cog.get_overwrites = MagicMock()
- self.cog.get_category = AsyncMock()
- self.ctx.guild.create_text_channel.return_value = MockTextChannel(mention="foobar-channel")
- actual = await self.cog.create_channels(self.guild, "my-team", members)
-
- self.assertEqual("foobar-channel", actual)
- self.cog.get_overwrites.assert_called_once_with(members, self.guild)
- self.cog.get_category.assert_awaited_once_with(self.guild)
-
- self.guild.create_text_channel.assert_awaited_once_with(
- "my-team",
- overwrites=self.cog.get_overwrites.return_value,
- category=self.cog.get_category.return_value
- )
- self.guild.create_voice_channel.assert_awaited_once_with(
- "My Team",
- overwrites=self.cog.get_overwrites.return_value,
- category=self.cog.get_category.return_value
- )
-
- async def test_jam_roles_adding(self):
- """Should add team leader role to leader and jam role to every team member."""
- leader_role = MockRole(name="Team Leader")
- jam_role = MockRole(name="Jammer")
- self.guild.get_role.side_effect = [leader_role, jam_role]
-
- leader = MockMember()
- members = [leader] + [MockMember() for _ in range(4)]
- await self.cog.add_roles(self.guild, members)
-
- leader.add_roles.assert_any_await(leader_role)
- for member in members:
- member.add_roles.assert_any_await(jam_role)
-
-
-class CodeJamSetup(unittest.TestCase):
- """Test for `setup` function of `CodeJam` cog."""
-
- def test_setup(self):
- """Should call `bot.add_cog`."""
- bot = MockBot()
- jams.setup(bot)
- bot.add_cog.assert_called_once()
diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py
index 6444532f2..f8805ac48 100644
--- a/tests/bot/rules/test_mentions.py
+++ b/tests/bot/rules/test_mentions.py
@@ -2,12 +2,14 @@ from typing import Iterable
from bot.rules import mentions
from tests.bot.rules import DisallowedCase, RuleTest
-from tests.helpers import MockMessage
+from tests.helpers import MockMember, MockMessage
-def make_msg(author: str, total_mentions: int) -> MockMessage:
+def make_msg(author: str, total_user_mentions: int, total_bot_mentions: int = 0) -> MockMessage:
"""Makes a message with `total_mentions` mentions."""
- return MockMessage(author=author, mentions=list(range(total_mentions)))
+ user_mentions = [MockMember() for _ in range(total_user_mentions)]
+ bot_mentions = [MockMember(bot=True) for _ in range(total_bot_mentions)]
+ return MockMessage(author=author, mentions=user_mentions+bot_mentions)
class TestMentions(RuleTest):
@@ -48,11 +50,27 @@ class TestMentions(RuleTest):
[make_msg("bob", 2), make_msg("alice", 3), make_msg("bob", 2)],
("bob",),
4,
- )
+ ),
+ DisallowedCase(
+ [make_msg("bob", 3, 1)],
+ ("bob",),
+ 3,
+ ),
)
await self.run_disallowed(cases)
+ async def test_ignore_bot_mentions(self):
+ """Messages with an allowed amount of mentions, also containing bot mentions."""
+ cases = (
+ [make_msg("bob", 0, 3)],
+ [make_msg("bob", 2, 1)],
+ [make_msg("bob", 1, 2), make_msg("bob", 1, 2)],
+ [make_msg("bob", 1, 5), make_msg("alice", 2, 5)]
+ )
+
+ await self.run_allowed(cases)
+
def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]:
last_message = case.recent_messages[0]
return tuple(
diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py
index 4af84dde5..6e3a6b898 100644
--- a/tests/bot/test_converters.py
+++ b/tests/bot/test_converters.py
@@ -11,7 +11,6 @@ from bot.converters import (
HushDurationConverter,
ISODateTime,
PackageName,
- TagContentConverter,
TagNameConverter,
)
@@ -26,43 +25,6 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase):
cls.fixed_utc_now = datetime.datetime.fromisoformat('2019-01-01T00:00:00')
- async def test_tag_content_converter_for_valid(self):
- """TagContentConverter should return correct values for valid input."""
- test_values = (
- ('hello', 'hello'),
- (' h ello ', 'h ello'),
- )
-
- for content, expected_conversion in test_values:
- with self.subTest(content=content, expected_conversion=expected_conversion):
- conversion = await TagContentConverter.convert(self.context, content)
- self.assertEqual(conversion, expected_conversion)
-
- async def test_tag_content_converter_for_invalid(self):
- """TagContentConverter should raise the proper exception for invalid input."""
- test_values = (
- ('', "Tag contents should not be empty, or filled with whitespace."),
- (' ', "Tag contents should not be empty, or filled with whitespace."),
- )
-
- for value, exception_message in test_values:
- with self.subTest(tag_content=value, exception_message=exception_message):
- with self.assertRaisesRegex(BadArgument, re.escape(exception_message)):
- await TagContentConverter.convert(self.context, value)
-
- async def test_tag_name_converter_for_valid(self):
- """TagNameConverter should return the correct values for valid tag names."""
- test_values = (
- ('tracebacks', 'tracebacks'),
- ('Tracebacks', 'tracebacks'),
- (' Tracebacks ', 'tracebacks'),
- )
-
- for name, expected_conversion in test_values:
- with self.subTest(name=name, expected_conversion=expected_conversion):
- conversion = await TagNameConverter.convert(self.context, name)
- self.assertEqual(conversion, expected_conversion)
-
async def test_tag_name_converter_for_invalid(self):
"""TagNameConverter should raise the correct exception for invalid tag names."""
test_values = (
@@ -291,7 +253,7 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase):
("10", 10),
("5m", 5),
("5M", 5),
- ("forever", None),
+ ("forever", -1),
)
converter = HushDurationConverter()
for minutes_string, expected_minutes in test_values:
diff --git a/tests/bot/utils/test_message_cache.py b/tests/bot/utils/test_message_cache.py
new file mode 100644
index 000000000..04bfd28d1
--- /dev/null
+++ b/tests/bot/utils/test_message_cache.py
@@ -0,0 +1,214 @@
+import unittest
+
+from bot.utils.message_cache import MessageCache
+from tests.helpers import MockMessage
+
+
+# noinspection SpellCheckingInspection
+class TestMessageCache(unittest.TestCase):
+ """Tests for the MessageCache class in the `bot.utils.caching` module."""
+
+ def test_first_append_sets_the_first_value(self):
+ """Test if the first append adds the message to the first cell."""
+ cache = MessageCache(maxlen=10)
+ message = MockMessage()
+
+ cache.append(message)
+
+ self.assertEqual(cache[0], message)
+
+ def test_append_adds_in_the_right_order(self):
+ """Test if two appends are added in the same order if newest_first is False, or in reverse order otherwise."""
+ messages = [MockMessage(), MockMessage()]
+
+ cache = MessageCache(maxlen=10, newest_first=False)
+ for msg in messages:
+ cache.append(msg)
+ self.assertListEqual(messages, list(cache))
+
+ cache = MessageCache(maxlen=10, newest_first=True)
+ for msg in messages:
+ cache.append(msg)
+ self.assertListEqual(messages[::-1], list(cache))
+
+ def test_appending_over_maxlen_removes_oldest(self):
+ """Test if three appends to a 2-cell cache leave the two newest messages."""
+ cache = MessageCache(maxlen=2)
+ messages = [MockMessage() for _ in range(3)]
+
+ for msg in messages:
+ cache.append(msg)
+
+ self.assertListEqual(messages[1:], list(cache))
+
+ def test_appending_over_maxlen_with_newest_first_removes_oldest(self):
+ """Test if three appends to a 2-cell cache leave the two newest messages if newest_first is True."""
+ cache = MessageCache(maxlen=2, newest_first=True)
+ messages = [MockMessage() for _ in range(3)]
+
+ for msg in messages:
+ cache.append(msg)
+
+ self.assertListEqual(messages[:0:-1], list(cache))
+
+ def test_pop_removes_from_the_end(self):
+ """Test if a pop removes the right-most message."""
+ cache = MessageCache(maxlen=3)
+ messages = [MockMessage() for _ in range(3)]
+
+ for msg in messages:
+ cache.append(msg)
+ msg = cache.pop()
+
+ self.assertEqual(msg, messages[-1])
+ self.assertListEqual(messages[:-1], list(cache))
+
+ def test_popleft_removes_from_the_beginning(self):
+ """Test if a popleft removes the left-most message."""
+ cache = MessageCache(maxlen=3)
+ messages = [MockMessage() for _ in range(3)]
+
+ for msg in messages:
+ cache.append(msg)
+ msg = cache.popleft()
+
+ self.assertEqual(msg, messages[0])
+ self.assertListEqual(messages[1:], list(cache))
+
+ def test_clear(self):
+ """Test if a clear makes the cache empty."""
+ cache = MessageCache(maxlen=5)
+ messages = [MockMessage() for _ in range(3)]
+
+ for msg in messages:
+ cache.append(msg)
+ cache.clear()
+
+ self.assertListEqual(list(cache), [])
+ self.assertEqual(len(cache), 0)
+
+ def test_get_message_returns_the_message(self):
+ """Test if get_message returns the cached message."""
+ cache = MessageCache(maxlen=5)
+ message = MockMessage(id=1234)
+
+ cache.append(message)
+
+ self.assertEqual(cache.get_message(1234), message)
+
+ def test_get_message_returns_none(self):
+ """Test if get_message returns None for an ID of a non-cached message."""
+ cache = MessageCache(maxlen=5)
+ message = MockMessage(id=1234)
+
+ cache.append(message)
+
+ self.assertIsNone(cache.get_message(4321))
+
+ def test_update_replaces_old_element(self):
+ """Test if an update replaced the old message with the same ID."""
+ cache = MessageCache(maxlen=5)
+ message = MockMessage(id=1234)
+
+ cache.append(message)
+ message = MockMessage(id=1234)
+ cache.update(message)
+
+ self.assertIs(cache.get_message(1234), message)
+ self.assertEqual(len(cache), 1)
+
+ def test_contains_returns_true_for_cached_message(self):
+ """Test if contains returns True for an ID of a cached message."""
+ cache = MessageCache(maxlen=5)
+ message = MockMessage(id=1234)
+
+ cache.append(message)
+
+ self.assertIn(1234, cache)
+
+ def test_contains_returns_false_for_non_cached_message(self):
+ """Test if contains returns False for an ID of a non-cached message."""
+ cache = MessageCache(maxlen=5)
+ message = MockMessage(id=1234)
+
+ cache.append(message)
+
+ self.assertNotIn(4321, cache)
+
+ def test_indexing(self):
+ """Test if the cache returns the correct messages by index."""
+ cache = MessageCache(maxlen=5)
+ messages = [MockMessage() for _ in range(5)]
+
+ for msg in messages:
+ cache.append(msg)
+
+ for current_loop in range(-5, 5):
+ with self.subTest(current_loop=current_loop):
+ self.assertEqual(cache[current_loop], messages[current_loop])
+
+ def test_bad_index_raises_index_error(self):
+ """Test if the cache raises IndexError for invalid indices."""
+ cache = MessageCache(maxlen=5)
+ messages = [MockMessage() for _ in range(3)]
+ test_cases = (-10, -4, 3, 4, 5)
+
+ for msg in messages:
+ cache.append(msg)
+
+ for current_loop in test_cases:
+ with self.subTest(current_loop=current_loop):
+ with self.assertRaises(IndexError):
+ cache[current_loop]
+
+ def test_slicing_with_unfilled_cache(self):
+ """Test if slicing returns the correct messages if the cache is not yet fully filled."""
+ sizes = (5, 10, 55, 101)
+
+ slices = (
+ slice(None), slice(2, None), slice(None, 2), slice(None, None, 2), slice(None, None, 3), slice(-1, 2),
+ slice(-1, 3000), slice(-3, -1), slice(-10, 3), slice(-10, 4, 2), slice(None, None, -1), slice(None, 3, -2),
+ slice(None, None, -3), slice(-1, -10, -2), slice(-3, -7, -1)
+ )
+
+ for size in sizes:
+ cache = MessageCache(maxlen=size)
+ messages = [MockMessage() for _ in range(size // 3 * 2)]
+
+ for msg in messages:
+ cache.append(msg)
+
+ for slice_ in slices:
+ with self.subTest(current_loop=(size, slice_)):
+ self.assertListEqual(cache[slice_], messages[slice_])
+
+ def test_slicing_with_overfilled_cache(self):
+ """Test if slicing returns the correct messages if the cache was appended with more messages it can contain."""
+ sizes = (5, 10, 55, 101)
+
+ slices = (
+ slice(None), slice(2, None), slice(None, 2), slice(None, None, 2), slice(None, None, 3), slice(-1, 2),
+ slice(-1, 3000), slice(-3, -1), slice(-10, 3), slice(-10, 4, 2), slice(None, None, -1), slice(None, 3, -2),
+ slice(None, None, -3), slice(-1, -10, -2), slice(-3, -7, -1)
+ )
+
+ for size in sizes:
+ cache = MessageCache(maxlen=size)
+ messages = [MockMessage() for _ in range(size * 3 // 2)]
+
+ for msg in messages:
+ cache.append(msg)
+ messages = messages[size // 2:]
+
+ for slice_ in slices:
+ with self.subTest(current_loop=(size, slice_)):
+ self.assertListEqual(cache[slice_], messages[slice_])
+
+ def test_length(self):
+ """Test if len returns the correct number of items in the cache."""
+ cache = MessageCache(maxlen=5)
+
+ for current_loop in range(10):
+ with self.subTest(current_loop=current_loop):
+ self.assertEqual(len(cache), min(current_loop, 5))
+ cache.append(MockMessage())
diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py
index 115ddfb0d..8edffd1c9 100644
--- a/tests/bot/utils/test_time.py
+++ b/tests/bot/utils/test_time.py
@@ -52,7 +52,7 @@ class TimeTests(unittest.TestCase):
def test_format_infraction(self):
"""Testing format_infraction."""
- self.assertEqual(time.format_infraction('2019-12-12T00:01:00Z'), '2019-12-12 00:01')
+ self.assertEqual(time.format_infraction('2019-12-12T00:01:00Z'), '<t:1576108860:f>')
def test_format_infraction_with_duration_none_expiry(self):
"""format_infraction_with_duration should work for None expiry."""
@@ -72,10 +72,10 @@ class TimeTests(unittest.TestCase):
def test_format_infraction_with_duration_custom_units(self):
"""format_infraction_with_duration should work for custom max_units."""
test_cases = (
- ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6,
- '2019-12-12 00:01 (11 hours, 55 minutes and 55 seconds)'),
- ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 20,
- '2019-11-23 20:09 (6 months, 28 days, 23 hours and 54 minutes)')
+ ('3000-12-12T00:01:00Z', datetime(3000, 12, 11, 12, 5, 5), 6,
+ '<t:32533488060:f> (11 hours, 55 minutes and 55 seconds)'),
+ ('3000-11-23T20:09:00Z', datetime(3000, 4, 25, 20, 15), 20,
+ '<t:32531918940:f> (6 months, 28 days, 23 hours and 54 minutes)')
)
for expiry, date_from, max_units, expected in test_cases:
@@ -85,16 +85,16 @@ class TimeTests(unittest.TestCase):
def test_format_infraction_with_duration_normal_usage(self):
"""format_infraction_with_duration should work for normal usage, across various durations."""
test_cases = (
- ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 2, '2019-12-12 00:01 (12 hours and 55 seconds)'),
- ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, '2019-12-12 00:01 (12 hours)'),
- ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, '2019-12-12 00:00 (1 minute)'),
- ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, '2019-11-23 20:09 (7 days and 23 hours)'),
- ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, '2019-11-23 20:09 (6 months and 28 days)'),
- ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, '2019-11-23 20:58 (5 minutes)'),
- ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, '2019-11-24 00:00 (1 minute)'),
- ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, '2019-11-23 23:59 (2 years and 4 months)'),
+ ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 2, '<t:1576108860:f> (12 hours and 55 seconds)'),
+ ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, '<t:1576108860:f> (12 hours)'),
+ ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, '<t:1576108800:f> (1 minute)'),
+ ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, '<t:1574539740:f> (7 days and 23 hours)'),
+ ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, '<t:1574539740:f> (6 months and 28 days)'),
+ ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, '<t:1574542680:f> (5 minutes)'),
+ ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, '<t:1574553600:f> (1 minute)'),
+ ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, '<t:1574553540:f> (2 years and 4 months)'),
('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), 2,
- '2019-11-23 23:59 (9 minutes and 55 seconds)'),
+ '<t:1574553540:f> (9 minutes and 55 seconds)'),
(None, datetime(2019, 11, 23, 23, 49, 5), 2, None),
)
@@ -104,45 +104,30 @@ class TimeTests(unittest.TestCase):
def test_until_expiration_with_duration_none_expiry(self):
"""until_expiration should work for None expiry."""
- test_cases = (
- (None, None, None, None),
-
- # To make sure that now and max_units are not touched
- (None, 'Why hello there!', None, None),
- (None, None, float('inf'), None),
- (None, 'Why hello there!', float('inf'), None),
- )
-
- for expiry, now, max_units, expected in test_cases:
- with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected):
- self.assertEqual(time.until_expiration(expiry, now, max_units), expected)
+ self.assertEqual(time.until_expiration(None), None)
def test_until_expiration_with_duration_custom_units(self):
"""until_expiration should work for custom max_units."""
test_cases = (
- ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6, '11 hours, 55 minutes and 55 seconds'),
- ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 20, '6 months, 28 days, 23 hours and 54 minutes')
+ ('3000-12-12T00:01:00Z', '<t:32533488060:R>'),
+ ('3000-11-23T20:09:00Z', '<t:32531918940:R>')
)
- for expiry, now, max_units, expected in test_cases:
- with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected):
- self.assertEqual(time.until_expiration(expiry, now, max_units), expected)
+ for expiry, expected in test_cases:
+ with self.subTest(expiry=expiry, expected=expected):
+ self.assertEqual(time.until_expiration(expiry,), expected)
def test_until_expiration_normal_usage(self):
"""until_expiration should work for normal usage, across various durations."""
test_cases = (
- ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 2, '12 hours and 55 seconds'),
- ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, '12 hours'),
- ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, '1 minute'),
- ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, '7 days and 23 hours'),
- ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, '6 months and 28 days'),
- ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, '5 minutes'),
- ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, '1 minute'),
- ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, '2 years and 4 months'),
- ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), 2, '9 minutes and 55 seconds'),
- (None, datetime(2019, 11, 23, 23, 49, 5), 2, None),
+ ('3000-12-12T00:01:00Z', '<t:32533488060:R>'),
+ ('3000-12-12T00:01:00Z', '<t:32533488060:R>'),
+ ('3000-12-12T00:00:00Z', '<t:32533488000:R>'),
+ ('3000-11-23T20:09:00Z', '<t:32531918940:R>'),
+ ('3000-11-23T20:09:00Z', '<t:32531918940:R>'),
+ (None, None),
)
- for expiry, now, max_units, expected in test_cases:
- with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected):
- self.assertEqual(time.until_expiration(expiry, now, max_units), expected)
+ for expiry, expected in test_cases:
+ with self.subTest(expiry=expiry, expected=expected):
+ self.assertEqual(time.until_expiration(expiry), expected)
diff --git a/tests/helpers.py b/tests/helpers.py
index e3dc5fe5b..3978076ed 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -16,7 +16,6 @@ from bot.async_stats import AsyncStatsClient
from bot.bot import Bot
from tests._autospec import autospec # noqa: F401 other modules import it via this module
-
for logger in logging.Logger.manager.loggerDict.values():
# Set all loggers to CRITICAL by default to prevent screen clutter during testing
@@ -320,7 +319,10 @@ channel_data = {
}
state = unittest.mock.MagicMock()
guild = unittest.mock.MagicMock()
-channel_instance = discord.TextChannel(state=state, guild=guild, data=channel_data)
+text_channel_instance = discord.TextChannel(state=state, guild=guild, data=channel_data)
+
+channel_data["type"] = "VoiceChannel"
+voice_channel_instance = discord.VoiceChannel(state=state, guild=guild, data=channel_data)
class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin):
@@ -330,7 +332,24 @@ class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin):
Instances of this class will follow the specifications of `discord.TextChannel` instances. For
more information, see the `MockGuild` docstring.
"""
- spec_set = channel_instance
+ spec_set = text_channel_instance
+
+ def __init__(self, **kwargs) -> None:
+ default_kwargs = {'id': next(self.discord_id), 'name': 'channel', 'guild': MockGuild()}
+ super().__init__(**collections.ChainMap(kwargs, default_kwargs))
+
+ if 'mention' not in kwargs:
+ self.mention = f"#{self.name}"
+
+
+class MockVoiceChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin):
+ """
+ A MagicMock subclass to mock VoiceChannel objects.
+
+ Instances of this class will follow the specifications of `discord.VoiceChannel` instances. For
+ more information, see the `MockGuild` docstring.
+ """
+ spec_set = voice_channel_instance
def __init__(self, **kwargs) -> None:
default_kwargs = {'id': next(self.discord_id), 'name': 'channel', 'guild': MockGuild()}
@@ -361,6 +380,27 @@ class MockDMChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin):
super().__init__(**collections.ChainMap(kwargs, default_kwargs))
+# Create CategoryChannel instance to get a realistic MagicMock of `discord.CategoryChannel`
+category_channel_data = {
+ 'id': 1,
+ 'type': discord.ChannelType.category,
+ 'name': 'category',
+ 'position': 1,
+}
+
+state = unittest.mock.MagicMock()
+guild = unittest.mock.MagicMock()
+category_channel_instance = discord.CategoryChannel(
+ state=state, guild=guild, data=category_channel_data
+)
+
+
+class MockCategoryChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin):
+ def __init__(self, **kwargs) -> None:
+ default_kwargs = {'id': next(self.discord_id)}
+ super().__init__(**collections.ChainMap(default_kwargs, kwargs))
+
+
# Create a Message instance to get a realistic MagicMock of `discord.Message`
message_data = {
'id': 1,
@@ -403,6 +443,7 @@ class MockContext(CustomMockMixin, unittest.mock.MagicMock):
self.guild = kwargs.get('guild', MockGuild())
self.author = kwargs.get('author', MockMember())
self.channel = kwargs.get('channel', MockTextChannel())
+ self.message = kwargs.get('message', MockMessage())
self.invoked_from_error_handler = kwargs.get('invoked_from_error_handler', False)