aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Numerlor <[email protected]>2021-09-06 01:59:51 +0200
committerGravatar Numerlor <[email protected]>2021-09-06 01:59:51 +0200
commitdc95845567f8048261440b9da7ce6bca1a740822 (patch)
treeb84d32967a81e187e1e5277c2a51c699b8c8a871
parentReturn 0 if search string has no a-z characters (diff)
parentServer command now uses correct TalentPool attr (#1810) (diff)
Merge remote-tracking branch 'upstream/main' into tag-groups
# Conflicts: # bot/converters.py # bot/exts/backend/error_handler.py # bot/exts/info/source.py # tests/bot/test_converters.py
-rw-r--r--.github/workflows/build.yml4
-rw-r--r--.github/workflows/deploy.yml6
-rw-r--r--.github/workflows/lint-test.yml21
-rw-r--r--.github/workflows/sentry_release.yml4
-rw-r--r--.github/workflows/status_embed.yaml4
-rw-r--r--Dockerfile2
-rw-r--r--bot/constants.py26
-rw-r--r--bot/converters.py220
-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.py5
-rw-r--r--bot/exts/filters/antispam.py88
-rw-r--r--bot/exts/filters/filtering.py7
-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/_cog.py14
-rw-r--r--bot/exts/help_channels/_message.py4
-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.py26
-rw-r--r--bot/exts/info/doc/_parsing.py2
-rw-r--r--bot/exts/info/help.py21
-rw-r--r--bot/exts/info/information.py122
-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.py37
-rw-r--r--bot/exts/moderation/defcon.py14
-rw-r--r--bot/exts/moderation/dm_relay.py6
-rw-r--r--bot/exts/moderation/incidents.py24
-rw-r--r--bot/exts/moderation/infraction/_scheduler.py46
-rw-r--r--bot/exts/moderation/infraction/_utils.py45
-rw-r--r--bot/exts/moderation/infraction/infractions.py114
-rw-r--r--bot/exts/moderation/infraction/management.py70
-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/watchchannels/_watchchannel.py2
-rw-r--r--bot/exts/moderation/watchchannels/bigbrother.py12
-rw-r--r--bot/exts/recruitment/talentpool/_cog.py244
-rw-r--r--bot/exts/recruitment/talentpool/_review.py63
-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.py18
-rw-r--r--bot/pagination.py22
-rw-r--r--bot/resources/tags/blocking.md5
-rw-r--r--bot/resources/tags/botvar.md23
-rw-r--r--bot/resources/tags/docstring.md18
-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/time.py85
-rw-r--r--config-default.yml41
-rw-r--r--poetry.lock375
-rw-r--r--pyproject.toml6
-rw-r--r--tests/README.md31
-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/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.py10
-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.py27
-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
87 files changed, 3559 insertions, 1547 deletions
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 84a671917..f8f2c8888 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -8,6 +8,10 @@ on:
types:
- completed
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
build:
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 8b809b777..88abe6fb6 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -8,6 +8,10 @@ on:
types:
- completed
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
build:
environment: production
@@ -38,6 +42,6 @@ jobs:
uses: Azure/k8s-deploy@v1
with:
manifests: |
- bot/deployment.yaml
+ namespaces/default/bot/deployment.yaml
images: 'ghcr.io/python-discord/bot:${{ steps.sha_tag.outputs.tag }}'
kubectl-version: 'latest'
diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml
index e99e6d181..619544e1a 100644
--- a/.github/workflows/lint-test.yml
+++ b/.github/workflows/lint-test.yml
@@ -6,11 +6,25 @@ on:
- main
pull_request:
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
jobs:
lint-test:
runs-on: ubuntu-latest
env:
+ # List of licenses that are compatible with the MIT License and
+ # can be used in our project
+ ALLOWED_LICENSE: Apache Software License;
+ BSD License;
+ GNU Library or Lesser General Public License (LGPL);
+ ISC License (ISCL);
+ MIT License;
+ Mozilla Public License 2.0 (MPL 2.0);
+ Public Domain;
+ Python Software Foundation License
+
# Dummy values for required bot environment variables
BOT_API_KEY: foo
BOT_SENTRY_DSN: blah
@@ -67,6 +81,13 @@ jobs:
pip install poetry
poetry install
+ # Check all the dependencies are compatible with the MIT license.
+ # If you added a new dependencies that is being rejected,
+ # please make sure it is compatible with the license for this project,
+ # and add it to the ALLOWED_LICENSE variable
+ - name: Check Dependencies License
+ run: pip-licenses --allow-only="$ALLOWED_LICENSE"
+
# This step caches our pre-commit environment. To make sure we
# do create a new environment when our pre-commit setup changes,
# we create a cache key based on relevant factors.
diff --git a/.github/workflows/sentry_release.yml b/.github/workflows/sentry_release.yml
index f6a1e1f0e..48f5e50f4 100644
--- a/.github/workflows/sentry_release.yml
+++ b/.github/workflows/sentry_release.yml
@@ -5,6 +5,10 @@ on:
branches:
- main
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
create_sentry_release:
runs-on: ubuntu-latest
diff --git a/.github/workflows/status_embed.yaml b/.github/workflows/status_embed.yaml
index b6a71b887..4178c366d 100644
--- a/.github/workflows/status_embed.yaml
+++ b/.github/workflows/status_embed.yaml
@@ -9,6 +9,10 @@ on:
types:
- completed
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
status_embed:
# We need to send a status embed whenever the workflow
diff --git a/Dockerfile b/Dockerfile
index c285898dc..30bf8a361 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.9.5-slim
+FROM --platform=linux/amd64 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 3d960f22b..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"
@@ -439,6 +441,7 @@ class Channels(metaclass=YAMLGetter):
discord_py: int
esoteric: int
voice_gate: int
+ code_jam_planning: int
admins: int
admin_spam: int
@@ -456,18 +459,19 @@ 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
- talent_pool: int
class Webhooks(metaclass=YAMLGetter):
@@ -478,7 +482,6 @@ class Webhooks(metaclass=YAMLGetter):
dev_log: int
duck_pond: int
incidents_archive: int
- talent_pool: int
class Roles(metaclass=YAMLGetter):
@@ -497,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
@@ -506,7 +511,6 @@ class Roles(metaclass=YAMLGetter):
project_leads: int
jammers: int
- team_leaders: int
class Guild(metaclass=YAMLGetter):
@@ -562,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
@@ -681,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__)
@@ -690,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 038d2a287..48a5e3dc2 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,19 @@ 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.exts.info.tags import TagIdentifier
+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 +134,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.
@@ -236,23 +280,37 @@ class Snowflake(IDConverter):
return snowflake
-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
- # 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.")
+ cmd = ctx.bot.get_command(argument)
+ if cmd:
+ return cmd
- return tag_content
+ tags_cog = ctx.bot.get_cog("Tags")
+ show_tag = True
+
+ if not tags_cog:
+ show_tag = False
+ else:
+ identifier = TagIdentifier.from_string(argument.lower())
+ if identifier in tags_cog.tags:
+ return identifier
+ 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):
@@ -381,11 +439,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,
@@ -393,7 +451,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.")
@@ -404,103 +462,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):
@@ -532,6 +538,24 @@ 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
+ 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 f2e2a964c..128e72c84 100644
--- a/bot/exts/backend/error_handler.py
+++ b/bot/exts/backend/error_handler.py
@@ -3,13 +3,13 @@ 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.errors import InvalidInfractedUser, LockedResourceError
+from bot.errors import InvalidInfractedUserError, LockedResourceError
from bot.exts.info import tags
from bot.utils.checks import ContextCheckFailure
@@ -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 89e539e7b..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__)
@@ -61,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):
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 661d6c9a2..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
@@ -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/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/_cog.py b/bot/exts/help_channels/_cog.py
index 35658d117..cfc9cf477 100644
--- a/bot/exts/help_channels/_cog.py
+++ b/bot/exts/help_channels/_cog.py
@@ -267,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.")
@@ -387,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.")
@@ -511,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"))
diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py
index befacd263..077b20b47 100644
--- a/bot/exts/help_channels/_message.py
+++ b/bot/exts/help_channels/_message.py
@@ -30,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})**.
"""
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..a2119a53d 100644
--- a/bot/exts/info/doc/_cog.py
+++ b/bot/exts/info/doc/_cog.py
@@ -150,6 +150,8 @@ class DocCog(commands.Cog):
self.update_or_reschedule_inventory(api_package_name, base_url, inventory_url),
)
else:
+ if not base_url:
+ base_url = self.base_url_from_inventory_url(inventory_url)
self.update_single(api_package_name, base_url, package)
def ensure_unique_symbol_name(self, package_name: str, group_name: str, symbol_name: str) -> str:
@@ -341,14 +343,22 @@ 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,))
+ @staticmethod
+ def base_url_from_inventory_url(inventory_url: str) -> str:
+ """Get a base url from the url to an objects inventory by removing the last path segment."""
+ return inventory_url.removesuffix("/").rsplit("/", maxsplit=1)[0] + "/"
+
@docs_group.command(name="setdoc", aliases=("s",))
@commands.has_any_role(*MODERATION_ROLES)
@lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True)
@@ -356,21 +366,21 @@ class DocCog(commands.Cog):
self,
ctx: commands.Context,
package_name: PackageName,
- base_url: ValidURL,
inventory: Inventory,
+ base_url: ValidURL = "",
) -> None:
"""
Adds a new documentation metadata object to the site's database.
The database will update the object, should an existing item with the specified `package_name` already exist.
+ If the base url is not specified, a default created by removing the last segment of the inventory url is used.
Example:
!docs setdoc \
python \
- https://docs.python.org/3/ \
https://docs.python.org/3/objects.inv
"""
- if not base_url.endswith("/"):
+ if base_url and not base_url.endswith("/"):
raise commands.BadArgument("The base url must end with a slash.")
inventory_url, inventory_dict = inventory
body = {
@@ -385,6 +395,8 @@ class DocCog(commands.Cog):
+ "\n".join(f"{key}: {value}" for key, value in body.items())
)
+ if not base_url:
+ base_url = self.base_url_from_inventory_url(inventory_url)
self.update_single(package_name, base_url, inventory_dict)
await ctx.send(f"Added the package `{package_name}` to the database and updated the inventories.")
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 1b1243118..51d47b75c 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,21 +44,35 @@ 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."""
talentpool_info = ""
if cog := self.bot.get_cog("Talentpool"):
- talentpool_info = f"Nominated: {len(cog.watched_users)}\n"
+ talentpool_info = f"Nominated: {len(cog.cache)}\n"
bb_info = ""
if cog := self.bot.get_cog("Big Brother"):
@@ -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}"
@@ -242,8 +264,14 @@ class Information(Cog):
badges.append(emoji)
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")
@@ -290,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',
@@ -307,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`.
@@ -348,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',
@@ -373,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`.
@@ -432,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
@@ -472,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 723ae5aba..e3e7029ca 100644
--- a/bot/exts/info/source.py
+++ b/bot/exts/info/source.py
@@ -2,50 +2,17 @@ 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
from bot.exts.info.tags import TagIdentifier
SourceType = Union[commands.HelpCommand, commands.Command, commands.Cog, TagIdentifier, 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
- else:
- identifier = TagIdentifier.from_string(argument.lower())
- if identifier in tags_cog.tags:
- return identifier
-
- 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/dm_relay.py b/bot/exts/moderation/dm_relay.py
index 1d2206e27..0051db82f 100644
--- a/bot/exts/moderation/dm_relay.py
+++ b/bot/exts/moderation/dm_relay.py
@@ -5,6 +5,7 @@ from discord.ext.commands import Cog, Context, command, has_any_role
from bot.bot import Bot
from bot.constants import Emojis, MODERATION_ROLES
+from bot.utils.channel import is_mod_channel
from bot.utils.services import send_to_paste_service
log = logging.getLogger(__name__)
@@ -63,8 +64,9 @@ class DMRelay(Cog):
await ctx.send(paste_link)
async def cog_check(self, ctx: Context) -> bool:
- """Only allow moderators to invoke the commands in this cog."""
- return await has_any_role(*MODERATION_ROLES).predicate(ctx)
+ """Only allow moderators to invoke the commands in this cog in mod channels."""
+ return (await has_any_role(*MODERATION_ROLES).predicate(ctx)
+ and is_mod_channel(ctx.channel))
def setup(bot: Bot) -> None:
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 8286d3635..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
@@ -115,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 = "",
@@ -165,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:
@@ -264,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}.")
@@ -292,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
@@ -345,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.
@@ -354,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)
@@ -380,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
@@ -468,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 e4eb7f79c..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,
@@ -164,13 +165,13 @@ async def notify_infraction(
text = INFRACTION_DESCRIPTION_TEMPLATE.format(
type=infr_type.title(),
- expires=f"{expires_at} UTC" if expires_at else "N/A",
+ expires=expires_at or "N/A",
reason=reason or "No reason provided."
)
# 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..223a124d8 100644
--- a/bot/exts/moderation/infraction/management.py
+++ b/bot/exts/moderation/infraction/management.py
@@ -3,19 +3,23 @@ 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.api import ResponseCodeError
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__)
@@ -41,9 +45,25 @@ class ModManagement(commands.Cog):
# region: Edit infraction commands
@commands.group(name='infraction', aliases=('infr', 'infractions', 'inf', 'i'), invoke_without_command=True)
- async def infraction_group(self, ctx: Context) -> None:
- """Infraction manipulation commands."""
- await ctx.send_help(ctx.command)
+ async def infraction_group(self, ctx: Context, infr_id: int = None) -> None:
+ """Infraction manipulation commands. If `infr_id` is passed then this command fetches that infraction."""
+ if infr_id is None:
+ await ctx.send_help(ctx.command)
+ return
+
+ try:
+ infraction_list = [await self.bot.api_client.get(f"bot/infractions/{infr_id}/expanded")]
+ except ResponseCodeError as e:
+ if e.status == 404:
+ await ctx.send(f":x: No infraction with ID `{infr_id}` could be found.")
+ return
+ raise e
+
+ embed = discord.Embed(
+ title=f"Infraction #{infr_id}",
+ colour=discord.Colour.orange()
+ )
+ await self.send_infraction_list(ctx, embed, infraction_list)
@infraction_group.command(name="append", aliases=("amend", "add", "a"))
async def infraction_append(
@@ -78,7 +98,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 +184,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 +218,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:
+ @infraction_search_group.command(name="user", aliases=("member", "userid"))
+ 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 +313,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 +326,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/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 c6ee844ef..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,7 +94,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
await ctx.send(f":x: {user} is already being watched.")
return
- # FetchedUser instances don't have a roles attribute
+ # 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
@@ -125,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 03326cab2..a317c6645 100644
--- a/bot/exts/recruitment/talentpool/_cog.py
+++ b/bot/exts/recruitment/talentpool/_cog.py
@@ -1,43 +1,71 @@
import logging
import textwrap
-from collections import ChainMap
+from collections import ChainMap, defaultdict
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, Webhooks
-from bot.converters import FetchedMember
-from bot.exts.moderation.watchchannels._watchchannel import WatchChannel
+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__)
-class TalentPool(WatchChannel, Cog, name="Talentpool"):
- """Relays messages of helper candidates to a watch channel to observe them."""
+class TalentPool(Cog, name="Talentpool"):
+ """Used to nominate potential helper candidates."""
- def __init__(self, bot: Bot) -> None:
- super().__init__(
- bot,
- destination=Channels.talent_pool,
- webhook_id=Webhooks.talent_pool,
- api_endpoint='bot/nominations',
- api_default_params={'active': 'true', 'ordering': '-inserted_at'},
- logger=log,
- disable_header=True,
- )
+ # 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.api_default_params = {'active': 'true', 'ordering': '-inserted_at'}
+ 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:
+ 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)
+
+ async def refresh_cache(self) -> bool:
+ """Updates TalentPool users cache."""
+ try:
+ data = await self.bot.api_client.get(
+ 'bot/nominations',
+ params=self.api_default_params
+ )
+ except ResponseCodeError as err:
+ log.exception("Failed to fetch the currently nominated users from the API", exc_info=err)
+ return False
+
+ self.cache = defaultdict(dict)
+
+ for entry in data:
+ user_id = entry.pop('user')
+ self.cache[user_id] = entry
+
+ return True
@group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True)
@has_any_role(*MODERATION_ROLES)
@@ -45,25 +73,73 @@ class TalentPool(WatchChannel, 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='watched', aliases=('all', 'list'), 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="nominees",
+ aliases=("nominated", "all", "list", "watched"),
+ root_aliases=("nominees",)
+ )
@has_any_role(*MODERATION_ROLES)
- async def watched_command(
+ async def list_command(
self,
ctx: Context,
oldest_first: bool = False,
update_cache: bool = True
) -> None:
"""
- Shows the users that are currently being monitored in the talent pool.
+ Shows the users that are currently in the talent pool.
The optional kwarg `oldest_first` can be used to order the list by oldest nomination.
The optional kwarg `update_cache` can be used to update the user
cache using the API before listing the users.
"""
- await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache)
+ await self.list_nominated_users(ctx, oldest_first=oldest_first, update_cache=update_cache)
- async def list_watched_users(
+ async def list_nominated_users(
self,
ctx: Context,
oldest_first: bool = False,
@@ -80,16 +156,27 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
The optional kwarg `update_cache` specifies whether the cache should
be refreshed by polling the API.
"""
- # TODO Once the watch channel is removed, this can be done in a smarter way, without splitting and overriding
- # the list_watched_users function.
- watched_data = await self.prepare_watched_users_data(ctx, oldest_first, update_cache)
+ successful_update = False
+ if update_cache:
+ if not (successful_update := await self.refresh_cache()):
+ await ctx.send(":warning: Unable to update cache. Data may be inaccurate.")
- if update_cache and not watched_data["updated"]:
- await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache")
+ nominations = self.cache.items()
+ if oldest_first:
+ nominations = reversed(nominations)
lines = []
- for user_id, line in watched_data["info"].items():
- if self.watched_users[user_id]['reviewed']:
+
+ for user_id, user_data in nominations:
+ member = ctx.guild.get_member(user_id)
+ line = f"• `{user_id}`"
+ if member:
+ line += f" ({member.name}#{member.discriminator})"
+ inserted_at = user_data['inserted_at']
+ line += f", added {get_time_delta(inserted_at)}"
+ if not member: # Cross off users who left the server.
+ line = f"~~{line}~~"
+ if user_data['reviewed']:
line += " *(reviewed)*"
elif user_id in self.reviewer:
line += " *(scheduled)*"
@@ -99,7 +186,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
lines = ("There's nothing here yet.",)
embed = Embed(
- title=watched_data["title"],
+ title=f"Talent Pool active nominations ({'updated' if update_cache and successful_update else 'cached'})",
color=Color.blue()
)
await LinePaginator.paginate(lines, ctx, embed, empty=False)
@@ -108,26 +195,30 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
@has_any_role(*MODERATION_ROLES)
async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None:
"""
- Shows talent pool monitored users ordered by oldest nomination.
+ Shows talent pool users ordered by oldest nomination.
The optional kwarg `update_cache` can be used to update the user
cache using the API before listing the users.
"""
- await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache)
+ await ctx.invoke(self.list_command, oldest_first=True, update_cache=update_cache)
- @nomination_group.command(name='forcewatch', aliases=('fw', 'forceadd', 'fa'), root_aliases=("forcenominate",))
+ @nomination_group.command(
+ name="forcenominate",
+ aliases=("fw", "forceadd", "fa", "fn", "forcewatch"),
+ root_aliases=("forcenominate",)
+ )
@has_any_role(*MODERATION_ROLES)
- async def force_watch_command(self, ctx: Context, user: FetchedMember, *, reason: str = '') -> None:
+ async def force_nominate_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None:
"""
Adds the given `user` to the talent pool, from any channel.
A `reason` for adding the user to the talent pool is optional.
"""
- await self._watch_user(ctx, user, reason)
+ await self._nominate_user(ctx, user, reason)
- @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",))
+ @nomination_group.command(name='nominate', aliases=("w", "add", "a", "watch"), root_aliases=("nominate",))
@has_any_role(*STAFF_ROLES)
- async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str = '') -> None:
+ async def nominate_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None:
"""
Adds the given `user` to the talent pool.
@@ -138,26 +229,26 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
if any(role.id in MODERATION_ROLES for role in ctx.author.roles):
await ctx.send(
f":x: Nominations should be run in the <#{Channels.nominations}> channel. "
- "Use `!tp forcewatch` to override this check."
+ "Use `!tp forcenominate` to override this check."
)
else:
await ctx.send(f":x: Nominations must be run in the <#{Channels.nominations}> channel")
return
- await self._watch_user(ctx, user, reason)
+ await self._nominate_user(ctx, user, reason)
- async def _watch_user(self, ctx: Context, user: FetchedMember, reason: str) -> None:
+ async def _nominate_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.")
+ await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. Only humans can be nominated.")
return
if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles):
await ctx.send(":x: Nominating staff members, eh? Here's a cookie :cookie:")
return
- if not await self.fetch_user_cache():
- await ctx.send(f":x: Failed to update the user cache; can't add {user}")
+ if not await self.refresh_cache():
+ await ctx.send(f":x: Failed to update the cache; can't add {user}")
return
if len(reason) > REASON_MAX_CHARS:
@@ -166,7 +257,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
# Manual request with `raise_for_status` as False because we want the actual response
session = self.bot.api_client.session
- url = self.bot.api_client._url_for(self.api_endpoint)
+ url = self.bot.api_client._url_for('bot/nominations')
kwargs = {
'json': {
'actor': ctx.author.id,
@@ -188,13 +279,13 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
else:
resp.raise_for_status()
- self.watched_users[user.id] = response_data
+ 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(
- self.api_endpoint,
+ 'bot/nominations',
params={
"user__id": str(user.id),
"active": "false",
@@ -202,7 +293,7 @@ class TalentPool(WatchChannel, 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)"
@@ -210,10 +301,10 @@ class TalentPool(WatchChannel, 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(
- self.api_endpoint,
+ 'bot/nominations',
params={
'user__id': str(user.id),
'ordering': "-active,-inserted_at"
@@ -237,20 +328,20 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
max_size=1000
)
- @nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",))
+ @nomination_group.command(name="end", aliases=("unwatch", "unnominate"), root_aliases=("unnominate",))
@has_any_role(*MODERATION_ROLES)
- async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
+ async def end_nomination_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None:
"""
Ends the active nomination of the specified user with the given reason.
Providing a `reason` is required.
"""
if len(reason) > REASON_MAX_CHARS:
- await ctx.send(f":x: Maxiumum allowed characters for the end reason is {REASON_MAX_CHARS}.")
+ await ctx.send(f":x: Maximum allowed characters for the end reason is {REASON_MAX_CHARS}.")
return
- if await self.unwatch(user.id, reason):
- await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed")
+ if await self.end_nomination(user.id, reason):
+ await ctx.send(f":white_check_mark: Successfully un-nominated {user}")
else:
await ctx.send(":x: The specified user does not have an active nomination")
@@ -262,17 +353,17 @@ class TalentPool(WatchChannel, 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}.")
return
try:
- nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}")
+ nomination = await self.bot.api_client.get(f"bot/nominations/{nomination_id}")
except ResponseCodeError as e:
if e.response.status == 404:
- self.log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}")
+ log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}")
await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`")
return
else:
@@ -283,16 +374,16 @@ class TalentPool(WatchChannel, 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
- self.log.trace(f"Changing reason for nomination with id {nomination_id} of actor {actor} to {repr(reason)}")
+ log.trace(f"Changing reason for nomination with id {nomination_id} of actor {actor} to {repr(reason)}")
await self.bot.api_client.patch(
- f"{self.api_endpoint}/{nomination_id}",
+ f"bot/nominations/{nomination_id}",
json={"actor": actor.id, "reason": reason}
)
- await self.fetch_user_cache() # Update cache
+ await self.refresh_cache() # Update cache
await ctx.send(":white_check_mark: Successfully updated nomination reason.")
@nomination_edit_group.command(name='end_reason')
@@ -304,10 +395,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
return
try:
- nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}")
+ nomination = await self.bot.api_client.get(f"bot/nominations/{nomination_id}")
except ResponseCodeError as e:
if e.response.status == 404:
- self.log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}")
+ log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}")
await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`")
return
else:
@@ -317,13 +408,13 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
await ctx.send(":x: Can't edit the end reason of an active nomination.")
return
- self.log.trace(f"Changing end reason for nomination with id {nomination_id} to {repr(reason)}")
+ log.trace(f"Changing end reason for nomination with id {nomination_id} to {repr(reason)}")
await self.bot.api_client.patch(
- f"{self.api_endpoint}/{nomination_id}",
+ f"bot/nominations/{nomination_id}",
json={"end_reason": reason}
)
- await self.fetch_user_cache() # Update cache.
+ await self.refresh_cache() # Update cache.
await ctx.send(":white_check_mark: Updated the end reason of the nomination!")
@nomination_group.command(aliases=('mr',))
@@ -356,9 +447,9 @@ class TalentPool(WatchChannel, 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.")
+ await self.end_nomination(user.id, "User was banned.")
@Cog.listener()
async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None:
@@ -380,10 +471,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
log.info(f"Archiving nomination {message.id}")
await self.reviewer.archive_vote(message, emoji == Emojis.incident_actioned)
- async def unwatch(self, user_id: int, reason: str) -> bool:
+ async def end_nomination(self, user_id: int, reason: str) -> bool:
"""End the active nomination of a user with the given reason and return True on success."""
active_nomination = await self.bot.api_client.get(
- self.api_endpoint,
+ 'bot/nominations',
params=ChainMap(
{"user__id": str(user_id)},
self.api_default_params,
@@ -398,12 +489,13 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
nomination = active_nomination[0]
await self.bot.api_client.patch(
- f"{self.api_endpoint}/{nomination['id']}",
+ f"bot/nominations/{nomination['id']}",
json={'end_reason': reason, 'active': False}
)
- self._remove_user(user_id)
- self.reviewer.cancel(user_id)
+ self.cache.pop(user_id)
+ if await self.autoreview_enabled():
+ self.reviewer.cancel(user_id)
return True
@@ -450,7 +542,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
{entries_string}
End date: {end_date}
- Unwatch reason: {nomination_object["end_reason"]}
+ Unnomination reason: {nomination_object["end_reason"]}
===============
"""
)
diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py
index 0cb786e4b..3ffbf93f3 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:
@@ -55,9 +58,9 @@ class Reviewer:
log.trace("Rescheduling reviews")
await self.bot.wait_until_guild_available()
# TODO Once the watch channel is removed, this can be done in a smarter way, e.g create a sync function.
- await self._pool.fetch_user_cache()
+ await self._pool.refresh_cache()
- for user_id, user_data in self._pool.watched_users.items():
+ for user_id, user_data in self._pool.cache.items():
if not user_data["reviewed"]:
self.schedule_review(user_id)
@@ -65,7 +68,7 @@ class Reviewer:
"""Schedules a single user for review."""
log.trace(f"Scheduling review of user with ID {user_id}")
- user_data = self._pool.watched_users.get(user_id)
+ user_data = self._pool.cache.get(user_id)
inserted_at = isoparse(user_data['inserted_at']).replace(tzinfo=None)
review_at = inserted_at + timedelta(days=MAX_DAYS_IN_POOL)
@@ -93,18 +96,18 @@ class Reviewer:
await last_message.add_reaction(reaction)
if update_database:
- nomination = self._pool.watched_users.get(user_id)
- await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True})
+ nomination = self._pool.cache.get(user_id)
+ await self.bot.api_client.patch(f"bot/nominations/{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 reviewed emoji."""
log.trace(f"Formatting the review of {user_id}")
- # Since `watched_users` is a defaultdict, we should take care
+ # Since `cache` is a defaultdict, we should take care
# not to accidentally insert the IDs of users that have no
- # active nominated by using the `watched_users.get(user_id)`
- # instead of `watched_users[user_id]`.
- nomination = self._pool.watched_users.get(user_id)
+ # active nominated by using the `cache.get(user_id)`
+ # instead of `cache[user_id]`.
+ nomination = self._pool.cache.get(user_id)
if not nomination:
log.trace(f"There doesn't appear to be an active nomination for {user_id}")
return "", None
@@ -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*'}"
@@ -141,14 +144,14 @@ class Reviewer:
"""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,7 +163,7 @@ 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
reviewed = await count_unique_users_reaction(
@@ -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}."
)
@@ -329,7 +332,7 @@ class Reviewer:
"""
log.trace(f"Fetching the nomination history data for {member.id}'s review")
history = await self.bot.api_client.get(
- self._pool.api_endpoint,
+ "bot/nominations",
params={
"user__id": str(member.id),
"active": "false",
@@ -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"
@@ -387,18 +390,18 @@ class Reviewer:
Returns True if the user was successfully marked as reviewed, False otherwise.
"""
log.trace(f"Updating user {user_id} as reviewed")
- await self._pool.fetch_user_cache()
- if user_id not in self._pool.watched_users:
+ await self._pool.refresh_cache()
+ if user_id not in self._pool.cache:
log.trace(f"Can't find a nominated user with id {user_id}")
await ctx.send(f":x: Can't find a currently nominated user with id `{user_id}`")
return False
- nomination = self._pool.watched_users.get(user_id)
+ nomination = self._pool.cache.get(user_id)
if nomination["reviewed"]:
await ctx.send(":x: This nomination was already reviewed, but here's a cookie :cookie:")
return False
- await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True})
+ await self.bot.api_client.patch(f"bot/nominations/{nomination['id']}", json={"reviewed": True})
if user_id in self._review_scheduler:
self._review_scheduler.cancel(user_id)
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 3b8564aee..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__)
@@ -50,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}"
@@ -157,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.")
@@ -175,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,
diff --git a/bot/pagination.py b/bot/pagination.py
index 1c5b94b07..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,8 +49,8 @@ class LinePaginator(Paginator):
self,
prefix: str = '```',
suffix: str = '```',
- max_size: int = 2000,
- scale_to_size: int = 2000,
+ max_size: int = 4000,
+ scale_to_size: int = 4000,
max_lines: t.Optional[int] = None,
linesep: str = "\n"
) -> None:
@@ -59,10 +59,10 @@ class LinePaginator(Paginator):
It overrides in order to allow us to configure the maximum number of lines per page.
"""
- # 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)")
+ # 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,
@@ -74,8 +74,8 @@ class LinePaginator(Paginator):
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
@@ -197,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,
@@ -233,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/botvar.md b/bot/resources/tags/botvar.md
new file mode 100644
index 000000000..3db6ae7ac
--- /dev/null
+++ b/bot/resources/tags/botvar.md
@@ -0,0 +1,23 @@
+Python allows you to set custom attributes to most objects, like your bot! By storing things as attributes of the bot object, 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/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 d4a921161..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),
- suppressed_exceptions=(HTTPException,),
+ 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/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 f4fdc7606..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
@@ -188,6 +192,7 @@ guild:
bot_commands: &BOT_CMD 267659945086812160
esoteric: 470884583684964352
voice_gate: 764802555427029012
+ code_jam_planning: 490217981872177157
# Staff
admins: &ADMINS 365960823622991872
@@ -212,20 +217,21 @@ 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
- talent_pool: &TALENT_POOL 534321732593647616
moderation_categories:
- *MODS_CATEGORY
@@ -256,7 +262,7 @@ guild:
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
@@ -264,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
@@ -274,7 +282,6 @@ guild:
# Code Jam
jammers: 737249140966162473
- team_leaders: 737250302834638889
# Streaming
video: 764245844798079016
@@ -297,7 +304,6 @@ guild:
duck_pond: 637821475327311927
incidents_archive: 720671599790915702
python_news: &PYNEWS_WEBHOOK 704381182279942324
- talent_pool: 569145364800602132
filter:
@@ -328,7 +334,6 @@ filter:
- *MESSAGE_LOG
- *MOD_LOG
- *STAFF_LOUNGE
- - *TALENT_POOL
role_whitelist:
- *ADMINS_ROLE
@@ -337,6 +342,7 @@ filter:
- *OWNERS_ROLE
- *PY_COMMUNITY_ROLE
- *SPRINTERS
+ - *PY_PARTNER_ROLE
keys:
@@ -371,6 +377,8 @@ urls:
anti_spam:
+ cache_size: 100
+
# Clean messages that violate a rule.
clean_offending: true
ping_everyone: true
@@ -426,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/poetry.lock b/poetry.lock
index 2041824e2..81b51b8da 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"
@@ -147,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"
@@ -171,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
@@ -182,7 +174,7 @@ pycparser = "*"
[[package]]
name = "cfgv"
-version = "3.3.0"
+version = "3.3.1"
description = "Validate configuration and produce human readable error messages."
category = "dev"
optional = false
@@ -197,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."
@@ -314,7 +317,7 @@ testing = ["pre-commit"]
[[package]]
name = "fakeredis"
-version = "1.5.2"
+version = "1.6.0"
description = "Fake implementation of redis API for testing purposes."
category = "main"
optional = false
@@ -326,7 +329,7 @@ six = ">=1.12"
sortedcontainers = "*"
[package.extras]
-aioredis = ["aioredis (<2)"]
+aioredis = ["aioredis"]
lua = ["lupa"]
[[package]]
@@ -434,14 +437,14 @@ flake8 = "*"
[[package]]
name = "flake8-tidy-imports"
-version = "4.3.0"
+version = "4.4.1"
description = "A flake8 plugin that helps you write tidier imports."
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
-flake8 = ">=3.0,<3.2.0 || >3.2.0,<4"
+flake8 = ">=3.8.0,<4"
[[package]]
name = "flake8-todo"
@@ -455,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"
@@ -486,7 +478,7 @@ pyreadline = {version = "*", markers = "sys_platform == \"win32\""}
[[package]]
name = "identify"
-version = "2.2.10"
+version = "2.2.13"
description = "File identification library for Python"
category = "dev"
optional = false
@@ -587,11 +579,11 @@ python-versions = ">=3.5"
[[package]]
name = "packaging"
-version = "20.9"
+version = "21.0"
description = "Core utilities for Python packages"
category = "dev"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+python-versions = ">=3.6"
[package.dependencies]
pyparsing = ">=2.0.2"
@@ -609,16 +601,43 @@ codegen = ["lxml"]
[[package]]
name = "pep8-naming"
-version = "0.11.1"
+version = "0.12.1"
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 = "pip-licenses"
+version = "3.5.2"
+description = "Dump the software license list of Python packages installed with pip."
+category = "dev"
+optional = false
+python-versions = "~=3.6"
+
+[package.dependencies]
+PTable = "*"
+
+[package.extras]
+test = ["docutils", "pytest-cov", "pytest-pycodestyle", "pytest-runner"]
+
+[[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"
@@ -631,7 +650,7 @@ dev = ["pre-commit", "tox"]
[[package]]
name = "pre-commit"
-version = "2.13.0"
+version = "2.14.0"
description = "A framework for managing and maintaining multi-language pre-commit hooks."
category = "dev"
optional = false
@@ -657,6 +676,14 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
test = ["ipaddress", "mock", "unittest2", "enum34", "pywin32", "wmi"]
[[package]]
+name = "ptable"
+version = "0.9.2"
+description = "A simple Python library for easily displaying tabular data in a visually appealing ASCII table format"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
name = "py"
version = "1.10.0"
description = "library with cross-python path, ini-parsing, io, code, log facilities"
@@ -801,7 +828,7 @@ 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
@@ -845,6 +872,14 @@ optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
[[package]]
+name = "rapidfuzz"
+version = "1.5.0"
+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"
@@ -865,19 +900,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
@@ -896,6 +937,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)"]
@@ -995,21 +1037,22 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[[package]]
name = "virtualenv"
-version = "20.4.7"
+version = "20.7.2"
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"
@@ -1026,7 +1069,7 @@ multidict = ">=4.0"
[metadata]
lock-version = "1.1"
python-versions = "3.9.*"
-content-hash = "feec7372374cc4025f407b64b2e5b45c2c9c8d49c4538b91dc372f2bad89a624"
+content-hash = "ceddbb2621849f480f736985d71f37cebefd08a9b38bc3943a6f72706258b6ee"
[metadata.files]
aio-pika = [
@@ -1076,10 +1119,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"},
@@ -1088,10 +1127,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"},
@@ -1112,6 +1147,10 @@ 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"},
@@ -1122,52 +1161,64 @@ certifi = [
{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.3.0-py2.py3-none-any.whl", hash = "sha256:b449c9c6118fe8cca7fa5e00b9ec60ba08145d281d52164230a69211c5d597a1"},
- {file = "cfgv-3.3.0.tar.gz", hash = "sha256:9e600479b3b99e8af981ecdfc80a0296104ee610cab48a5ae4ffd0b668650eb1"},
+ {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"},
+ {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"},
]
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"},
@@ -1257,8 +1308,8 @@ execnet = [
{file = "execnet-1.9.0.tar.gz", hash = "sha256:8f694f3ba9cc92cab508b152dcfe322153975c29bda272e2fd7f3f00f36e47c5"},
]
fakeredis = [
- {file = "fakeredis-1.5.2-py3-none-any.whl", hash = "sha256:f1ffdb134538e6d7c909ddfb4fc5edeb4a73d0ea07245bc69b8135fbc4144b04"},
- {file = "fakeredis-1.5.2.tar.gz", hash = "sha256:18fc1808d2ce72169d3f11acdb524a00ef96bd29970c6d34cfeb2edb3fc0c020"},
+ {file = "fakeredis-1.6.0-py3-none-any.whl", hash = "sha256:3449b306f3a85102b28f8180c24722ef966fcb1e3c744758b6f635ec80321a5c"},
+ {file = "fakeredis-1.6.0.tar.gz", hash = "sha256:11ccfc9769d718d37e45b382e64a6ba02586b622afa0371a6bd85766d72255f3"},
]
feedparser = [
{file = "feedparser-6.0.8-py3-none-any.whl", hash = "sha256:1b7f57841d9cf85074deb316ed2c795091a238adb79846bc46dccdaf80f9c59a"},
@@ -1297,16 +1348,12 @@ flake8-string-format = [
{file = "flake8_string_format-0.3.0-py2.py3-none-any.whl", hash = "sha256:812ff431f10576a74c89be4e85b8e075a705be39bc40c4b4278b5b13e2afa9af"},
]
flake8-tidy-imports = [
- {file = "flake8-tidy-imports-4.3.0.tar.gz", hash = "sha256:e66d46f58ed108f36da920e7781a728dc2d8e4f9269e7e764274105700c0a90c"},
- {file = "flake8_tidy_imports-4.3.0-py3-none-any.whl", hash = "sha256:d6e64cb565ca9474d13d5cb3f838b8deafb5fed15906998d4a674daf55bd6d89"},
+ {file = "flake8-tidy-imports-4.4.1.tar.gz", hash = "sha256:c18b3351b998787db071e766e318da1f0bd9d5cecc69c4022a69e7aa2efb2c51"},
+ {file = "flake8_tidy_imports-4.4.1-py3-none-any.whl", hash = "sha256:631a1ba9daaedbe8bb53f6086c5a92b390e98371205259e0e311a378df8c3dc8"},
]
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"},
@@ -1355,8 +1402,8 @@ humanfriendly = [
{file = "humanfriendly-9.2.tar.gz", hash = "sha256:f7dba53ac7935fd0b4a2fc9a29e316ddd9ea135fb3052d3d0279d10c18ff9c48"},
]
identify = [
- {file = "identify-2.2.10-py2.py3-none-any.whl", hash = "sha256:18d0c531ee3dbc112fa6181f34faa179de3f57ea57ae2899754f16a7e0ff6421"},
- {file = "identify-2.2.10.tar.gz", hash = "sha256:5b41f71471bc738e7b586308c3fca172f78940195cb3bf6734c1e66fdac49306"},
+ {file = "identify-2.2.13-py2.py3-none-any.whl", hash = "sha256:7199679b5be13a6b40e6e19ea473e789b11b4e3b60986499b1f589ffb03c217c"},
+ {file = "identify-2.2.13.tar.gz", hash = "sha256:7bc6e829392bd017236531963d2d937d66fc27cadc643ac0aba2ce9f26157c79"},
]
idna = [
{file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"},
@@ -1477,24 +1524,32 @@ ordered-set = [
{file = "ordered-set-4.0.2.tar.gz", hash = "sha256:ba93b2df055bca202116ec44b9bead3df33ea63a7d5827ff8e16738b97f33a95"},
]
packaging = [
- {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"},
- {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"},
+ {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.1.tar.gz", hash = "sha256:bb2455947757d162aa4cad55dba4ce029005cd1692f2899a21d51d8630ca7841"},
+ {file = "pep8_naming-0.12.1-py2.py3-none-any.whl", hash = "sha256:4a8daeaeb33cfcde779309fc0c9c0a68a3bbe2ad8a8308b763c5068f86eb9f37"},
+]
+pip-licenses = [
+ {file = "pip-licenses-3.5.2.tar.gz", hash = "sha256:c5e984f461b34ad04dafa151d0048eb9d049e3d6439966c6440bb6b53ad077b6"},
+ {file = "pip_licenses-3.5.2-py3-none-any.whl", hash = "sha256:62deafc82d5dccea1a4cab55172706e02f228abcd67f4d53e382fcb1497e9b62"},
+]
+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.13.0-py2.py3-none-any.whl", hash = "sha256:b679d0fddd5b9d6d98783ae5f10fd0c4c59954f375b70a58cbe1ce9bcf9809a4"},
- {file = "pre_commit-2.13.0.tar.gz", hash = "sha256:764972c60693dc668ba8e86eb29654ec3144501310f7198742a767bec385a378"},
+ {file = "pre_commit-2.14.0-py2.py3-none-any.whl", hash = "sha256:ec3045ae62e1aa2eecfb8e86fa3025c2e3698f77394ef8d2011ce0aedd85b2d4"},
+ {file = "pre_commit-2.14.0.tar.gz", hash = "sha256:2386eeb4cf6633712c7cc9ede83684d53c8cafca6b59f79c738098b51c6d206c"},
]
psutil = [
{file = "psutil-5.8.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:0066a82f7b1b37d334e68697faba68e5ad5e858279fd6351c8ca6024e8d6ba64"},
@@ -1526,6 +1581,9 @@ psutil = [
{file = "psutil-5.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:f4634b033faf0d968bb9220dd1c793b897ab7f1189956e1aa9eae752527127d3"},
{file = "psutil-5.8.0.tar.gz", hash = "sha256:0c9ccb99ab76025f2f0bbecf341d4656e9c1351db8cc8a03ccd62e318ab4b5c6"},
]
+ptable = [
+ {file = "PTable-0.9.2.tar.gz", hash = "sha256:aa7fc151cb40f2dabcd2275ba6f7fd0ff8577a86be3365cd3fb297cbe09cc292"},
+]
py = [
{file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"},
{file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"},
@@ -1607,8 +1665,8 @@ pytest-xdist = [
{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"},
@@ -1625,22 +1683,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.5.0-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:670a330e90e962de5823e01e8ae1b8903af788325fbce1ef3fd5ece4d22e0ba4"},
+ {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:079afafa6e6b00ee799e16d9fc6c6522132cbd7742a7a9e78bd301321e1b5ad6"},
+ {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:26cb066e79c9867d313450514bb70124d392ac457640c4ec090d29eb68b75541"},
+ {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:542fbe8fb4403af36bfffd53e42cb1ff3f8d969a046208373d004804072b744c"},
+ {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:407a5c4d2af813e803b828b004f8686300baf298e9bf90b3388a568b1637a8dc"},
+ {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:662b4021951ac9edb9a0d026820529e891cea69c11f280188c5b80fefe6ee257"},
+ {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2014_ppc64le.whl", hash = "sha256:03c97beb1c7ce5cb1d12bbb8eb87777e9a5fad23216dab78d6850cafdd3ecaf1"},
+ {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2014_s390x.whl", hash = "sha256:eaafa0349d47850ed2c3ae121b62e078a63daf1d533b1cd43fca0c675a85a025"},
+ {file = "rapidfuzz-1.5.0-cp35-cp35m-win32.whl", hash = "sha256:f0b7e15209208ee74bc264b97e111a3c73e19336eda7255c406e56cc6fbbd384"},
+ {file = "rapidfuzz-1.5.0-cp35-cp35m-win_amd64.whl", hash = "sha256:0679af3d85082dcb27e75ea30c5047dbcc99340f38490c7d4769ae16909c246a"},
+ {file = "rapidfuzz-1.5.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3a3ef319fd1162e7e38bf11259d86fc6ea3885d2abae6359e5b4dafad62592db"},
+ {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:60ea1cee33a5a847aeac91a35865c6f7f35a87613df282bda2e7f984e91526f5"},
+ {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2ba6ffe8ac66dbeae91a0b2cb50f4836ec16920f58746eaf46ff3e9c4f9c0ad8"},
+ {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:7c101bafb27436affcaa14c631e2bf99d6a7a7860a201ce17ee98447c9c0e7f4"},
+ {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:a8f3f374b4e8e80516b955a1da6364c526d480311a5c6be48264cf7dc06d2fba"},
+ {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:f2fe161526cce52eae224c2af9ae1b9c475ae3e1001fe76024603b290bc8f719"},
+ {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:8b086b2f70571c9bf16ead5f65976414f8e75a1c680220a839b8ddf005743060"},
+ {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:814cd474c31db0383c69eed5b457571f63521f38829955c842b141b4835f067f"},
+ {file = "rapidfuzz-1.5.0-cp36-cp36m-win32.whl", hash = "sha256:0a901aa223a4b051846cb828c33967a6f9c66b8fe0ba7e2a4dc70f6612006988"},
+ {file = "rapidfuzz-1.5.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f03a5fa9fe38d7f8d566bff0b66600f488d56700469bf1e5e36078f4b58290b6"},
+ {file = "rapidfuzz-1.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:122b7c25792eb27ca59ab23623a922a7290d881d296556d0c23da63ed1691cd5"},
+ {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:73509dbfcf556233d62683aed0e5f23282ec7138eeedc3ecda2938ad8e8c969d"},
+ {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6e8c4fd87361699e0cf5cf7ff075e4cd70a2698e9f914368f0c3e198c77c755c"},
+ {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d627ec73d324d804af4c95909e2fa30b0e59f7efaf69264e553a0e498034404b"},
+ {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:c57f3b74942ae0d0869336e613cbd0760de61a462ff441095eb5fca6575cf964"},
+ {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:075b8bf76dd4bbc9ccb5177806c9867424d365898415433bf88e7b8e88dc4dfe"},
+ {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:8049a500b431724d283ddf97d67fe48aa67b4523d617a203c22fd9da3a496223"},
+ {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:a2d84fde07c32514758d283dd1227453db3ed5372a3e9eae85d0c29b2953f252"},
+ {file = "rapidfuzz-1.5.0-cp37-cp37m-win32.whl", hash = "sha256:0e35b9b92a955018ebd09d4d9d70f8e81a0106fe1ed04bc82e3a05166cd04ea5"},
+ {file = "rapidfuzz-1.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:8ae7bf62f0382d13e9b36babc897742bac5e7ee04b4e5e94cd67085bfccfd2fd"},
+ {file = "rapidfuzz-1.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:466d9c644fa235278ef376eefb1fc4382107b07764fbc3c7280533ad9ce49bb4"},
+ {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:d04a8465738363d0b9ee39abb3b289e1198d1f3cbc98bc43b8e21ec8e0b21774"},
+ {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2c1ce8e8419ac8462289a6e021b8802701ea0f111ebde7607ba3c9588c3d6f30"},
+ {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:f44564a29e96af0925e68733859d8247a692968034e1b37407d9cfa746d3a853"},
+ {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:d2d1bea50f54387bc1e82b93f6e3a433084e0fa538a7ada8e4d4d7200bae4b83"},
+ {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:b409f0f86a316b6132253258185c7b011e779ed2170d1ad83c79515fea7d78c8"},
+ {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:bf5a6f4f2eb44f32271e9c2d1e46b657764dbd1b933dd84d7c0433eab48741f8"},
+ {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bbdee2e3c2cee9c59e1d1a3f351760a1b510e96379d14ba2fa2484a79f56d0ea"},
+ {file = "rapidfuzz-1.5.0-cp38-cp38-win32.whl", hash = "sha256:575a0eceaf84632f2014fd55a42a0621e448115adf6fcbc2b0e5c7ae1c18b501"},
+ {file = "rapidfuzz-1.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:cd6603b94e2a3d56d143a5100f8f3c1d29ad8f5416bdc2a25b079f96eee3c306"},
+ {file = "rapidfuzz-1.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3fa261479e3828eff1f3d0265def8d0d893f2e2f90692d5dae96b3f4ae44d69e"},
+ {file = "rapidfuzz-1.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7a386fe0aad7e89b5017768492ea085d241c32f6dc5a6774b0a309d28f61e720"},
+ {file = "rapidfuzz-1.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68156a67d541bb4584cb31e366fb7de9326f5b77ed07f9882e9b9aaa40b2e5b8"},
+ {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:b62b2a2d2532d357d1b970107a90e85305bdd8e302995dd251f67a19495033f5"},
+ {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:190b48ba8e3fbcb1cfc522300dbd6a007f50c13cd71002c95bd3946a63b749f6"},
+ {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:51f9ac3316e713b4a10554a4d6b75fe6f802dd9b4073082cc98968ace6377cac"},
+ {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:e00198aa7ca8408616d9821501ff90157c429c952d55a2a53987a9b064f73d49"},
+ {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:5784c24e2de539064d8d5ce3f68756630b54fc33af31e054373a65bbed68823a"},
+ {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:712a4d510c466d6ca75138dad53a1cbd8db0da4bbfa5fc431fcebb0a426e5323"},
+ {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:2647e00e2211ed741aecb4e676461b7202ce46d536c3439ede911b088432b7a4"},
+ {file = "rapidfuzz-1.5.0-cp39-cp39-win32.whl", hash = "sha256:0b77ca0dacb129e878c2583295b76e12da890bd091115417d23b4049b02c2566"},
+ {file = "rapidfuzz-1.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:dec0d429d117ffd7df1661e5f6ca56bfb6806e117be0b75b5d414df43aa4b6d5"},
+ {file = "rapidfuzz-1.5.0-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a533d17d177d11b7c177c849adb728035621462f6ce2baaeb9cf1f42ba3e326c"},
+ {file = "rapidfuzz-1.5.0-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:ac9a2d5a47a4a4eab060882a162d3626889abdec69f899a59fe7b9e01ce122c9"},
+ {file = "rapidfuzz-1.5.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:0e6e2f02bb67a35d75a5613509bb49f0050c0ec4471a9af14da3ad5488d6d5ff"},
+ {file = "rapidfuzz-1.5.0-pp36-pypy36_pp73-win32.whl", hash = "sha256:8c61ced6729146e695ecad403165bf3a07e60b8e8a18df91962b3abf72aae6d5"},
+ {file = "rapidfuzz-1.5.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:360415125e967d8682291f00bcea311c738101e0aee4cb90e5572d7e54483f0d"},
+ {file = "rapidfuzz-1.5.0-pp37-pypy37_pp73-manylinux1_x86_64.whl", hash = "sha256:2fb9d47fc16a2e8f5e900c8334d823a7307148ea764321f861b876f85a880d57"},
+ {file = "rapidfuzz-1.5.0-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:2134ac91e8951d42c9a7de131d767580b8ac50820475221024e5bd63577a376f"},
+ {file = "rapidfuzz-1.5.0-pp37-pypy37_pp73-win32.whl", hash = "sha256:04c4fd372e858f25e0898ba27b5bb7ed8dc528b0915b7aa02d20237e9cdd4feb"},
+ {file = "rapidfuzz-1.5.0.tar.gz", hash = "sha256:141ee381c16f7e58640ef1f1dbf76beb953d248297a7165f7ba25d81ac1161c7"},
+]
redis = [
{file = "redis-3.5.3-py2.py3-none-any.whl", hash = "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"},
{file = "redis-3.5.3.tar.gz", hash = "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2"},
@@ -1689,12 +1818,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"},
@@ -1737,8 +1866,8 @@ urllib3 = [
{file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"},
]
virtualenv = [
- {file = "virtualenv-20.4.7-py2.py3-none-any.whl", hash = "sha256:2b0126166ea7c9c3661f5b8e06773d28f83322de7a3ff7d06f0aed18c9de6a76"},
- {file = "virtualenv-20.4.7.tar.gz", hash = "sha256:14fdf849f80dbb29a4eb6caa9875d476ee2a5cf76a5f5415fa2f1606010ab467"},
+ {file = "virtualenv-20.7.2-py2.py3-none-any.whl", hash = "sha256:e4670891b3a03eb071748c569a87cceaefbf643c5bac46d996c5a45c34aa0f06"},
+ {file = "virtualenv-20.7.2.tar.gz", hash = "sha256:9ef4e8ee4710826e98ff3075c9a4739e2cb1040de6a2a8d35db0055840dc96a0"},
]
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 c76bb47d6..23cbba19b 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"] }
@@ -21,7 +20,7 @@ deepdiff = "~=4.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]
@@ -46,6 +45,7 @@ flake8-todo = "~=0.7"
pep8-naming = "~=0.9"
pre-commit = "~=2.1"
taskipy = "~=1.7.0"
+pip-licenses = "~=3.5.2"
python-dotenv = "~=0.17.1"
pytest = "~=6.2.4"
pytest-cov = "~=2.12.1"
diff --git a/tests/README.md b/tests/README.md
index 0192f916e..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:
@@ -25,6 +33,29 @@ To ensure the results you obtain on your personal machine are comparable to thos
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 eafcbae6c..ce59ee5fa 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/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 50a717bb5..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:
@@ -137,7 +137,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
title=utils.INFRACTION_TITLE,
description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
type="Ban",
- expires="2020-02-26 09:20 (23 hours and 59 minutes) UTC",
+ expires="2020-02-26 09:20 (23 hours and 59 minutes)",
reason="No reason provided."
),
colour=Colours.soft_red,
@@ -193,7 +193,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
title=utils.INFRACTION_TITLE,
description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
type="Mute",
- expires="2020-02-26 09:20 (23 hours and 59 minutes) UTC",
+ expires="2020-02-26 09:20 (23 hours and 59 minutes)",
reason="Test"
),
colour=Colours.soft_red,
@@ -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 d0d7af1ba..f84de453d 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,
)
@@ -25,30 +24,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_package_name_for_valid(self):
"""PackageName returns valid package names unchanged."""
test_values = ('foo', 'le_mon', 'num83r')
@@ -262,7 +237,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)