aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Johannes Christ <[email protected]>2021-12-08 13:51:45 +0100
committerGravatar GitHub <[email protected]>2021-12-08 13:51:45 +0100
commit5985ac1c193826380e8a16f44be2d9b91e1354b7 (patch)
tree02cce777db98e29ec165a27a087db67f815caf3e
parentMerge remote-tracking branch 'upstream/main' into tag-groups (diff)
parentMerge pull request #1634 from Shivansh-007/modpings-schedule (diff)
Merge branch 'main' into tag-groups
-rw-r--r--bot/__init__.py5
-rw-r--r--bot/constants.py15
-rw-r--r--bot/converters.py40
-rw-r--r--bot/exts/backend/branding/_cog.py4
-rw-r--r--bot/exts/backend/error_handler.py58
-rw-r--r--bot/exts/events/code_jams/_cog.py2
-rw-r--r--bot/exts/filters/antispam.py9
-rw-r--r--bot/exts/filters/filter_lists.py7
-rw-r--r--bot/exts/filters/filtering.py113
-rw-r--r--bot/exts/filters/token_remover.py2
-rw-r--r--bot/exts/filters/webhook_remover.py2
-rw-r--r--bot/exts/fun/duck_pond.py4
-rw-r--r--bot/exts/fun/off_topic_names.py7
-rw-r--r--bot/exts/help_channels/_cog.py53
-rw-r--r--bot/exts/help_channels/_message.py6
-rw-r--r--bot/exts/info/doc/_batch_parser.py22
-rw-r--r--bot/exts/info/doc/_cog.py1
-rw-r--r--bot/exts/info/doc/_redis_cache.py43
-rw-r--r--bot/exts/info/help.py153
-rw-r--r--bot/exts/info/information.py22
-rw-r--r--bot/exts/info/pep.py7
-rw-r--r--bot/exts/info/pypi.py2
-rw-r--r--bot/exts/info/site.py17
-rw-r--r--bot/exts/info/subscribe.py201
-rw-r--r--bot/exts/moderation/clean.py596
-rw-r--r--bot/exts/moderation/defcon.py26
-rw-r--r--bot/exts/moderation/incidents.py269
-rw-r--r--bot/exts/moderation/infraction/_scheduler.py42
-rw-r--r--bot/exts/moderation/infraction/_utils.py20
-rw-r--r--bot/exts/moderation/infraction/management.py73
-rw-r--r--bot/exts/moderation/modlog.py215
-rw-r--r--bot/exts/moderation/modpings.py124
-rw-r--r--bot/exts/moderation/silence.py38
-rw-r--r--bot/exts/moderation/verification.py71
-rw-r--r--bot/exts/moderation/voice_gate.py13
-rw-r--r--bot/exts/moderation/watchchannels/_watchchannel.py9
-rw-r--r--bot/exts/recruitment/talentpool/_cog.py14
-rw-r--r--bot/exts/recruitment/talentpool/_review.py62
-rw-r--r--bot/exts/utils/bot.py17
-rw-r--r--bot/exts/utils/clean.py274
-rw-r--r--bot/exts/utils/extensions.py2
-rw-r--r--bot/exts/utils/internal.py15
-rw-r--r--bot/exts/utils/ping.py5
-rw-r--r--bot/exts/utils/reminders.py34
-rw-r--r--bot/exts/utils/utils.py2
-rw-r--r--bot/log.py15
-rw-r--r--bot/monkey_patches.py30
-rw-r--r--bot/resources/tags/off-topic.md4
-rw-r--r--bot/resources/tags/sql-fstring.md16
-rw-r--r--bot/utils/channel.py9
-rw-r--r--bot/utils/checks.py7
-rw-r--r--bot/utils/members.py23
-rw-r--r--bot/utils/messages.py2
-rw-r--r--bot/utils/regex.py19
-rw-r--r--bot/utils/time.py19
-rw-r--r--config-default.yml18
-rw-r--r--docker-compose.yml1
-rw-r--r--poetry.lock200
-rw-r--r--pyproject.toml4
-rw-r--r--tests/base.py2
-rw-r--r--tests/bot/exts/backend/test_error_handler.py34
-rw-r--r--tests/bot/exts/filters/test_filtering.py40
-rw-r--r--tests/bot/exts/filters/test_token_remover.py4
-rw-r--r--tests/bot/exts/info/test_information.py15
-rw-r--r--tests/bot/exts/moderation/infraction/test_utils.py20
-rw-r--r--tests/bot/exts/moderation/test_incidents.py98
-rw-r--r--tests/bot/exts/moderation/test_silence.py64
-rw-r--r--tests/bot/test_converters.py50
-rw-r--r--tests/bot/utils/test_checks.py1
-rw-r--r--tests/bot/utils/test_time.py27
-rw-r--r--tests/helpers.py27
-rw-r--r--tox.ini2
72 files changed, 2521 insertions, 946 deletions
diff --git a/bot/__init__.py b/bot/__init__.py
index a1c4466f1..17d99105a 100644
--- a/bot/__init__.py
+++ b/bot/__init__.py
@@ -18,6 +18,11 @@ if os.name == "nt":
monkey_patches.patch_typing()
+# This patches any convertors that use PartialMessage, but not the PartialMessageConverter itself
+# as library objects are made by this mapping.
+# https://github.com/Rapptz/discord.py/blob/1a4e73d59932cdbe7bf2c281f25e32529fc7ae1f/discord/ext/commands/converter.py#L984-L1004
+commands.converter.PartialMessageConverter = monkey_patches.FixedPartialMessageConverter
+
# Monkey-patch discord.py decorators to use the Command subclass which supports root aliases.
# Must be patched before any cogs are added.
commands.command = partial(commands.command, cls=monkey_patches.Command)
diff --git a/bot/constants.py b/bot/constants.py
index f704c9e6a..078ab6912 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -444,6 +444,7 @@ class Channels(metaclass=YAMLGetter):
incidents: int
incidents_archive: int
mod_alerts: int
+ mod_meta: int
nominations: int
nomination_voting: int
organisation: int
@@ -475,6 +476,7 @@ class Webhooks(metaclass=YAMLGetter):
big_brother: int
dev_log: int
duck_pond: int
+ incidents: int
incidents_archive: int
@@ -482,7 +484,12 @@ class Roles(metaclass=YAMLGetter):
section = "guild"
subsection = "roles"
+ # Self-assignable roles, see the Subscribe cog
+ advent_of_code: int
announcements: int
+ lovefest: int
+ pyweek_announcements: int
+
contributors: int
help_cooldown: int
muted: int
@@ -681,8 +688,16 @@ class VideoPermission(metaclass=YAMLGetter):
default_permission_duration: int
+class ThreadArchiveTimes(Enum):
+ HOUR = 60
+ DAY = 1440
+ THREE_DAY = 4320
+ WEEK = 10080
+
+
# Debug mode
DEBUG_MODE: bool = _CONFIG_YAML["debug"] == "true"
+FILE_LOGS: bool = _CONFIG_YAML["file_logs"].lower() == "true"
# Paths
BOT_DIR = os.path.dirname(__file__)
diff --git a/bot/converters.py b/bot/converters.py
index d1ebb641b..559e759e1 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -2,7 +2,7 @@ from __future__ import annotations
import re
import typing as t
-from datetime import datetime
+from datetime import datetime, timezone
from ssl import CertificateError
import dateutil.parser
@@ -11,7 +11,7 @@ import discord
from aiohttp import ClientConnectorError
from dateutil.relativedelta import relativedelta
from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, MemberConverter, UserConverter
-from discord.utils import DISCORD_EPOCH, escape_markdown, snowflake_time
+from discord.utils import escape_markdown, snowflake_time
from bot import exts
from bot.api import ResponseCodeError
@@ -29,7 +29,7 @@ if t.TYPE_CHECKING:
log = get_logger(__name__)
-DISCORD_EPOCH_DT = datetime.utcfromtimestamp(DISCORD_EPOCH / 1000)
+DISCORD_EPOCH_DT = snowflake_time(0)
RE_USER_MENTION = re.compile(r"<@!?([0-9]+)>$")
@@ -72,10 +72,10 @@ class ValidDiscordServerInvite(Converter):
async def convert(self, ctx: Context, server_invite: str) -> dict:
"""Check whether the string is a valid Discord server invite."""
- invite_code = INVITE_RE.search(server_invite)
+ invite_code = INVITE_RE.match(server_invite)
if invite_code:
response = await ctx.bot.http_session.get(
- f"{URLs.discord_invite_api}/{invite_code[1]}"
+ f"{URLs.discord_invite_api}/{invite_code.group('invite')}"
)
if response.status != 404:
invite_data = await response.json()
@@ -281,7 +281,7 @@ class Snowflake(IDConverter):
if time < DISCORD_EPOCH_DT:
raise BadArgument(f"{error}: timestamp is before the Discord epoch.")
- elif (datetime.utcnow() - time).days < -1:
+ elif (datetime.now(timezone.utc) - time).days < -1:
raise BadArgument(f"{error}: timestamp is too far into the future.")
return snowflake
@@ -354,7 +354,7 @@ class Duration(DurationDelta):
The converter supports the same symbols for each unit of time as its parent class.
"""
delta = await super().convert(ctx, duration)
- now = datetime.utcnow()
+ now = datetime.now(timezone.utc)
try:
return now + delta
@@ -362,6 +362,24 @@ class Duration(DurationDelta):
raise BadArgument(f"`{duration}` results in a datetime outside the supported range.")
+class Age(DurationDelta):
+ """Convert duration strings into UTC datetime.datetime objects."""
+
+ async def convert(self, ctx: Context, duration: str) -> datetime:
+ """
+ Converts a `duration` string to a datetime object that's `duration` in the past.
+
+ The converter supports the same symbols for each unit of time as its parent class.
+ """
+ delta = await super().convert(ctx, duration)
+ now = datetime.now(timezone.utc)
+
+ try:
+ return now - delta
+ except (ValueError, OverflowError):
+ raise BadArgument(f"`{duration}` results in a datetime outside the supported range.")
+
+
class OffTopicName(Converter):
"""A converter that ensures an added off-topic name is valid."""
@@ -410,8 +428,8 @@ class ISODateTime(Converter):
The converter is flexible in the formats it accepts, as it uses the `isoparse` method of
`dateutil.parser`. In general, it accepts datetime strings that start with a date,
optionally followed by a time. Specifying a timezone offset in the datetime string is
- supported, but the `datetime` object will be converted to UTC and will be returned without
- `tzinfo` as a timezone-unaware `datetime` object.
+ supported, but the `datetime` object will be converted to UTC. If no timezone is specified, the datetime will
+ be assumed to be in UTC already. In all cases, the returned object will have the UTC timezone.
See: https://dateutil.readthedocs.io/en/stable/parser.html#dateutil.parser.isoparse
@@ -437,7 +455,8 @@ class ISODateTime(Converter):
if dt.tzinfo:
dt = dt.astimezone(dateutil.tz.UTC)
- dt = dt.replace(tzinfo=None)
+ else: # Without a timezone, assume it represents UTC.
+ dt = dt.replace(tzinfo=dateutil.tz.UTC)
return dt
@@ -566,6 +585,7 @@ if t.TYPE_CHECKING:
SourceConverter = SourceType # noqa: F811
DurationDelta = relativedelta # noqa: F811
Duration = datetime # noqa: F811
+ Age = datetime # noqa: F811
OffTopicName = str # noqa: F811
ISODateTime = datetime # noqa: F811
HushDurationConverter = int # noqa: F811
diff --git a/bot/exts/backend/branding/_cog.py b/bot/exts/backend/branding/_cog.py
index 9c5bdbb4e..0c5839a7a 100644
--- a/bot/exts/backend/branding/_cog.py
+++ b/bot/exts/backend/branding/_cog.py
@@ -294,7 +294,7 @@ class Branding(commands.Cog):
else:
content = "Python Discord is entering a new event!" if is_notification else None
- embed = discord.Embed(description=description[:4096], colour=discord.Colour.blurple())
+ embed = discord.Embed(description=description[:4096], colour=discord.Colour.og_blurple())
embed.set_footer(text=duration[:4096])
await channel.send(content=content, embed=embed)
@@ -573,7 +573,7 @@ class Branding(commands.Cog):
await ctx.send(embed=resp)
return
- embed = discord.Embed(title="Current event calendar", colour=discord.Colour.blurple())
+ embed = discord.Embed(title="Current event calendar", colour=discord.Colour.og_blurple())
# Because Discord embeds can only contain up to 25 fields, we only show the first 25.
first_25 = list(available_events.items())[:25]
diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py
index 44bb10a92..c79c7b2a7 100644
--- a/bot/exts/backend/error_handler.py
+++ b/bot/exts/backend/error_handler.py
@@ -1,5 +1,4 @@
import difflib
-import typing as t
from discord import Embed
from discord.ext.commands import ChannelNotFound, Cog, Context, TextChannelConverter, VoiceChannelConverter, errors
@@ -58,17 +57,23 @@ class ErrorHandler(Cog):
log.trace(f"Command {command} had its error already handled locally; ignoring.")
return
+ debug_message = (
+ f"Command {command} invoked by {ctx.message.author} with error "
+ f"{e.__class__.__name__}: {e}"
+ )
+
if isinstance(e, errors.CommandNotFound) and not getattr(ctx, "invoked_from_error_handler", False):
if await self.try_silence(ctx):
return
- # Try to look for a tag with the command's name
- await self.try_get_tag(ctx)
- return # Exit early to avoid logging.
+ await self.try_get_tag(ctx) # Try to look for a tag with the command's name
elif isinstance(e, errors.UserInputError):
+ log.debug(debug_message)
await self.handle_user_input_error(ctx, e)
elif isinstance(e, errors.CheckFailure):
+ log.debug(debug_message)
await self.handle_check_failure(ctx, e)
elif isinstance(e, errors.CommandOnCooldown):
+ log.debug(debug_message)
await ctx.send(e)
elif isinstance(e, errors.CommandInvokeError):
if isinstance(e.original, ResponseCodeError):
@@ -79,30 +84,25 @@ class ErrorHandler(Cog):
await ctx.send(f"Cannot infract that user. {e.original.reason}")
else:
await self.handle_unexpected_error(ctx, e.original)
- return # Exit early to avoid logging.
elif isinstance(e, errors.ConversionError):
if isinstance(e.original, ResponseCodeError):
await self.handle_api_error(ctx, e.original)
else:
await self.handle_unexpected_error(ctx, e.original)
- return # Exit early to avoid logging.
- elif not isinstance(e, errors.DisabledCommand):
+ elif isinstance(e, errors.DisabledCommand):
+ log.debug(debug_message)
+ else:
# MaxConcurrencyReached, ExtensionError
await self.handle_unexpected_error(ctx, e)
- return # Exit early to avoid logging.
- log.debug(
- f"Command {command} invoked by {ctx.message.author} with error "
- f"{e.__class__.__name__}: {e}"
- )
-
- @staticmethod
- def get_help_command(ctx: Context) -> t.Coroutine:
+ async def send_command_help(self, ctx: Context) -> None:
"""Return a prepared `help` command invocation coroutine."""
if ctx.command:
- return ctx.send_help(ctx.command)
+ self.bot.help_command.context = ctx
+ await ctx.send_help(ctx.command)
+ return
- return ctx.send_help()
+ await ctx.send_help()
async def try_silence(self, ctx: Context) -> bool:
"""
@@ -179,9 +179,6 @@ class ErrorHandler(Cog):
if not any(role.id in MODERATION_ROLES for role in ctx.author.roles):
await self.send_command_suggestion(ctx, ctx.invoked_with)
- # Return to not raise the exception
- return
-
async def send_command_suggestion(self, ctx: Context, command_name: str) -> None:
"""Sends user similar commands if any can be found."""
# No similar tag found, or tag on cooldown -
@@ -226,38 +223,31 @@ class ErrorHandler(Cog):
"""
if isinstance(e, errors.MissingRequiredArgument):
embed = self._get_error_embed("Missing required argument", e.param.name)
- await ctx.send(embed=embed)
- await self.get_help_command(ctx)
self.bot.stats.incr("errors.missing_required_argument")
elif isinstance(e, errors.TooManyArguments):
embed = self._get_error_embed("Too many arguments", str(e))
- await ctx.send(embed=embed)
- await self.get_help_command(ctx)
self.bot.stats.incr("errors.too_many_arguments")
elif isinstance(e, errors.BadArgument):
embed = self._get_error_embed("Bad argument", str(e))
- await ctx.send(embed=embed)
- await self.get_help_command(ctx)
self.bot.stats.incr("errors.bad_argument")
elif isinstance(e, errors.BadUnionArgument):
embed = self._get_error_embed("Bad argument", f"{e}\n{e.errors[-1]}")
- await ctx.send(embed=embed)
- await self.get_help_command(ctx)
self.bot.stats.incr("errors.bad_union_argument")
elif isinstance(e, errors.ArgumentParsingError):
embed = self._get_error_embed("Argument parsing error", str(e))
await ctx.send(embed=embed)
- self.get_help_command(ctx).close()
self.bot.stats.incr("errors.argument_parsing_error")
+ return
else:
embed = self._get_error_embed(
"Input error",
"Something about your input seems off. Check the arguments and try again."
)
- await ctx.send(embed=embed)
- await self.get_help_command(ctx)
self.bot.stats.incr("errors.other_user_input_error")
+ await ctx.send(embed=embed)
+ await self.send_command_help(ctx)
+
@staticmethod
async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None:
"""
@@ -290,8 +280,8 @@ class ErrorHandler(Cog):
async def handle_api_error(ctx: Context, e: ResponseCodeError) -> None:
"""Send an error message in `ctx` for ResponseCodeError and log it."""
if e.status == 404:
- await ctx.send("There does not seem to be anything matching your query.")
log.debug(f"API responded with 404 for command {ctx.command}")
+ await ctx.send("There does not seem to be anything matching your query.")
ctx.bot.stats.incr("errors.api_error_404")
elif e.status == 400:
content = await e.response.json()
@@ -299,12 +289,12 @@ class ErrorHandler(Cog):
await ctx.send("According to the API, your request is malformed.")
ctx.bot.stats.incr("errors.api_error_400")
elif 500 <= e.status < 600:
- await ctx.send("Sorry, there seems to be an internal issue with the API.")
log.warning(f"API responded with {e.status} for command {ctx.command}")
+ await ctx.send("Sorry, there seems to be an internal issue with the API.")
ctx.bot.stats.incr("errors.api_internal_server_error")
else:
- await ctx.send(f"Got an unexpected status code from the API (`{e.status}`).")
log.warning(f"Unexpected API response for command {ctx.command}: {e.status}")
+ await ctx.send(f"Got an unexpected status code from the API (`{e.status}`).")
ctx.bot.stats.incr(f"errors.api_error_{e.status}")
@staticmethod
diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py
index b31d628d5..452199f5f 100644
--- a/bot/exts/events/code_jams/_cog.py
+++ b/bot/exts/events/code_jams/_cog.py
@@ -160,7 +160,7 @@ class CodeJams(commands.Cog):
embed = Embed(
title=str(member),
- colour=Colour.blurple()
+ colour=Colour.og_blurple()
)
embed.add_field(name="Team", value=self.team_name(channel), inline=True)
diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py
index 78ad57b48..ddfd11231 100644
--- a/bot/exts/filters/antispam.py
+++ b/bot/exts/filters/antispam.py
@@ -2,11 +2,12 @@ import asyncio
from collections import defaultdict
from collections.abc import Mapping
from dataclasses import dataclass, field
-from datetime import datetime, timedelta
+from datetime import timedelta
from itertools import takewhile
from operator import attrgetter, itemgetter
from typing import Dict, Iterable, List, Set
+import arrow
from discord import Colour, Member, Message, NotFound, Object, TextChannel
from discord.ext.commands import Cog
@@ -106,7 +107,7 @@ class DeletionContext:
colour=Colour(Colours.soft_red),
title="Spam detected!",
text=mod_alert_message,
- thumbnail=first_message.author.avatar_url_as(static_format="png"),
+ thumbnail=first_message.author.display_avatar.url,
channel_id=Channels.mod_alerts,
ping_everyone=AntiSpamConfig.ping_everyone
)
@@ -177,7 +178,7 @@ class AntiSpam(Cog):
self.cache.append(message)
- earliest_relevant_at = datetime.utcnow() - timedelta(seconds=self.max_interval)
+ earliest_relevant_at = arrow.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:
@@ -185,7 +186,7 @@ class AntiSpam(Cog):
rule_function = RULE_FUNCTION_MAPPING[rule_name]
# 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'])
+ latest_interesting_stamp = arrow.utcnow() - timedelta(seconds=rule_config['interval'])
messages_for_rule = list(
takewhile(lambda msg: msg.created_at > latest_interesting_stamp, relevant_messages)
)
diff --git a/bot/exts/filters/filter_lists.py b/bot/exts/filters/filter_lists.py
index 4b5200684..ee5bd89f3 100644
--- a/bot/exts/filters/filter_lists.py
+++ b/bot/exts/filters/filter_lists.py
@@ -6,6 +6,7 @@ from discord.ext.commands import BadArgument, Cog, Context, IDConverter, group,
from bot import constants
from bot.api import ResponseCodeError
from bot.bot import Bot
+from bot.constants import Channels
from bot.converters import ValidDiscordServerInvite, ValidFilterListType
from bot.log import get_logger
from bot.pagination import LinePaginator
@@ -100,6 +101,12 @@ class FilterLists(Cog):
)
raise
+ # If it is an autoban trigger we send a warning in #mod-meta
+ if comment and "[autoban]" in comment:
+ await self.bot.get_channel(Channels.mod_meta).send(
+ f":warning: Heads-up! The new filter `{content}` (`{comment}`) will automatically ban users."
+ )
+
# Insert the item into the cache
self.bot.insert_item_into_filter_list_cache(item)
await ctx.message.add_reaction("✅")
diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py
index 78b7a8d94..8accc61f8 100644
--- a/bot/exts/filters/filtering.py
+++ b/bot/exts/filters/filtering.py
@@ -1,9 +1,11 @@
import asyncio
import re
-from datetime import datetime, timedelta
+import unicodedata
+from datetime import timedelta
from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple, Union
-import dateutil
+import arrow
+import dateutil.parser
import discord.errors
import regex
from async_rediscache import RedisCache
@@ -43,6 +45,23 @@ ZALGO_RE = regex.compile(rf"[\p{{NONSPACING MARK}}\p{{ENCLOSING MARK}}--[{VARIAT
DAYS_BETWEEN_ALERTS = 3
OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days)
+# Autoban
+LINK_PASSWORD = "https://support.discord.com/hc/en-us/articles/218410947-I-forgot-my-Password-Where-can-I-set-a-new-one"
+LINK_2FA = "https://support.discord.com/hc/en-us/articles/219576828-Setting-up-Two-Factor-Authentication"
+AUTO_BAN_REASON = (
+ "Your account has been used to send links to a phishing website. You have been automatically banned. "
+ "If you are not aware of sending them, that means your account has been compromised.\n\n"
+
+ f"Here is a guide from Discord on [how to change your password]({LINK_PASSWORD}).\n\n"
+
+ f"We also highly recommend that you [enable 2 factor authentication on your account]({LINK_2FA}), "
+ "for heightened security.\n\n"
+
+ "Once you have changed your password, feel free to follow the instructions at the bottom of "
+ "this message to appeal your ban."
+)
+AUTO_BAN_DURATION = timedelta(days=4)
+
FilterMatch = Union[re.Match, dict, bool, List[discord.Embed]]
@@ -171,29 +190,43 @@ class Filtering(Cog):
"""
Invoke message filter for message edits.
- If there have been multiple edits, calculate the time delta from the previous edit.
+ Also calculates the time delta from the previous edit or when message was sent if there's no prior edits.
"""
+ # We only care about changes to the message contents/attachments and embed additions, not pin status etc.
+ if all((
+ before.content == after.content, # content hasn't changed
+ before.attachments == after.attachments, # attachments haven't changed
+ len(before.embeds) >= len(after.embeds) # embeds haven't been added
+ )):
+ return
+
if not before.edited_at:
delta = relativedelta(after.edited_at, before.created_at).microseconds
else:
delta = relativedelta(after.edited_at, before.edited_at).microseconds
await self._filter_message(after, delta)
- def get_name_matches(self, name: str) -> List[re.Match]:
- """Check bad words from passed string (name). Return list of matches."""
- name = self.clean_input(name)
- matches = []
+ def get_name_match(self, name: str) -> Optional[re.Match]:
+ """Check bad words from passed string (name). Return the first match found."""
+ normalised_name = unicodedata.normalize("NFKC", name)
+ cleaned_normalised_name = "".join([c for c in normalised_name if not unicodedata.combining(c)])
+
+ # Run filters against normalised, cleaned normalised and the original name,
+ # in case we have filters for one but not the other.
+ names_to_check = (name, normalised_name, cleaned_normalised_name)
+
watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False)
for pattern in watchlist_patterns:
- if match := re.search(pattern, name, flags=re.IGNORECASE):
- matches.append(match)
- return matches
+ for name in names_to_check:
+ if match := re.search(pattern, name, flags=re.IGNORECASE):
+ return match
+ return None
async def check_send_alert(self, member: Member) -> bool:
"""When there is less than 3 days after last alert, return `False`, otherwise `True`."""
if last_alert := await self.name_alerts.get(member.id):
- last_alert = datetime.utcfromtimestamp(last_alert)
- if datetime.utcnow() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert:
+ last_alert = arrow.get(last_alert)
+ if arrow.utcnow() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert:
log.trace(f"Last alert was too recent for {member}'s nickname.")
return False
@@ -203,10 +236,14 @@ class Filtering(Cog):
"""Send a mod alert every 3 days if a username still matches a watchlist pattern."""
# Use lock to avoid race conditions
async with self.name_lock:
- # Check whether the users display name contains any words in our blacklist
- matches = self.get_name_matches(member.display_name)
+ # Check if we recently alerted about this user first,
+ # to avoid running all the filter tokens against their name again.
+ if not await self.check_send_alert(member):
+ return
- if not matches or not await self.check_send_alert(member):
+ # Check whether the users display name contains any words in our blacklist
+ match = self.get_name_match(member.display_name)
+ if not match:
return
log.info(f"Sending bad nickname alert for '{member.display_name}' ({member.id}).")
@@ -214,7 +251,7 @@ class Filtering(Cog):
log_string = (
f"**User:** {format_user(member)}\n"
f"**Display Name:** {escape_markdown(member.display_name)}\n"
- f"**Bad Matches:** {', '.join(match.group() for match in matches)}"
+ f"**Bad Match:** {match.group()}"
)
await self.mod_log.send_log_message(
@@ -223,11 +260,11 @@ class Filtering(Cog):
title="Username filtering alert",
text=log_string,
channel_id=Channels.mod_alerts,
- thumbnail=member.avatar_url
+ thumbnail=member.display_avatar.url
)
# Update time when alert sent
- await self.name_alerts.set(member.id, datetime.utcnow().timestamp())
+ await self.name_alerts.set(member.id, arrow.utcnow().timestamp())
async def filter_eval(self, result: str, msg: Message) -> bool:
"""
@@ -323,7 +360,7 @@ class Filtering(Cog):
await self.notify_member(msg.author, _filter["notification_msg"], msg.channel)
# If the message is classed as offensive, we store it in the site db and
- # it will be deleted it after one week.
+ # it will be deleted after one week.
if _filter["schedule_deletion"] and not is_private:
delete_date = (msg.created_at + OFFENSIVE_MSG_DELETE_TIME).isoformat()
data = {
@@ -346,6 +383,24 @@ class Filtering(Cog):
stats = self._add_stats(filter_name, match, msg.content)
await self._send_log(filter_name, _filter, msg, stats, reason)
+ # If the filter reason contains `[autoban]`, we want to auto-ban the user
+ if reason and "[autoban]" in reason.lower():
+ # Create a new context, with the author as is the bot, and the channel as #mod-alerts.
+ # This sends the ban confirmation directly under watchlist trigger embed, to inform
+ # mods that the user was auto-banned for the message.
+ context = await self.bot.get_context(msg)
+ context.guild = self.bot.get_guild(Guild.id)
+ context.author = context.guild.get_member(self.bot.user.id)
+ context.channel = self.bot.get_channel(Channels.mod_alerts)
+ context.command = self.bot.get_command("tempban")
+
+ await context.invoke(
+ context.command,
+ msg.author,
+ arrow.utcnow() + AUTO_BAN_DURATION,
+ reason=AUTO_BAN_REASON
+ )
+
break # We don't want multiple filters to trigger
async def _send_log(
@@ -367,6 +422,10 @@ class Filtering(Cog):
# Allow specific filters to override ping_everyone
ping_everyone = Filter.ping_everyone and _filter.get("ping_everyone", True)
+ # If we are going to autoban, we don't want to ping
+ if reason and "[autoban]" in reason:
+ ping_everyone = False
+
eval_msg = "using !eval " if is_eval else ""
footer = f"Reason: {reason}" if reason else None
message = (
@@ -383,7 +442,7 @@ class Filtering(Cog):
colour=Colour(Colours.soft_red),
title=f"{_filter['type'].title()} triggered!",
text=message,
- thumbnail=msg.author.avatar_url_as(static_format="png"),
+ thumbnail=msg.author.display_avatar.url,
channel_id=Channels.mod_alerts,
ping_everyone=ping_everyone,
additional_embeds=stats.additional_embeds,
@@ -456,10 +515,6 @@ class Filtering(Cog):
text = self.clean_input(text)
- # Make sure it's not a URL
- if URL_RE.search(text):
- return False, None
-
watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False)
for pattern in watchlist_patterns:
match = re.search(pattern, text, flags=re.IGNORECASE)
@@ -507,7 +562,7 @@ class Filtering(Cog):
# discord\.gg/gdudes-pony-farm
text = text.replace("\\", "")
- invites = INVITE_RE.findall(text)
+ invites = [m.group("invite") for m in INVITE_RE.finditer(text)]
invite_data = dict()
for invite in invites:
if invite in invite_data:
@@ -603,7 +658,7 @@ class Filtering(Cog):
def schedule_msg_delete(self, msg: dict) -> None:
"""Delete an offensive message once its deletion date is reached."""
- delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None)
+ delete_at = dateutil.parser.isoparse(msg['delete_date'])
self.scheduler.schedule_at(delete_at, msg['id'], self.delete_offensive_msg(msg))
async def reschedule_offensive_msg_deletion(self) -> None:
@@ -611,17 +666,17 @@ class Filtering(Cog):
await self.bot.wait_until_ready()
response = await self.bot.api_client.get('bot/offensive-messages',)
- now = datetime.utcnow()
+ now = arrow.utcnow()
for msg in response:
- delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None)
+ delete_at = dateutil.parser.isoparse(msg['delete_date'])
if delete_at < now:
await self.delete_offensive_msg(msg)
else:
self.schedule_msg_delete(msg)
- async def delete_offensive_msg(self, msg: Mapping[str, str]) -> None:
+ async def delete_offensive_msg(self, msg: Mapping[str, int]) -> None:
"""Delete an offensive message, and then delete it from the db."""
try:
channel = self.bot.get_channel(msg['channel_id'])
diff --git a/bot/exts/filters/token_remover.py b/bot/exts/filters/token_remover.py
index f68d4b987..520283ba3 100644
--- a/bot/exts/filters/token_remover.py
+++ b/bot/exts/filters/token_remover.py
@@ -109,7 +109,7 @@ class TokenRemover(Cog):
colour=Colour(Colours.soft_red),
title="Token removed!",
text=log_message + "\n" + userid_message,
- thumbnail=msg.author.avatar_url_as(static_format="png"),
+ thumbnail=msg.author.display_avatar.url,
channel_id=Channels.mod_alerts,
ping_everyone=mention_everyone,
)
diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py
index 40cb4e141..96334317c 100644
--- a/bot/exts/filters/webhook_remover.py
+++ b/bot/exts/filters/webhook_remover.py
@@ -63,7 +63,7 @@ class WebhookRemover(Cog):
colour=Colour(Colours.soft_red),
title="Discord webhook URL removed!",
text=message,
- thumbnail=msg.author.avatar_url_as(static_format="png"),
+ thumbnail=msg.author.display_avatar.url,
channel_id=Channels.mod_alerts
)
diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py
index 2b5592530..c51656343 100644
--- a/bot/exts/fun/duck_pond.py
+++ b/bot/exts/fun/duck_pond.py
@@ -94,7 +94,7 @@ class DuckPond(Cog):
webhook=self.webhook,
content=message.clean_content,
username=message.author.display_name,
- avatar_url=message.author.avatar_url
+ avatar_url=message.author.display_avatar.url
)
if message.attachments:
@@ -109,7 +109,7 @@ class DuckPond(Cog):
webhook=self.webhook,
embed=e,
username=message.author.display_name,
- avatar_url=message.author.avatar_url
+ avatar_url=message.author.display_avatar.url
)
except discord.HTTPException:
log.exception("Failed to send an attachment to the webhook")
diff --git a/bot/exts/fun/off_topic_names.py b/bot/exts/fun/off_topic_names.py
index 427667c66..7df1d172d 100644
--- a/bot/exts/fun/off_topic_names.py
+++ b/bot/exts/fun/off_topic_names.py
@@ -1,6 +1,7 @@
import difflib
-from datetime import datetime, timedelta
+from datetime import timedelta
+import arrow
from discord import Colour, Embed
from discord.ext.commands import Cog, Context, group, has_any_role
from discord.utils import sleep_until
@@ -22,9 +23,9 @@ async def update_names(bot: Bot) -> None:
while True:
# Since we truncate the compute timedelta to seconds, we add one second to ensure
# we go past midnight in the `seconds_to_sleep` set below.
- today_at_midnight = datetime.utcnow().replace(microsecond=0, second=0, minute=0, hour=0)
+ today_at_midnight = arrow.utcnow().replace(microsecond=0, second=0, minute=0, hour=0)
next_midnight = today_at_midnight + timedelta(days=1)
- await sleep_until(next_midnight)
+ await sleep_until(next_midnight.datetime)
try:
channel_0_name, channel_1_name, channel_2_name = await bot.api_client.get(
diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py
index 498305b47..60209ba6e 100644
--- a/bot/exts/help_channels/_cog.py
+++ b/bot/exts/help_channels/_cog.py
@@ -66,6 +66,9 @@ class HelpChannels(commands.Cog):
self.bot = bot
self.scheduler = scheduling.Scheduler(self.__class__.__name__)
+ self.guild: discord.Guild = None
+ self.cooldown_role: discord.Role = None
+
# Categories
self.available_category: discord.CategoryChannel = None
self.in_use_category: discord.CategoryChannel = None
@@ -95,24 +98,6 @@ class HelpChannels(commands.Cog):
self.scheduler.cancel_all()
- async def _handle_role_change(self, member: discord.Member, coro: t.Callable[..., t.Coroutine]) -> None:
- """
- Change `member`'s cooldown role via awaiting `coro` and handle errors.
-
- `coro` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`.
- """
- try:
- await coro(self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.help_cooldown))
- except discord.NotFound:
- log.debug(f"Failed to change role for {member} ({member.id}): member not found")
- except discord.Forbidden:
- log.debug(
- f"Forbidden to change role for {member} ({member.id}); "
- f"possibly due to role hierarchy"
- )
- except discord.HTTPException as e:
- log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}")
-
@lock.lock_arg(NAMESPACE, "message", attrgetter("channel.id"))
@lock.lock_arg(NAMESPACE, "message", attrgetter("author.id"))
@lock.lock_arg(f"{NAMESPACE}.unclaim", "message", attrgetter("author.id"), wait=True)
@@ -125,14 +110,19 @@ class HelpChannels(commands.Cog):
"""
log.info(f"Channel #{message.channel} was claimed by `{message.author.id}`.")
await self.move_to_in_use(message.channel)
- await self._handle_role_change(message.author, message.author.add_roles)
- await _message.pin(message)
+ # Handle odd edge case of `message.author` not being a `discord.Member` (see bot#1839)
+ if not isinstance(message.author, discord.Member):
+ log.debug(f"{message.author} ({message.author.id}) isn't a member. Not giving cooldown role or sending DM.")
+ else:
+ await members.handle_role_change(message.author, message.author.add_roles, self.cooldown_role)
- try:
- await _message.dm_on_open(message)
- except Exception as e:
- log.warning("Error occurred while sending DM:", exc_info=e)
+ try:
+ await _message.dm_on_open(message)
+ except Exception as e:
+ log.warning("Error occurred while sending DM:", exc_info=e)
+
+ await _message.pin(message)
# Add user with channel for dormant check.
await _caches.claimants.set(message.channel.id, message.author.id)
@@ -297,6 +287,9 @@ class HelpChannels(commands.Cog):
await self.bot.wait_until_guild_available()
log.trace("Initialising the cog.")
+ self.guild = self.bot.get_guild(constants.Guild.id)
+ self.cooldown_role = self.guild.get_role(constants.Roles.help_cooldown)
+
await self.init_categories()
self.channel_queue = self.create_channel_queue()
@@ -369,6 +362,12 @@ class HelpChannels(commands.Cog):
log.trace(f"Moving #{channel} ({channel.id}) to the Available category.")
+ # Unpin any previously stuck pins
+ log.trace(f"Looking for pins stuck in #{channel} ({channel.id}).")
+ for message in await channel.pins():
+ await _message.pin_wrapper(message.id, channel, pin=False)
+ log.debug(f"Removed a stuck pin from #{channel} ({channel.id}). ID: {message.id}")
+
await _channel.move_to_bottom(
channel=channel,
category_id=constants.Categories.help_available,
@@ -434,11 +433,11 @@ class HelpChannels(commands.Cog):
await _caches.claimants.delete(channel.id)
await _caches.session_participants.delete(channel.id)
- claimant = await members.get_or_fetch_member(self.bot.get_guild(constants.Guild.id), claimant_id)
+ claimant = await members.get_or_fetch_member(self.guild, claimant_id)
if claimant is None:
log.info(f"{claimant_id} left the guild during their help session; the cooldown role won't be removed")
else:
- await self._handle_role_change(claimant, claimant.remove_roles)
+ await members.handle_role_change(claimant, claimant.remove_roles, self.cooldown_role)
await _message.unpin(channel)
await _stats.report_complete_session(channel.id, closed_on)
@@ -579,7 +578,7 @@ class HelpChannels(commands.Cog):
embed = discord.Embed(
title="Currently Helping",
description=f"You're currently helping in {message.channel.mention}",
- color=constants.Colours.soft_green,
+ color=constants.Colours.bright_green,
timestamp=message.created_at
)
embed.add_field(name="Conversation", value=f"[Jump to message]({message.jump_url})")
diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py
index a52c67570..241dd606c 100644
--- a/bot/exts/help_channels/_message.py
+++ b/bot/exts/help_channels/_message.py
@@ -174,7 +174,7 @@ async def notify(channel: discord.TextChannel, last_notification: t.Optional[Arr
async def pin(message: discord.Message) -> None:
"""Pin an initial question `message` and store it in a cache."""
- if await _pin_wrapper(message.id, message.channel, pin=True):
+ if await pin_wrapper(message.id, message.channel, pin=True):
await _caches.question_messages.set(message.channel.id, message.id)
@@ -205,7 +205,7 @@ async def unpin(channel: discord.TextChannel) -> None:
if msg_id is None:
log.debug(f"#{channel} ({channel.id}) doesn't have a message pinned.")
else:
- await _pin_wrapper(msg_id, channel, pin=False)
+ await pin_wrapper(msg_id, channel, pin=False)
def _match_bot_embed(message: t.Optional[discord.Message], description: str) -> bool:
@@ -220,7 +220,7 @@ def _match_bot_embed(message: t.Optional[discord.Message], description: str) ->
return message.author == bot.instance.user and bot_msg_desc.strip() == description.strip()
-async def _pin_wrapper(msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool:
+async def pin_wrapper(msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool:
"""
Pin message `msg_id` in `channel` if `pin` is True or unpin if it's False.
diff --git a/bot/exts/info/doc/_batch_parser.py b/bot/exts/info/doc/_batch_parser.py
index 92f814c9d..c27f28eac 100644
--- a/bot/exts/info/doc/_batch_parser.py
+++ b/bot/exts/info/doc/_batch_parser.py
@@ -17,6 +17,7 @@ from bot.utils import scheduling
from . import _cog, doc_cache
from ._parsing import get_symbol_markdown
+from ._redis_cache import StaleItemCounter
log = get_logger(__name__)
@@ -24,6 +25,8 @@ log = get_logger(__name__)
class StaleInventoryNotifier:
"""Handle sending notifications about stale inventories through `DocItem`s to dev log."""
+ symbol_counter = StaleItemCounter()
+
def __init__(self):
self._init_task = scheduling.create_task(
self._init_channel(),
@@ -40,13 +43,16 @@ class StaleInventoryNotifier:
async def send_warning(self, doc_item: _cog.DocItem) -> None:
"""Send a warning to dev log if one wasn't already sent for `item`'s url."""
if doc_item.url not in self._warned_urls:
- self._warned_urls.add(doc_item.url)
- await self._init_task
- embed = discord.Embed(
- description=f"Doc item `{doc_item.symbol_id=}` present in loaded documentation inventories "
- f"not found on [site]({doc_item.url}), inventories may need to be refreshed."
- )
- await self._dev_log.send(embed=embed)
+ # Only warn if the item got less than 3 warnings
+ # or if it has been more than 3 weeks since the last warning
+ if await self.symbol_counter.increment_for(doc_item) < 3:
+ self._warned_urls.add(doc_item.url)
+ await self._init_task
+ embed = discord.Embed(
+ description=f"Doc item `{doc_item.symbol_id=}` present in loaded documentation inventories "
+ f"not found on [site]({doc_item.url}), inventories may need to be refreshed."
+ )
+ await self._dev_log.send(embed=embed)
class QueueItem(NamedTuple):
@@ -103,7 +109,7 @@ class BatchParser:
if doc_item not in self._item_futures and doc_item not in self._queue:
self._item_futures[doc_item].user_requested = True
- async with bot.instance.http_session.get(doc_item.url) as response:
+ async with bot.instance.http_session.get(doc_item.url, raise_for_status=True) as response:
soup = await bot.instance.loop.run_in_executor(
None,
BeautifulSoup,
diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py
index fbbcd4a10..ebf5f5932 100644
--- a/bot/exts/info/doc/_cog.py
+++ b/bot/exts/info/doc/_cog.py
@@ -464,6 +464,7 @@ class DocCog(commands.Cog):
) -> None:
"""Clear the persistent redis cache for `package`."""
if await doc_cache.delete(package_name):
+ await self.item_fetcher.stale_inventory_notifier.symbol_counter.delete()
await ctx.send(f"Successfully cleared the cache for `{package_name}`.")
else:
await ctx.send("No keys matching the package found.")
diff --git a/bot/exts/info/doc/_redis_cache.py b/bot/exts/info/doc/_redis_cache.py
index 79648893a..107f2344f 100644
--- a/bot/exts/info/doc/_redis_cache.py
+++ b/bot/exts/info/doc/_redis_cache.py
@@ -25,8 +25,7 @@ class DocRedisCache(RedisObject):
All keys from a single page are stored together, expiring a week after the first set.
"""
- url_key = remove_suffix(item.relative_url_path, ".html")
- redis_key = f"{self.namespace}:{item.package}:{url_key}"
+ redis_key = f"{self.namespace}:{item_key(item)}"
needs_expire = False
with await self._get_pool_connection() as connection:
@@ -44,10 +43,36 @@ class DocRedisCache(RedisObject):
@namespace_lock
async def get(self, item: DocItem) -> Optional[str]:
"""Return the Markdown content of the symbol `item` if it exists."""
- url_key = remove_suffix(item.relative_url_path, ".html")
+ with await self._get_pool_connection() as connection:
+ return await connection.hget(f"{self.namespace}:{item_key(item)}", item.symbol_id, encoding="utf8")
+ @namespace_lock
+ async def delete(self, package: str) -> bool:
+ """Remove all values for `package`; return True if at least one key was deleted, False otherwise."""
+ with await self._get_pool_connection() as connection:
+ package_keys = [
+ package_key async for package_key in connection.iscan(match=f"{self.namespace}:{package}:*")
+ ]
+ if package_keys:
+ await connection.delete(*package_keys)
+ return True
+ return False
+
+
+class StaleItemCounter(RedisObject):
+ """Manage increment counters for stale `DocItem`s."""
+
+ @namespace_lock
+ async def increment_for(self, item: DocItem) -> int:
+ """
+ Increment the counter for `item` by 1, set it to expire in 3 weeks and return the new value.
+
+ If the counter didn't exist, initialize it with 1.
+ """
+ key = f"{self.namespace}:{item_key(item)}:{item.symbol_id}"
with await self._get_pool_connection() as connection:
- return await connection.hget(f"{self.namespace}:{item.package}:{url_key}", item.symbol_id, encoding="utf8")
+ await connection.expire(key, WEEK_SECONDS * 3)
+ return int(await connection.incr(key))
@namespace_lock
async def delete(self, package: str) -> bool:
@@ -62,10 +87,6 @@ class DocRedisCache(RedisObject):
return False
-def remove_suffix(string: str, suffix: str) -> str:
- """Remove `suffix` from end of `string`."""
- # TODO replace usages with str.removesuffix on 3.9
- if string.endswith(suffix):
- return string[:-len(suffix)]
- else:
- return string
+def item_key(item: DocItem) -> str:
+ """Get the redis redis key string from `item`."""
+ return f"{item.package}:{item.relative_url_path.removesuffix('.html')}"
diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py
index f413caded..06799fb71 100644
--- a/bot/exts/info/help.py
+++ b/bot/exts/info/help.py
@@ -1,9 +1,12 @@
+from __future__ import annotations
+
import itertools
+import re
from collections import namedtuple
from contextlib import suppress
-from typing import List, Union
+from typing import List, Optional, Union
-from discord import Colour, Embed
+from discord import ButtonStyle, Colour, Embed, Emoji, Interaction, PartialEmoji, ui
from discord.ext.commands import Bot, Cog, Command, CommandError, Context, DisabledCommand, Group, HelpCommand
from rapidfuzz import fuzz, process
from rapidfuzz.utils import default_process
@@ -25,6 +28,119 @@ NOT_ALLOWED_TO_RUN_MESSAGE = "***You cannot run this command.***\n\n"
Category = namedtuple("Category", ["name", "description", "cogs"])
+class SubcommandButton(ui.Button):
+ """
+ A button shown in a group's help embed.
+
+ The button represents a subcommand, and pressing it will edit the help embed to that of the subcommand.
+ """
+
+ def __init__(
+ self,
+ help_command: CustomHelpCommand,
+ command: Command,
+ *,
+ style: ButtonStyle = ButtonStyle.primary,
+ label: Optional[str] = None,
+ disabled: bool = False,
+ custom_id: Optional[str] = None,
+ url: Optional[str] = None,
+ emoji: Optional[Union[str, Emoji, PartialEmoji]] = None,
+ row: Optional[int] = None
+ ):
+ super().__init__(
+ style=style, label=label, disabled=disabled, custom_id=custom_id, url=url, emoji=emoji, row=row
+ )
+
+ self.help_command = help_command
+ self.command = command
+
+ async def callback(self, interaction: Interaction) -> None:
+ """Edits the help embed to that of the subcommand."""
+ message = interaction.message
+ if not message:
+ return
+
+ subcommand = self.command
+ if isinstance(subcommand, Group):
+ embed, subcommand_view = await self.help_command.format_group_help(subcommand)
+ else:
+ embed, subcommand_view = await self.help_command.command_formatting(subcommand)
+ await message.edit(embed=embed, view=subcommand_view)
+
+
+class GroupButton(ui.Button):
+ """
+ A button shown in a subcommand's help embed.
+
+ The button represents the parent command, and pressing it will edit the help embed to that of the parent.
+ """
+
+ def __init__(
+ self,
+ help_command: CustomHelpCommand,
+ command: Command,
+ *,
+ style: ButtonStyle = ButtonStyle.secondary,
+ label: Optional[str] = None,
+ disabled: bool = False,
+ custom_id: Optional[str] = None,
+ url: Optional[str] = None,
+ emoji: Optional[Union[str, Emoji, PartialEmoji]] = None,
+ row: Optional[int] = None
+ ):
+ super().__init__(
+ style=style, label=label, disabled=disabled, custom_id=custom_id, url=url, emoji=emoji, row=row
+ )
+
+ self.help_command = help_command
+ self.command = command
+
+ async def callback(self, interaction: Interaction) -> None:
+ """Edits the help embed to that of the parent."""
+ message = interaction.message
+ if not message:
+ return
+
+ embed, group_view = await self.help_command.format_group_help(self.command.parent)
+ await message.edit(embed=embed, view=group_view)
+
+
+class CommandView(ui.View):
+ """
+ The view added to any command's help embed.
+
+ If the command has a parent, a button is added to the view to show that parent's help embed.
+ """
+
+ def __init__(self, help_command: CustomHelpCommand, command: Command):
+ super().__init__()
+
+ if command.parent:
+ self.children.append(GroupButton(help_command, command, emoji="↩️"))
+
+
+class GroupView(CommandView):
+ """
+ The view added to a group's help embed.
+
+ The view generates a SubcommandButton for every subcommand the group has.
+ """
+
+ MAX_BUTTONS_IN_ROW = 5
+ MAX_ROWS = 5
+
+ def __init__(self, help_command: CustomHelpCommand, group: Group, subcommands: list[Command]):
+ super().__init__(help_command, group)
+ # Don't add buttons if only a portion of the subcommands can be shown.
+ if len(subcommands) + len(self.children) > self.MAX_ROWS * self.MAX_BUTTONS_IN_ROW:
+ log.trace(f"Attempted to add navigation buttons for `{group.qualified_name}`, but there was no space.")
+ return
+
+ for subcommand in subcommands:
+ self.add_item(SubcommandButton(help_command, subcommand, label=subcommand.name))
+
+
class HelpQueryNotFound(ValueError):
"""
Raised when a HelpSession Query doesn't match a command or cog.
@@ -147,7 +263,7 @@ class CustomHelpCommand(HelpCommand):
await self.context.send(embed=embed)
- async def command_formatting(self, command: Command) -> Embed:
+ async def command_formatting(self, command: Command) -> tuple[Embed, Optional[CommandView]]:
"""
Takes a command and turns it into an embed.
@@ -179,15 +295,20 @@ class CustomHelpCommand(HelpCommand):
except CommandError:
command_details += NOT_ALLOWED_TO_RUN_MESSAGE
- command_details += f"*{command.help or 'No details provided.'}*\n"
+ # Remove line breaks from docstrings, if not used to separate paragraphs.
+ # Allow overriding this behaviour via putting \u2003 at the start of a line.
+ formatted_doc = re.sub("(?<!\n)\n(?![\n\u2003])", " ", command.help)
+ command_details += f"*{formatted_doc or 'No details provided.'}*\n"
embed.description = command_details
- return embed
+ # If the help is invoked in the context of an error, don't show subcommand navigation.
+ view = CommandView(self, command) if not self.context.command_failed else None
+ return embed, view
async def send_command_help(self, command: Command) -> None:
"""Send help for a single command."""
- embed = await self.command_formatting(command)
- message = await self.context.send(embed=embed)
+ embed, view = await self.command_formatting(command)
+ message = await self.context.send(embed=embed, view=view)
await wait_for_deletion(message, (self.context.author.id,))
@staticmethod
@@ -208,25 +329,31 @@ class CustomHelpCommand(HelpCommand):
else:
return "".join(details)
- async def send_group_help(self, group: Group) -> None:
- """Sends help for a group command."""
+ async def format_group_help(self, group: Group) -> tuple[Embed, Optional[CommandView]]:
+ """Formats help for a group command."""
subcommands = group.commands
if len(subcommands) == 0:
# no subcommands, just treat it like a regular command
- await self.send_command_help(group)
- return
+ return await self.command_formatting(group)
# remove commands that the user can't run and are hidden, and sort by name
commands_ = await self.filter_commands(subcommands, sort=True)
- embed = await self.command_formatting(group)
+ embed, _ = await self.command_formatting(group)
command_details = self.get_commands_brief_details(commands_)
if command_details:
embed.description += f"\n**Subcommands:**\n{command_details}"
- message = await self.context.send(embed=embed)
+ # If the help is invoked in the context of an error, don't show subcommand navigation.
+ view = GroupView(self, group, commands_) if not self.context.command_failed else None
+ return embed, view
+
+ async def send_group_help(self, group: Group) -> None:
+ """Sends help for a group command."""
+ embed, view = await self.format_group_help(group)
+ message = await self.context.send(embed=embed, view=view)
await wait_for_deletion(message, (self.context.author.id,))
async def send_cog_help(self, cog: Cog) -> None:
diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py
index f27483af8..5b48495dc 100644
--- a/bot/exts/info/information.py
+++ b/bot/exts/info/information.py
@@ -112,7 +112,7 @@ class Information(Cog):
# Build an embed
embed = Embed(
title=f"Role information (Total {len(roles)} role{'s' * (len(role_list) > 1)})",
- colour=Colour.blurple()
+ colour=Colour.og_blurple()
)
await LinePaginator.paginate(role_list, ctx, embed, empty=False)
@@ -170,7 +170,7 @@ class Information(Cog):
@command(name="server", aliases=["server_info", "guild", "guild_info"])
async def server_info(self, ctx: Context) -> None:
"""Returns an embed full of server information."""
- embed = Embed(colour=Colour.blurple(), title="Server Information")
+ embed = Embed(colour=Colour.og_blurple(), title="Server Information")
created = discord_timestamp(ctx.guild.created_at, TimestampFormats.RELATIVE)
region = ctx.guild.region
@@ -178,7 +178,10 @@ class Information(Cog):
# Server Features are only useful in certain channels
if ctx.channel.id in (
- *constants.MODERATION_CHANNELS, constants.Channels.dev_core, constants.Channels.dev_contrib
+ *constants.MODERATION_CHANNELS,
+ constants.Channels.dev_core,
+ constants.Channels.dev_contrib,
+ constants.Channels.bot_commands
):
features = f"\nFeatures: {', '.join(ctx.guild.features)}"
else:
@@ -200,7 +203,7 @@ class Information(Cog):
f"\nRoles: {num_roles}"
f"\nMember status: {member_status}"
)
- embed.set_thumbnail(url=ctx.guild.icon_url)
+ embed.set_thumbnail(url=ctx.guild.icon.url)
# Members
total_members = f"{ctx.guild.member_count:,}"
@@ -315,8 +318,8 @@ class Information(Cog):
for field_name, field_content in fields:
embed.add_field(name=field_name, value=field_content, inline=False)
- embed.set_thumbnail(url=user.avatar_url_as(static_format="png"))
- embed.colour = user.colour if user.colour != Colour.default() else Colour.blurple()
+ embed.set_thumbnail(url=user.display_avatar.url)
+ embed.colour = user.colour if user.colour != Colour.default() else Colour.og_blurple()
return embed
@@ -419,7 +422,12 @@ class Information(Cog):
activity_output = "No activity"
else:
activity_output.append(user_activity["total_messages"] or "No messages")
- activity_output.append(user_activity["activity_blocks"] or "No activity")
+
+ if (activity_blocks := user_activity.get("activity_blocks")) is not None:
+ # activity_blocks is not included in the response if the user has a lot of messages
+ activity_output.append(activity_blocks or "No activity") # Special case when activity_blocks is 0.
+ else:
+ activity_output.append("Too many to count!")
activity_output = "\n".join(
f"{name}: {metric}" for name, metric in zip(["Messages", "Activity blocks"], activity_output)
diff --git a/bot/exts/info/pep.py b/bot/exts/info/pep.py
index 259095b50..67866620b 100644
--- a/bot/exts/info/pep.py
+++ b/bot/exts/info/pep.py
@@ -16,7 +16,7 @@ log = get_logger(__name__)
ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png"
BASE_PEP_URL = "http://www.python.org/dev/peps/pep-"
-PEPS_LISTING_API_URL = "https://api.github.com/repos/python/peps/contents?ref=master"
+PEPS_LISTING_API_URL = "https://api.github.com/repos/python/peps/contents?ref=main"
pep_cache = AsyncCache()
@@ -97,9 +97,12 @@ class PythonEnhancementProposals(Cog):
def generate_pep_embed(self, pep_header: Dict, pep_nr: int) -> Embed:
"""Generate PEP embed based on PEP headers data."""
+ # the parsed header can be wrapped to multiple lines, so we need to make sure that is removed
+ # for an example of a pep with this issue, see pep 500
+ title = " ".join(pep_header["Title"].split())
# Assemble the embed
pep_embed = Embed(
- title=f"**PEP {pep_nr} - {pep_header['Title']}**",
+ title=f"**PEP {pep_nr} - {title}**",
description=f"[Link]({BASE_PEP_URL}{pep_nr:04})",
)
diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py
index c3d2e2a3c..dacf7bc12 100644
--- a/bot/exts/info/pypi.py
+++ b/bot/exts/info/pypi.py
@@ -29,7 +29,7 @@ class PyPi(Cog):
def __init__(self, bot: Bot):
self.bot = bot
- @command(name="pypi", aliases=("package", "pack"))
+ @command(name="pypi", aliases=("package", "pack", "pip"))
async def get_package_info(self, ctx: Context, package: str) -> None:
"""Provide information about a specific package from PyPI."""
embed = Embed(title=random.choice(NEGATIVE_REPLIES), colour=Colours.soft_red)
diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py
index e1f2f5153..f6499ecce 100644
--- a/bot/exts/info/site.py
+++ b/bot/exts/info/site.py
@@ -1,3 +1,5 @@
+from textwrap import shorten
+
from discord import Colour, Embed
from discord.ext.commands import Cog, Context, Greedy, group
@@ -29,7 +31,7 @@ class Site(Cog):
embed = Embed(title="Python Discord website")
embed.set_footer(text=url)
- embed.colour = Colour.blurple()
+ embed.colour = Colour.og_blurple()
embed.description = (
f"[Our official website]({url}) is an open-source community project "
"created with Python and Django. It contains information about the server "
@@ -46,7 +48,7 @@ class Site(Cog):
embed = Embed(title="Resources")
embed.set_footer(text=f"{learning_url}")
- embed.colour = Colour.blurple()
+ embed.colour = Colour.og_blurple()
embed.description = (
f"The [Resources page]({learning_url}) on our website contains a "
"list of hand-selected learning resources that we regularly recommend "
@@ -62,7 +64,7 @@ class Site(Cog):
embed = Embed(title="Tools")
embed.set_footer(text=f"{tools_url}")
- embed.colour = Colour.blurple()
+ embed.colour = Colour.og_blurple()
embed.description = (
f"The [Tools page]({tools_url}) on our website contains a "
f"couple of the most popular tools for programming in Python."
@@ -77,7 +79,7 @@ class Site(Cog):
embed = Embed(title="Asking Good Questions")
embed.set_footer(text=url)
- embed.colour = Colour.blurple()
+ embed.colour = Colour.og_blurple()
embed.description = (
"Asking the right question about something that's new to you can sometimes be tricky. "
f"To help with this, we've created a [guide to asking good questions]({url}) on our website. "
@@ -93,7 +95,7 @@ class Site(Cog):
embed = Embed(title="FAQ")
embed.set_footer(text=url)
- embed.colour = Colour.blurple()
+ embed.colour = Colour.og_blurple()
embed.description = (
"As the largest Python community on Discord, we get hundreds of questions every day. "
"Many of these questions have been asked before. We've compiled a list of the most "
@@ -106,7 +108,7 @@ 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'{BASE_URL}/pages/rules')
+ rules_embed = Embed(title='Rules', color=Colour.og_blurple(), url=f'{BASE_URL}/pages/rules')
if not rules:
# Rules were not submitted. Return the default description.
@@ -123,10 +125,11 @@ class Site(Cog):
# Remove duplicates and sort the rule indices
rules = sorted(set(rules))
+
invalid = ', '.join(str(index) for index in rules if index < 1 or index > len(full_rules))
if invalid:
- await ctx.send(f":x: Invalid rule indices: {invalid}")
+ await ctx.send(shorten(":x: Invalid rule indices: " + invalid, 75, placeholder=' ...'))
return
for rule in rules:
diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py
new file mode 100644
index 000000000..1299d5d59
--- /dev/null
+++ b/bot/exts/info/subscribe.py
@@ -0,0 +1,201 @@
+import calendar
+import operator
+import typing as t
+from dataclasses import dataclass
+
+import arrow
+import discord
+from discord.ext import commands
+from discord.interactions import Interaction
+
+from bot import constants
+from bot.bot import Bot
+from bot.decorators import redirect_output
+from bot.log import get_logger
+from bot.utils import members, scheduling
+
+
+@dataclass(frozen=True)
+class AssignableRole:
+ """
+ A role that can be assigned to a user.
+
+ months_available is a tuple that signifies what months the role should be
+ self-assignable, using None for when it should always be available.
+ """
+
+ role_id: int
+ months_available: t.Optional[tuple[int]]
+ name: t.Optional[str] = None # This gets populated within Subscribe.init_cog()
+
+ def is_currently_available(self) -> bool:
+ """Check if the role is available for the current month."""
+ if self.months_available is None:
+ return True
+ return arrow.utcnow().month in self.months_available
+
+ def get_readable_available_months(self) -> str:
+ """Get a readable string of the months the role is available."""
+ if self.months_available is None:
+ return f"{self.name} is always available."
+
+ # Join the months together with comma separators, but use "and" for the final seperator.
+ month_names = [calendar.month_name[month] for month in self.months_available]
+ available_months_str = ", ".join(month_names[:-1]) + f" and {month_names[-1]}"
+ return f"{self.name} can only be assigned during {available_months_str}."
+
+
+ASSIGNABLE_ROLES = (
+ AssignableRole(constants.Roles.announcements, None),
+ AssignableRole(constants.Roles.pyweek_announcements, None),
+ AssignableRole(constants.Roles.lovefest, (1, 2)),
+ AssignableRole(constants.Roles.advent_of_code, (11, 12)),
+)
+
+ITEMS_PER_ROW = 3
+DELETE_MESSAGE_AFTER = 300 # Seconds
+
+log = get_logger(__name__)
+
+
+class RoleButtonView(discord.ui.View):
+ """A list of SingleRoleButtons to show to the member."""
+
+ def __init__(self, member: discord.Member):
+ super().__init__()
+ self.interaction_owner = member
+
+ async def interaction_check(self, interaction: Interaction) -> bool:
+ """Ensure that the user clicking the button is the member who invoked the command."""
+ if interaction.user != self.interaction_owner:
+ await interaction.response.send_message(
+ ":x: This is not your command to react to!",
+ ephemeral=True
+ )
+ return False
+ return True
+
+
+class SingleRoleButton(discord.ui.Button):
+ """A button that adds or removes a role from the member depending on it's current state."""
+
+ ADD_STYLE = discord.ButtonStyle.success
+ REMOVE_STYLE = discord.ButtonStyle.red
+ UNAVAILABLE_STYLE = discord.ButtonStyle.secondary
+ LABEL_FORMAT = "{action} role {role_name}."
+ CUSTOM_ID_FORMAT = "subscribe-{role_id}"
+
+ def __init__(self, role: AssignableRole, assigned: bool, row: int):
+ if role.is_currently_available():
+ style = self.REMOVE_STYLE if assigned else self.ADD_STYLE
+ label = self.LABEL_FORMAT.format(action="Remove" if assigned else "Add", role_name=role.name)
+ else:
+ style = self.UNAVAILABLE_STYLE
+ label = f"🔒 {role.name}"
+
+ super().__init__(
+ style=style,
+ label=label,
+ custom_id=self.CUSTOM_ID_FORMAT.format(role_id=role.role_id),
+ row=row,
+ )
+ self.role = role
+ self.assigned = assigned
+
+ async def callback(self, interaction: Interaction) -> None:
+ """Update the member's role and change button text to reflect current text."""
+ if isinstance(interaction.user, discord.User):
+ log.trace("User %s is not a member", interaction.user)
+ await interaction.message.delete()
+ self.view.stop()
+ return
+
+ if not self.role.is_currently_available():
+ await interaction.response.send_message(self.role.get_readable_available_months(), ephemeral=True)
+ return
+
+ await members.handle_role_change(
+ interaction.user,
+ interaction.user.remove_roles if self.assigned else interaction.user.add_roles,
+ discord.Object(self.role.role_id),
+ )
+
+ self.assigned = not self.assigned
+ await self.update_view(interaction)
+ await interaction.response.send_message(
+ self.LABEL_FORMAT.format(action="Added" if self.assigned else "Removed", role_name=self.role.name),
+ ephemeral=True,
+ )
+
+ async def update_view(self, interaction: Interaction) -> None:
+ """Updates the original interaction message with a new view object with the updated buttons."""
+ self.style = self.REMOVE_STYLE if self.assigned else self.ADD_STYLE
+ self.label = self.LABEL_FORMAT.format(action="Remove" if self.assigned else "Add", role_name=self.role.name)
+ try:
+ await interaction.message.edit(view=self.view)
+ except discord.NotFound:
+ log.debug("Subscribe message for %s removed before buttons could be updated", interaction.user)
+ self.view.stop()
+
+
+class Subscribe(commands.Cog):
+ """Cog to allow user to self-assign & remove the roles present in ASSIGNABLE_ROLES."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.init_task = scheduling.create_task(self.init_cog(), event_loop=self.bot.loop)
+ self.assignable_roles: list[AssignableRole] = []
+ self.guild: discord.Guild = None
+
+ async def init_cog(self) -> None:
+ """Initialise the cog by resolving the role IDs in ASSIGNABLE_ROLES to role names."""
+ await self.bot.wait_until_guild_available()
+
+ self.guild = self.bot.get_guild(constants.Guild.id)
+
+ for role in ASSIGNABLE_ROLES:
+ discord_role = self.guild.get_role(role.role_id)
+ if discord_role is None:
+ log.warning("Could not resolve %d to a role in the guild, skipping.", role.role_id)
+ continue
+ self.assignable_roles.append(
+ AssignableRole(
+ role_id=role.role_id,
+ months_available=role.months_available,
+ name=discord_role.name,
+ )
+ )
+
+ # Sort by role name, then shift unavailable roles to the end of the list
+ self.assignable_roles.sort(key=operator.attrgetter("name"))
+ self.assignable_roles.sort(key=operator.methodcaller("is_currently_available"), reverse=True)
+
+ @commands.cooldown(1, 10, commands.BucketType.member)
+ @commands.command(name="subscribe")
+ @redirect_output(
+ destination_channel=constants.Channels.bot_commands,
+ bypass_roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES,
+ )
+ async def subscribe_command(self, ctx: commands.Context, *_) -> None: # We don't actually care about the args
+ """Display the member's current state for each role, and allow them to add/remove the roles."""
+ await self.init_task
+
+ button_view = RoleButtonView(ctx.author)
+ author_roles = [role.id for role in ctx.author.roles]
+ for index, role in enumerate(self.assignable_roles):
+ row = index // ITEMS_PER_ROW
+ button_view.add_item(SingleRoleButton(role, role.role_id in author_roles, row))
+
+ await ctx.send(
+ "Click the buttons below to add or remove your roles!",
+ view=button_view,
+ delete_after=DELETE_MESSAGE_AFTER,
+ )
+
+
+def setup(bot: Bot) -> None:
+ """Load the Subscribe cog."""
+ if len(ASSIGNABLE_ROLES) > ITEMS_PER_ROW*5: # Discord limits views to 5 rows of buttons.
+ log.error("Too many roles for 5 rows, not loading the Subscribe cog.")
+ else:
+ bot.add_cog(Subscribe(bot))
diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py
new file mode 100644
index 000000000..826265aa3
--- /dev/null
+++ b/bot/exts/moderation/clean.py
@@ -0,0 +1,596 @@
+import contextlib
+import logging
+import re
+import time
+from collections import defaultdict
+from contextlib import suppress
+from datetime import datetime
+from itertools import islice
+from typing import Any, Callable, Iterable, Literal, Optional, TYPE_CHECKING, Union
+
+from discord import Colour, Message, NotFound, TextChannel, User, errors
+from discord.ext.commands import Cog, Context, Converter, Greedy, group, has_any_role
+from discord.ext.commands.converter import TextChannelConverter
+from discord.ext.commands.errors import BadArgument
+
+from bot.bot import Bot
+from bot.constants import Channels, CleanMessages, Colours, Emojis, Event, Icons, MODERATION_ROLES
+from bot.converters import Age, ISODateTime
+from bot.exts.moderation.modlog import ModLog
+from bot.utils.channel import is_mod_channel
+
+log = logging.getLogger(__name__)
+
+# Default number of messages to look at in each channel.
+DEFAULT_TRAVERSE = 10
+# Number of seconds before command invocations and responses are deleted in non-moderation channels.
+MESSAGE_DELETE_DELAY = 5
+
+# Type alias for checks for whether a message should be deleted.
+Predicate = Callable[[Message], bool]
+# Type alias for message lookup ranges.
+CleanLimit = Union[Message, Age, ISODateTime]
+
+
+class CleanChannels(Converter):
+ """A converter that turns the given string to a list of channels to clean, or the literal `*` for all channels."""
+
+ _channel_converter = TextChannelConverter()
+
+ async def convert(self, ctx: Context, argument: str) -> Union[Literal["*"], list[TextChannel]]:
+ """Converts a string to a list of channels to clean, or the literal `*` for all channels."""
+ if argument == "*":
+ return "*"
+ return [await self._channel_converter.convert(ctx, channel) for channel in argument.split()]
+
+
+class Regex(Converter):
+ """A converter that takes a string in the form `.+` and returns the contents of the inline code compiled."""
+
+ async def convert(self, ctx: Context, argument: str) -> re.Pattern:
+ """Strips the backticks from the string and compiles it to a regex pattern."""
+ match = re.fullmatch(r"`(.+?)`", argument)
+ if not match:
+ raise BadArgument("Regex pattern missing wrapping backticks")
+ try:
+ return re.compile(match.group(1), re.IGNORECASE + re.DOTALL)
+ except re.error as e:
+ raise BadArgument(f"Regex error: {e.msg}")
+
+
+if TYPE_CHECKING: # Used to allow method resolution in IDEs like in converters.py.
+ CleanChannels = Union[Literal["*"], list[TextChannel]] # noqa: F811
+ Regex = re.Pattern # noqa: F811
+
+
+class Clean(Cog):
+ """
+ A cog that allows messages to be deleted in bulk while applying various filters.
+
+ You can delete messages sent by a specific user, messages sent by bots, all messages, or messages that match a
+ specific regular expression.
+
+ The deleted messages are saved and uploaded to the database via an API endpoint, and a URL is returned which can be
+ used to view the messages in the Discord dark theme style.
+ """
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.cleaning = False
+
+ @property
+ def mod_log(self) -> ModLog:
+ """Get currently loaded ModLog cog instance."""
+ return self.bot.get_cog("ModLog")
+
+ # region: Helper functions
+
+ @staticmethod
+ def _validate_input(
+ traverse: int,
+ channels: Optional[CleanChannels],
+ bots_only: bool,
+ users: Optional[list[User]],
+ first_limit: Optional[CleanLimit],
+ second_limit: Optional[CleanLimit],
+ ) -> None:
+ """Raise errors if an argument value or a combination of values is invalid."""
+ # Is this an acceptable amount of messages to traverse?
+ if traverse > CleanMessages.message_limit:
+ raise BadArgument(f"Cannot traverse more than {CleanMessages.message_limit} messages.")
+
+ if (isinstance(first_limit, Message) or isinstance(second_limit, Message)) and channels:
+ raise BadArgument("Both a message limit and channels specified.")
+
+ if isinstance(first_limit, Message) and isinstance(second_limit, Message):
+ # Messages are not in same channel.
+ if first_limit.channel != second_limit.channel:
+ raise BadArgument("Message limits are in different channels.")
+
+ if users and bots_only:
+ raise BadArgument("Marked as bots only, but users were specified.")
+
+ # This is an implementation error rather than user error.
+ if second_limit and not first_limit:
+ raise ValueError("Second limit specified without the first.")
+
+ @staticmethod
+ async def _send_expiring_message(ctx: Context, content: str) -> None:
+ """Send `content` to the context channel. Automatically delete if it's not a mod channel."""
+ delete_after = None if is_mod_channel(ctx.channel) else MESSAGE_DELETE_DELAY
+ await ctx.send(content, delete_after=delete_after)
+
+ @staticmethod
+ def _build_predicate(
+ bots_only: bool = False,
+ users: Optional[list[User]] = None,
+ regex: Optional[re.Pattern] = None,
+ first_limit: Optional[datetime] = None,
+ second_limit: Optional[datetime] = None,
+ ) -> Predicate:
+ """Return the predicate that decides whether to delete a given message."""
+ def predicate_bots_only(message: Message) -> bool:
+ """Return True if the message was sent by a bot."""
+ return message.author.bot
+
+ def predicate_specific_users(message: Message) -> bool:
+ """Return True if the message was sent by the user provided in the _clean_messages call."""
+ return message.author in users
+
+ def predicate_regex(message: Message) -> bool:
+ """Check if the regex provided in _clean_messages matches the message content or any embed attributes."""
+ content = [message.content]
+
+ # Add the content for all embed attributes
+ for embed in message.embeds:
+ content.append(embed.title)
+ content.append(embed.description)
+ content.append(embed.footer.text)
+ content.append(embed.author.name)
+ for field in embed.fields:
+ content.append(field.name)
+ content.append(field.value)
+
+ # Get rid of empty attributes and turn it into a string
+ content = "\n".join(attr for attr in content if attr)
+
+ # Now let's see if there's a regex match
+ return bool(regex.search(content))
+
+ def predicate_range(message: Message) -> bool:
+ """Check if the message age is between the two limits."""
+ return first_limit <= message.created_at <= second_limit
+
+ def predicate_after(message: Message) -> bool:
+ """Check if the message is older than the first limit."""
+ return message.created_at >= first_limit
+
+ predicates = []
+ # Set up the correct predicate
+ if bots_only:
+ predicates.append(predicate_bots_only) # Delete messages from bots
+ if users:
+ predicates.append(predicate_specific_users) # Delete messages from specific user
+ if regex:
+ predicates.append(predicate_regex) # Delete messages that match regex
+ # Add up to one of the following:
+ if second_limit:
+ predicates.append(predicate_range) # Delete messages in the specified age range
+ elif first_limit:
+ predicates.append(predicate_after) # Delete messages older than specific message
+
+ if not predicates:
+ return lambda m: True
+ if len(predicates) == 1:
+ return predicates[0]
+ return lambda m: all(pred(m) for pred in predicates)
+
+ async def _delete_invocation(self, ctx: Context) -> None:
+ """Delete the command invocation if it's not in a mod channel."""
+ if not is_mod_channel(ctx.channel):
+ self.mod_log.ignore(Event.message_delete, ctx.message.id)
+ try:
+ await ctx.message.delete()
+ except errors.NotFound:
+ # Invocation message has already been deleted
+ log.info("Tried to delete invocation message, but it was already deleted.")
+
+ def _get_messages_from_cache(self, traverse: int, to_delete: Predicate) -> tuple[defaultdict[Any, list], list[int]]:
+ """Helper function for getting messages from the cache."""
+ message_mappings = defaultdict(list)
+ message_ids = []
+ for message in islice(self.bot.cached_messages, traverse):
+ if not self.cleaning:
+ # Cleaning was canceled
+ return message_mappings, message_ids
+
+ if to_delete(message):
+ message_mappings[message.channel].append(message)
+ message_ids.append(message.id)
+
+ return message_mappings, message_ids
+
+ async def _get_messages_from_channels(
+ self,
+ traverse: int,
+ channels: Iterable[TextChannel],
+ to_delete: Predicate,
+ before: Optional[datetime] = None,
+ after: Optional[datetime] = None
+ ) -> tuple[defaultdict[Any, list], list]:
+ message_mappings = defaultdict(list)
+ message_ids = []
+
+ for channel in channels:
+ async for message in channel.history(limit=traverse, before=before, after=after):
+
+ if not self.cleaning:
+ # Cleaning was canceled, return empty containers.
+ return defaultdict(list), []
+
+ if to_delete(message):
+ message_mappings[message.channel].append(message)
+ message_ids.append(message.id)
+
+ return message_mappings, message_ids
+
+ @staticmethod
+ def is_older_than_14d(message: Message) -> bool:
+ """
+ Precisely checks if message is older than 14 days, bulk deletion limit.
+
+ Inspired by how purge works internally.
+ Comparison on message age could possibly be less accurate which in turn would resort in problems
+ with message deletion if said messages are very close to the 14d mark.
+ """
+ two_weeks_old_snowflake = int((time.time() - 14 * 24 * 60 * 60) * 1000.0 - 1420070400000) << 22
+ return message.id < two_weeks_old_snowflake
+
+ async def _delete_messages_individually(self, messages: list[Message]) -> list[Message]:
+ """Delete each message in the list unless cleaning is cancelled. Return the deleted messages."""
+ deleted = []
+ for message in messages:
+ # Ensure that deletion was not canceled
+ if not self.cleaning:
+ return deleted
+ with contextlib.suppress(NotFound): # Message doesn't exist or was already deleted
+ await message.delete()
+ deleted.append(message)
+ return deleted
+
+ async def _delete_found(self, message_mappings: dict[TextChannel, list[Message]]) -> list[Message]:
+ """
+ Delete the detected messages.
+
+ Deletion is made in bulk per channel for messages less than 14d old.
+ The function returns the deleted messages.
+ If cleaning was cancelled in the middle, return messages already deleted.
+ """
+ deleted = []
+ for channel, messages in message_mappings.items():
+ to_delete = []
+
+ delete_old = False
+ for current_index, message in enumerate(messages): # noqa: B007
+ if not self.cleaning:
+ # Means that the cleaning was canceled
+ return deleted
+
+ if self.is_older_than_14d(message):
+ # Further messages are too old to be deleted in bulk
+ delete_old = True
+ break
+
+ to_delete.append(message)
+
+ if len(to_delete) == 100:
+ # Only up to 100 messages can be deleted in a bulk
+ await channel.delete_messages(to_delete)
+ deleted.extend(to_delete)
+ to_delete.clear()
+
+ if not self.cleaning:
+ return deleted
+ if len(to_delete) > 0:
+ # Deleting any leftover messages if there are any
+ with suppress(NotFound):
+ await channel.delete_messages(to_delete)
+ deleted.extend(to_delete)
+
+ if not self.cleaning:
+ return deleted
+ if delete_old:
+ old_deleted = await self._delete_messages_individually(messages[current_index:])
+ deleted.extend(old_deleted)
+
+ return deleted
+
+ async def _modlog_cleaned_messages(self, messages: list[Message], channels: CleanChannels, ctx: Context) -> bool:
+ """Log the deleted messages to the modlog. Return True if logging was successful."""
+ if not messages:
+ # Can't build an embed, nothing to clean!
+ await self._send_expiring_message(ctx, ":x: No matching messages could be found.")
+ return False
+
+ # Reverse the list to have reverse chronological order
+ log_messages = reversed(messages)
+ log_url = await self.mod_log.upload_log(log_messages, ctx.author.id)
+
+ # Build the embed and send it
+ if channels == "*":
+ target_channels = "all channels"
+ else:
+ target_channels = ", ".join(channel.mention for channel in channels)
+
+ message = (
+ f"**{len(messages)}** messages deleted in {target_channels} by "
+ f"{ctx.author.mention}\n\n"
+ f"A log of the deleted messages can be found [here]({log_url})."
+ )
+
+ await self.mod_log.send_log_message(
+ icon_url=Icons.message_bulk_delete,
+ colour=Colour(Colours.soft_red),
+ title="Bulk message delete",
+ text=message,
+ channel_id=Channels.mod_log,
+ )
+
+ return True
+
+ # endregion
+
+ async def _clean_messages(
+ self,
+ ctx: Context,
+ traverse: int,
+ channels: Optional[CleanChannels],
+ bots_only: bool = False,
+ users: Optional[list[User]] = None,
+ regex: Optional[re.Pattern] = None,
+ first_limit: Optional[CleanLimit] = None,
+ second_limit: Optional[CleanLimit] = None,
+ use_cache: Optional[bool] = True
+ ) -> None:
+ """A helper function that does the actual message cleaning."""
+ self._validate_input(traverse, channels, bots_only, users, first_limit, second_limit)
+
+ # Are we already performing a clean?
+ if self.cleaning:
+ await self._send_expiring_message(
+ ctx, ":x: Please wait for the currently ongoing clean operation to complete."
+ )
+ return
+ self.cleaning = True
+
+ # Default to using the invoking context's channel or the channel of the message limit(s).
+ if not channels:
+ # Input was validated - if first_limit is a message, second_limit won't point at a different channel.
+ if isinstance(first_limit, Message):
+ channels = [first_limit.channel]
+ elif isinstance(second_limit, Message):
+ channels = [second_limit.channel]
+ else:
+ channels = [ctx.channel]
+
+ if isinstance(first_limit, Message):
+ first_limit = first_limit.created_at
+ if isinstance(second_limit, Message):
+ second_limit = second_limit.created_at
+ if first_limit and second_limit:
+ first_limit, second_limit = sorted([first_limit, second_limit])
+
+ # Needs to be called after standardizing the input.
+ predicate = self._build_predicate(bots_only, users, regex, first_limit, second_limit)
+
+ # Delete the invocation first
+ await self._delete_invocation(ctx)
+
+ if channels == "*" and use_cache:
+ message_mappings, message_ids = self._get_messages_from_cache(traverse=traverse, to_delete=predicate)
+ else:
+ deletion_channels = channels
+ if channels == "*":
+ deletion_channels = [channel for channel in ctx.guild.channels if isinstance(channel, TextChannel)]
+ message_mappings, message_ids = await self._get_messages_from_channels(
+ traverse=traverse,
+ channels=deletion_channels,
+ to_delete=predicate,
+ before=second_limit,
+ after=first_limit # Remember first is the earlier datetime.
+ )
+
+ if not self.cleaning:
+ # Means that the cleaning was canceled
+ return
+
+ # Now let's delete the actual messages with purge.
+ self.mod_log.ignore(Event.message_delete, *message_ids)
+ deleted_messages = await self._delete_found(message_mappings)
+ self.cleaning = False
+
+ logged = await self._modlog_cleaned_messages(deleted_messages, channels, ctx)
+
+ if logged and is_mod_channel(ctx.channel):
+ with suppress(NotFound): # Can happen if the invoker deleted their own messages.
+ await ctx.message.add_reaction(Emojis.check_mark)
+
+ # region: Commands
+
+ @group(invoke_without_command=True, name="clean", aliases=["clear", "purge"])
+ async def clean_group(
+ self,
+ ctx: Context,
+ users: Greedy[User] = None,
+ traverse: Optional[int] = None,
+ first_limit: Optional[CleanLimit] = None,
+ second_limit: Optional[CleanLimit] = None,
+ use_cache: Optional[bool] = None,
+ bots_only: Optional[bool] = False,
+ regex: Optional[Regex] = None,
+ *,
+ channels: CleanChannels = None # "Optional" with discord.py silently ignores incorrect input.
+ ) -> None:
+ """
+ Commands for cleaning messages in channels.
+
+ If arguments are provided, will act as a master command from which all subcommands can be derived.
+
+ \u2003• `users`: A series of user mentions, ID's, or names.
+ \u2003• `traverse`: The number of messages to look at in each channel. If using the cache, will look at the
+ first `traverse` messages in the cache.
+ \u2003• `first_limit` and `second_limit`: A message, a duration delta, or an ISO datetime.
+ If a message is provided, cleaning will happen in that channel, and channels cannot be provided.
+ If a limit is provided, multiple channels cannot be provided.
+ If only one of them is provided, acts as `clean until`. If both are provided, acts as `clean between`.
+ \u2003• `use_cache`: Whether to use the message cache.
+ If not provided, will default to False unless an asterisk is used for the channels.
+ \u2003• `bots_only`: Whether to delete only bots. If specified, users cannot be specified.
+ \u2003• `regex`: A regex pattern the message must contain to be deleted.
+ The pattern must be provided enclosed in backticks.
+ If the pattern contains spaces, it still needs to be enclosed in double quotes on top of that.
+ \u2003• `channels`: A series of channels to delete in, or an asterisk to delete from all channels.
+ """
+ if not any([traverse, users, first_limit, second_limit, regex, channels]):
+ await ctx.send_help(ctx.command)
+ return
+
+ if not traverse:
+ if first_limit:
+ traverse = CleanMessages.message_limit
+ else:
+ traverse = DEFAULT_TRAVERSE
+ if use_cache is None:
+ use_cache = channels == "*"
+
+ await self._clean_messages(
+ ctx, traverse, channels, bots_only, users, regex, first_limit, second_limit, use_cache
+ )
+
+ @clean_group.command(name="user", aliases=["users"])
+ async def clean_user(
+ self,
+ ctx: Context,
+ user: User,
+ traverse: Optional[int] = DEFAULT_TRAVERSE,
+ use_cache: Optional[bool] = True,
+ *,
+ channels: CleanChannels = None
+ ) -> None:
+ """Delete messages posted by the provided user, stop cleaning after traversing `traverse` messages."""
+ await self._clean_messages(ctx, traverse, users=[user], channels=channels, use_cache=use_cache)
+
+ @clean_group.command(name="all", aliases=["everything"])
+ async def clean_all(
+ self,
+ ctx: Context,
+ traverse: Optional[int] = DEFAULT_TRAVERSE,
+ use_cache: Optional[bool] = True,
+ *,
+ channels: CleanChannels = None
+ ) -> None:
+ """Delete all messages, regardless of poster, stop cleaning after traversing `traverse` messages."""
+ await self._clean_messages(ctx, traverse, channels=channels, use_cache=use_cache)
+
+ @clean_group.command(name="bots", aliases=["bot"])
+ async def clean_bots(
+ self,
+ ctx: Context,
+ traverse: Optional[int] = DEFAULT_TRAVERSE,
+ use_cache: Optional[bool] = True,
+ *,
+ channels: CleanChannels = None
+ ) -> None:
+ """Delete all messages posted by a bot, stop cleaning after traversing `traverse` messages."""
+ await self._clean_messages(ctx, traverse, bots_only=True, channels=channels, use_cache=use_cache)
+
+ @clean_group.command(name="regex", aliases=["word", "expression", "pattern"])
+ async def clean_regex(
+ self,
+ ctx: Context,
+ regex: Regex,
+ traverse: Optional[int] = DEFAULT_TRAVERSE,
+ use_cache: Optional[bool] = True,
+ *,
+ channels: CleanChannels = None
+ ) -> None:
+ """
+ Delete all messages that match a certain regex, stop cleaning after traversing `traverse` messages.
+
+ The pattern must be provided enclosed in backticks.
+ If the pattern contains spaces, it still needs to be enclosed in double quotes on top of that.
+ For example: `[0-9]`
+ """
+ await self._clean_messages(ctx, traverse, regex=regex, channels=channels, use_cache=use_cache)
+
+ @clean_group.command(name="until")
+ async def clean_until(
+ self,
+ ctx: Context,
+ until: CleanLimit,
+ channel: TextChannel = None
+ ) -> None:
+ """
+ Delete all messages until a certain limit.
+
+ A limit can be either a message, and ISO date-time string, or a time delta.
+ If a message is specified, `channel` cannot be specified.
+ """
+ await self._clean_messages(
+ ctx,
+ CleanMessages.message_limit,
+ channels=[channel] if channel else None,
+ first_limit=until,
+ )
+
+ @clean_group.command(name="between", aliases=["after-until", "from-to"])
+ async def clean_between(
+ self,
+ ctx: Context,
+ first_limit: CleanLimit,
+ second_limit: CleanLimit,
+ channel: TextChannel = None
+ ) -> None:
+ """
+ Delete all messages within range.
+
+ The range is specified through two limits.
+ A limit can be either a message, and ISO date-time string, or a time delta.
+
+ If two messages are specified, they both must be in the same channel.
+ If a message is specified, `channel` cannot be specified.
+ """
+ await self._clean_messages(
+ ctx,
+ CleanMessages.message_limit,
+ channels=[channel] if channel else None,
+ first_limit=first_limit,
+ second_limit=second_limit,
+ )
+
+ @clean_group.command(name="stop", aliases=["cancel", "abort"])
+ async def clean_cancel(self, ctx: Context) -> None:
+ """If there is an ongoing cleaning process, attempt to immediately cancel it."""
+ if not self.cleaning:
+ message = ":question: There's no cleaning going on."
+ else:
+ self.cleaning = False
+ message = f"{Emojis.check_mark} Clean interrupted."
+
+ await self._send_expiring_message(ctx, message)
+ await self._delete_invocation(ctx)
+
+ # endregion
+
+ 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)
+
+ async def cog_command_error(self, ctx: Context, error: Exception) -> None:
+ """Safely end the cleaning operation on unexpected errors."""
+ self.cleaning = False
+
+
+def setup(bot: Bot) -> None:
+ """Load the Clean cog."""
+ bot.add_cog(Clean(bot))
diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py
index 56051d0e5..14db37367 100644
--- a/bot/exts/moderation/defcon.py
+++ b/bot/exts/moderation/defcon.py
@@ -4,6 +4,7 @@ from datetime import datetime
from enum import Enum
from typing import Optional, Union
+import arrow
from aioredis import RedisError
from async_rediscache import RedisCache
from dateutil.relativedelta import relativedelta
@@ -49,7 +50,7 @@ class Action(Enum):
SERVER_OPEN = ActionInfo(Icons.defcon_unshutdown, Emojis.defcon_unshutdown, Colours.soft_green, "")
SERVER_SHUTDOWN = ActionInfo(Icons.defcon_shutdown, Emojis.defcon_shutdown, Colours.soft_red, "")
DURATION_UPDATE = ActionInfo(
- Icons.defcon_update, Emojis.defcon_update, Colour.blurple(), "**Threshold:** {threshold}\n\n"
+ Icons.defcon_update, Emojis.defcon_update, Colour.og_blurple(), "**Threshold:** {threshold}\n\n"
)
@@ -109,7 +110,7 @@ class Defcon(Cog):
async def on_member_join(self, member: Member) -> None:
"""Check newly joining users to see if they meet the account age threshold."""
if self.threshold:
- now = datetime.utcnow()
+ now = arrow.utcnow()
if now - member.created_at < relativedelta_to_timedelta(self.threshold):
log.info(f"Rejecting user {member}: Account is too new")
@@ -137,7 +138,7 @@ class Defcon(Cog):
await self.mod_log.send_log_message(
Icons.defcon_denied, Colours.soft_red, "Entry denied",
- message, member.avatar_url_as(static_format="png")
+ message, member.display_avatar.url
)
@group(name='defcon', aliases=('dc',), invoke_without_command=True)
@@ -151,7 +152,7 @@ class Defcon(Cog):
async def status(self, ctx: Context) -> None:
"""Check the current status of DEFCON mode."""
embed = Embed(
- colour=Colour.blurple(), title="DEFCON Status",
+ colour=Colour.og_blurple(), title="DEFCON Status",
description=f"""
**Threshold:** {humanize_delta(self.threshold) if self.threshold else "-"}
**Expires:** {discord_timestamp(self.expiry, TimestampFormats.RELATIVE) if self.expiry else "-"}
@@ -185,7 +186,12 @@ class Defcon(Cog):
role = ctx.guild.default_role
permissions = role.permissions
- permissions.update(send_messages=False, add_reactions=False, connect=False)
+ permissions.update(
+ send_messages=False,
+ add_reactions=False,
+ send_messages_in_threads=False,
+ connect=False
+ )
await role.edit(reason="DEFCON shutdown", permissions=permissions)
await ctx.send(f"{Action.SERVER_SHUTDOWN.value.emoji} Server shut down.")
@@ -196,7 +202,12 @@ class Defcon(Cog):
role = ctx.guild.default_role
permissions = role.permissions
- permissions.update(send_messages=True, add_reactions=True, connect=True)
+ permissions.update(
+ send_messages=True,
+ add_reactions=True,
+ send_messages_in_threads=True,
+ connect=True
+ )
await role.edit(reason="DEFCON unshutdown", permissions=permissions)
await ctx.send(f"{Action.SERVER_OPEN.value.emoji} Server reopened.")
@@ -244,7 +255,8 @@ class Defcon(Cog):
expiry_message = ""
if expiry:
- expiry_message = f" for the next {humanize_delta(relativedelta(expiry, datetime.utcnow()), max_units=2)}"
+ activity_duration = relativedelta(expiry, arrow.utcnow().datetime)
+ expiry_message = f" for the next {humanize_delta(activity_duration, max_units=2)}"
if self.threshold:
channel_message = (
diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py
index 4470b6dd6..77dfad255 100644
--- a/bot/exts/moderation/incidents.py
+++ b/bot/exts/moderation/incidents.py
@@ -1,16 +1,18 @@
import asyncio
-import typing as t
+import re
from datetime import datetime
from enum import Enum
+from typing import Optional
import discord
-from discord.ext.commands import Cog
+from async_rediscache import RedisCache
+from discord.ext.commands import Cog, Context, MessageConverter, MessageNotFound
from bot.bot import Bot
-from bot.constants import Channels, Colours, Emojis, Guild, Webhooks
+from bot.constants import Channels, Colours, Emojis, Guild, Roles, Webhooks
from bot.log import get_logger
from bot.utils import scheduling
-from bot.utils.messages import sub_clyde
+from bot.utils.messages import format_user, sub_clyde
log = get_logger(__name__)
@@ -22,6 +24,12 @@ CRAWL_LIMIT = 50
# Seconds for `crawl_task` to sleep after adding reactions to a message
CRAWL_SLEEP = 2
+DISCORD_MESSAGE_LINK_RE = re.compile(
+ r"(https?:\/\/(?:(ptb|canary|www)\.)?discord(?:app)?\.com\/channels\/"
+ r"[0-9]{15,20}"
+ r"\/[0-9]{15,20}\/[0-9]{15,20})"
+)
+
class Signal(Enum):
"""
@@ -37,17 +45,17 @@ class Signal(Enum):
# Reactions from non-mod roles will be removed
-ALLOWED_ROLES: t.Set[int] = set(Guild.moderation_roles)
+ALLOWED_ROLES: set[int] = set(Guild.moderation_roles)
# Message must have all of these emoji to pass the `has_signals` check
-ALL_SIGNALS: t.Set[str] = {signal.value for signal in Signal}
+ALL_SIGNALS: set[str] = {signal.value for signal in Signal}
# An embed coupled with an optional file to be dispatched
# If the file is not None, the embed attempts to show it in its body
-FileEmbed = t.Tuple[discord.Embed, t.Optional[discord.File]]
+FileEmbed = tuple[discord.Embed, Optional[discord.File]]
-async def download_file(attachment: discord.Attachment) -> t.Optional[discord.File]:
+async def download_file(attachment: discord.Attachment) -> Optional[discord.File]:
"""
Download & return `attachment` file.
@@ -94,7 +102,7 @@ async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: di
timestamp=datetime.utcnow(),
colour=colour,
)
- embed.set_footer(text=footer, icon_url=actioned_by.avatar_url)
+ embed.set_footer(text=footer, icon_url=actioned_by.display_avatar.url)
if incident.attachments:
attachment = incident.attachments[0] # User-sent messages can only contain one attachment
@@ -105,7 +113,7 @@ async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: di
else:
embed.set_author(name="[Failed to relay attachment]", url=attachment.proxy_url) # Embed links the file
else:
- file = None
+ file = discord.utils.MISSING
return embed, file
@@ -121,7 +129,7 @@ def is_incident(message: discord.Message) -> bool:
return all(conditions)
-def own_reactions(message: discord.Message) -> t.Set[str]:
+def own_reactions(message: discord.Message) -> set[str]:
"""Get the set of reactions placed on `message` by the bot itself."""
return {str(reaction.emoji) for reaction in message.reactions if reaction.me}
@@ -131,6 +139,108 @@ def has_signals(message: discord.Message) -> bool:
return ALL_SIGNALS.issubset(own_reactions(message))
+def shorten_text(text: str) -> str:
+ """
+ Truncate the text if there are over 3 lines or 300 characters, or if it is a single word.
+
+ The maximum length of the string would be 303 characters across 3 lines at maximum.
+ """
+ original_length = len(text)
+ # Truncate text to a maximum of 300 characters
+ if len(text) > 300:
+ text = text[:300]
+
+ # Limit to a maximum of three lines
+ text = "\n".join(text.split("\n", maxsplit=3)[:3])
+
+ # If it is a single word, then truncate it to 50 characters
+ if text.find(" ") == -1:
+ text = text[:50]
+
+ # Remove extra whitespaces from the `text`
+ text = text.strip()
+
+ # Add placeholder if the text was shortened
+ if len(text) < original_length:
+ text = f"{text}..."
+
+ return text
+
+
+async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[discord.Embed]:
+ """
+ Create an embedded representation of the discord message link contained in the incident report.
+
+ The Embed would contain the following information -->
+ Author: @Jason Terror ♦ (736234578745884682)
+ Channel: Special/#bot-commands (814190307980607493)
+ Content: This is a very important message!
+ """
+ embed = None
+
+ try:
+ message: discord.Message = await MessageConverter().convert(ctx, message_link)
+ except MessageNotFound:
+ mod_logs_channel = ctx.bot.get_channel(Channels.mod_log)
+
+ last_100_logs: list[discord.Message] = await mod_logs_channel.history(limit=100).flatten()
+
+ for log_entry in last_100_logs:
+ if not log_entry.embeds:
+ continue
+
+ log_embed: discord.Embed = log_entry.embeds[0]
+ if (
+ log_embed.author.name == "Message deleted"
+ and f"[Jump to message]({message_link})" in log_embed.description
+ ):
+ embed = discord.Embed(
+ colour=discord.Colour.dark_gold(),
+ title="Deleted Message Link",
+ description=(
+ f"Found <#{Channels.mod_log}> entry for deleted message: "
+ f"[Jump to message]({log_entry.jump_url})."
+ )
+ )
+ if not embed:
+ embed = discord.Embed(
+ colour=discord.Colour.red(),
+ title="Bad Message Link",
+ description=f"Message {message_link} not found."
+ )
+ except discord.DiscordException as e:
+ log.exception(f"Failed to make message link embed for '{message_link}', raised exception: {e}")
+ else:
+ channel = message.channel
+ if not channel.permissions_for(channel.guild.get_role(Roles.helpers)).view_channel:
+ log.info(
+ f"Helpers don't have read permissions in #{channel.name},"
+ f" not sending message link embed for {message_link}"
+ )
+ return
+
+ embed = discord.Embed(
+ colour=discord.Colour.gold(),
+ description=(
+ f"**Author:** {format_user(message.author)}\n"
+ f"**Channel:** {channel.mention} ({channel.category}"
+ f"{f'/#{channel.parent.name} - ' if isinstance(channel, discord.Thread) else '/#'}"
+ f"{channel.name})\n"
+ ),
+ timestamp=message.created_at
+ )
+ embed.add_field(
+ name="Content",
+ value=shorten_text(message.content) if message.content else "[No Message Content]"
+ )
+ embed.set_footer(text=f"Message ID: {message.id}")
+
+ if message.attachments:
+ embed.set_image(url=message.attachments[0].url)
+
+ return embed
+
+
async def add_signals(incident: discord.Message) -> None:
"""
Add `Signal` member emoji to `incident` as reactions.
@@ -168,6 +278,7 @@ class Incidents(Cog):
* See: `crawl_incidents`
On message:
+ * Run message through `extract_message_links` and send them into the channel
* Add `Signal` member emoji if message qualifies as an incident
* Ignore messages starting with #
* Use this if verbal communication is necessary
@@ -181,18 +292,35 @@ class Incidents(Cog):
* If `Signal.ACTIONED` or `Signal.NOT_ACTIONED` were chosen, attempt to
relay the incident message to #incidents-archive
* If relay successful, delete original message
+ * Delete quotation message if cached
* See: `on_raw_reaction_add`
Please refer to function docstrings for implementation details.
"""
+ # This dictionary maps an incident report message to the message link embed's ID
+ # RedisCache[discord.Message.id, discord.Message.id]
+ message_link_embeds_cache = RedisCache()
+
def __init__(self, bot: Bot) -> None:
"""Prepare `event_lock` and schedule `crawl_task` on start-up."""
self.bot = bot
+ self.incidents_webhook = None
+
+ scheduling.create_task(self.fetch_webhook(), event_loop=self.bot.loop)
self.event_lock = asyncio.Lock()
self.crawl_task = scheduling.create_task(self.crawl_incidents(), event_loop=self.bot.loop)
+ async def fetch_webhook(self) -> None:
+ """Fetch the incidents webhook object, so we can post message link embeds to it."""
+ await self.bot.wait_until_guild_available()
+
+ try:
+ self.incidents_webhook = await self.bot.fetch_webhook(Webhooks.incidents)
+ except discord.HTTPException:
+ log.error(f"Failed to fetch incidents webhook with id `{Webhooks.incidents}`.")
+
async def crawl_incidents(self) -> None:
"""
Crawl #incidents and add missing emoji where necessary.
@@ -253,7 +381,7 @@ class Incidents(Cog):
await webhook.send(
embed=embed,
username=sub_clyde(incident.author.name),
- avatar_url=incident.author.avatar_url,
+ avatar_url=incident.author.display_avatar.url,
file=attachment_file,
)
except Exception:
@@ -292,8 +420,11 @@ class Incidents(Cog):
This ensures that if there is a racing event awaiting the lock, it will fail to find the
message, and will abort. There is a `timeout` to ensure that this doesn't hold the lock
forever should something go wrong.
+
+ Deletes cache value (`message_link_embeds_cache`) of `incident` if it exists. It then removes the
+ webhook message for that particular link from the channel.
"""
- members_roles: t.Set[int] = {role.id for role in member.roles}
+ members_roles: 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")
try:
@@ -340,7 +471,11 @@ class Incidents(Cog):
else:
log.trace("Deletion was confirmed")
- async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]:
+ if self.incidents_webhook:
+ # Deletes the message link embeds found in cache from the channel and cache.
+ await self.delete_msg_link_embed(incident.id)
+
+ async def resolve_message(self, message_id: int) -> Optional[discord.Message]:
"""
Get `discord.Message` for `message_id` from cache, or API.
@@ -355,7 +490,7 @@ class Incidents(Cog):
"""
await self.bot.wait_until_guild_available() # First make sure that the cache is ready
log.trace(f"Resolving message for: {message_id=}")
- message: t.Optional[discord.Message] = self.bot._connection._get_message(message_id)
+ message: Optional[discord.Message] = self.bot._connection._get_message(message_id)
if message is not None:
log.trace("Message was found in cache")
@@ -419,9 +554,107 @@ class Incidents(Cog):
@Cog.listener()
async def on_message(self, message: discord.Message) -> None:
- """Pass `message` to `add_signals` if and only if it satisfies `is_incident`."""
- if is_incident(message):
- await add_signals(message)
+ """
+ Pass `message` to `add_signals` and `extract_message_links` if it satisfies `is_incident`.
+
+ If `message` is an incident report, then run it through `extract_message_links` to get all
+ the message link embeds (embeds which contain information about that particular link).
+ These message link embeds are then sent into the channel.
+
+ Also passes the message into `add_signals` if the message is an incident.
+ """
+ if not is_incident(message):
+ return
+
+ await add_signals(message)
+
+ # Only use this feature if incidents webhook embed is found
+ if self.incidents_webhook:
+ if embed_list := await self.extract_message_links(message):
+ await self.send_message_link_embeds(embed_list, message, self.incidents_webhook)
+
+ @Cog.listener()
+ async def on_raw_message_delete(self, payload: discord.RawMessageDeleteEvent) -> None:
+ """
+ Delete message link embeds for `payload.message_id`.
+
+ Search through the cache for message, if found delete it from cache and channel.
+ """
+ if self.incidents_webhook:
+ await self.delete_msg_link_embed(payload.message_id)
+
+ async def extract_message_links(self, message: discord.Message) -> Optional[list[discord.Embed]]:
+ """
+ Check if there's any message links in the text content.
+
+ Then pass the message_link into `make_message_link_embed` to format an
+ embed for it containing information about the link.
+
+ As Discord only allows a max of 10 embeds in a single webhook, just send the
+ first 10 embeds and don't care about the rest.
+
+ If no links are found for the message, just log a trace statement.
+ """
+ message_links = DISCORD_MESSAGE_LINK_RE.findall(message.content)
+ if not message_links:
+ log.trace(
+ f"No message links detected on incident message with id {message.id}."
+ )
+ return
+
+ embeds = []
+ for message_link in message_links[:10]:
+ ctx = await self.bot.get_context(message)
+ embed = await make_message_link_embed(ctx, message_link[0])
+ if embed:
+ embeds.append(embed)
+
+ return embeds
+
+ async def send_message_link_embeds(
+ self,
+ webhook_embed_list: list,
+ message: discord.Message,
+ webhook: discord.Webhook,
+ ) -> Optional[int]:
+ """
+ Send message link embeds to #incidents channel.
+
+ Using the `webhook` passed in as a parameter to send
+ the embeds in the `webhook_embed_list` parameter.
+
+ After sending each embed it maps the `message.id`
+ to the `webhook_msg_ids` IDs in the async redis-cache.
+ """
+ try:
+ webhook_msg = await webhook.send(
+ embeds=[embed for embed in webhook_embed_list if embed],
+ username=sub_clyde(message.author.name),
+ avatar_url=message.author.display_avatar.url,
+ wait=True,
+ )
+ except discord.DiscordException:
+ log.exception(
+ f"Failed to send message link embed {message.id} to #incidents."
+ )
+ else:
+ await self.message_link_embeds_cache.set(message.id, webhook_msg.id)
+ log.trace("Message link embeds sent successfully to #incidents!")
+ return webhook_msg.id
+
+ async def delete_msg_link_embed(self, message_id: int) -> None:
+ """Delete the Discord message link message found in cache for `message_id`."""
+ log.trace("Deleting Discord message link's webhook message.")
+ webhook_msg_id = await self.message_link_embeds_cache.get(int(message_id))
+
+ if webhook_msg_id:
+ try:
+ await self.incidents_webhook.delete_message(webhook_msg_id)
+ except discord.errors.NotFound:
+ log.trace(f"Incidents message link embed (`{webhook_msg_id}`) has already been deleted, skipping.")
+
+ await self.message_link_embeds_cache.delete(message_id)
+ log.trace("Successfully deleted discord links webhook message.")
def setup(bot: Bot) -> None:
diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py
index 2a1ccb9d4..762eb6afa 100644
--- a/bot/exts/moderation/infraction/_scheduler.py
+++ b/bot/exts/moderation/infraction/_scheduler.py
@@ -1,9 +1,9 @@
import textwrap
import typing as t
from abc import abstractmethod
-from datetime import datetime
from gettext import ngettext
+import arrow
import dateutil.parser
import discord
from discord.ext.commands import Context
@@ -67,7 +67,7 @@ class InfractionScheduler:
# We make sure to fire this
if to_schedule:
next_reschedule_point = max(
- dateutil.parser.isoparse(infr["expires_at"]).replace(tzinfo=None) for infr in to_schedule
+ dateutil.parser.isoparse(infr["expires_at"]) for infr in to_schedule
)
log.trace("Will reschedule remaining infractions at %s", next_reschedule_point)
@@ -81,12 +81,16 @@ class InfractionScheduler:
apply_coro: t.Optional[t.Awaitable]
) -> None:
"""Reapply an infraction if it's still active or deactivate it if less than 60 sec left."""
- # Calculate the time remaining, in seconds, for the mute.
- expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None)
- delta = (expiry - datetime.utcnow()).total_seconds()
+ if infraction["expires_at"] is not None:
+ # Calculate the time remaining, in seconds, for the mute.
+ expiry = dateutil.parser.isoparse(infraction["expires_at"])
+ delta = (expiry - arrow.utcnow()).total_seconds()
+ else:
+ # If the infraction is permanent, it is not possible to get the time remaining.
+ delta = None
- # Mark as inactive if less than a minute remains.
- if delta < 60:
+ # Mark as inactive if the infraction is not permanent and less than a minute remains.
+ if delta is not None and delta < 60:
log.info(
"Infraction will be deactivated instead of re-applied "
"because less than 1 minute remains."
@@ -171,13 +175,7 @@ class InfractionScheduler:
dm_log_text = "\nDM: Sent"
end_msg = ""
- if infraction["actor"] == self.bot.user.id:
- log.trace(
- f"Infraction #{id_} actor is bot; including the reason in the confirmation message."
- )
- if reason:
- end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})"
- elif is_mod_channel(ctx.channel):
+ if is_mod_channel(ctx.channel):
log.trace(f"Fetching total infraction count for {user}.")
infractions = await self.bot.api_client.get(
@@ -186,6 +184,12 @@ class InfractionScheduler:
)
total = len(infractions)
end_msg = f" (#{id_} ; {total} infraction{ngettext('', 's', total)} total)"
+ elif infraction["actor"] == self.bot.user.id:
+ log.trace(
+ f"Infraction #{id_} actor is bot; including the reason in the confirmation message."
+ )
+ if reason:
+ end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})"
purge = infraction.get("purge", "")
@@ -249,7 +253,7 @@ class InfractionScheduler:
icon_url=icon,
colour=Colours.soft_red,
title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}",
- thumbnail=user.avatar_url_as(static_format="png"),
+ thumbnail=user.display_avatar.url,
text=textwrap.dedent(f"""
Member: {messages.format_user(user)}
Actor: {ctx.author.mention}{dm_log_text}{expiry_log_text}
@@ -343,7 +347,7 @@ class InfractionScheduler:
icon_url=_utils.INFRACTION_ICONS[infr_type][1],
colour=Colours.soft_green,
title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}",
- thumbnail=user.avatar_url_as(static_format="png"),
+ thumbnail=user.display_avatar.url,
text="\n".join(f"{k}: {v}" for k, v in log_text.items()),
footer=footer,
content=log_content,
@@ -378,7 +382,7 @@ class InfractionScheduler:
log.info(f"Marking infraction #{id_} as inactive (expired).")
- expiry = dateutil.parser.isoparse(expiry).replace(tzinfo=None) if expiry else None
+ expiry = dateutil.parser.isoparse(expiry) if expiry else None
created = time.format_infraction_with_duration(inserted_at, expiry)
log_content = None
@@ -460,7 +464,7 @@ class InfractionScheduler:
log_title = "expiration failed" if "Failure" in log_text else "expired"
user = self.bot.get_user(user_id)
- avatar = user.avatar_url_as(static_format="png") if user else None
+ avatar = user.display_avatar.url if user else None
# Move reason to end so when reason is too long, this is not gonna cut out required items.
log_text["Reason"] = log_text.pop("Reason")
@@ -499,5 +503,5 @@ class InfractionScheduler:
At the time of expiration, the infraction is marked as inactive on the website and the
expiration task is cancelled.
"""
- expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None)
+ expiry = dateutil.parser.isoparse(infraction["expires_at"])
self.scheduler.schedule_at(expiry, infraction["id"], self.deactivate_infraction(infraction))
diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py
index 89718c857..bb3cc5380 100644
--- a/bot/exts/moderation/infraction/_utils.py
+++ b/bot/exts/moderation/infraction/_utils.py
@@ -27,16 +27,18 @@ RULES_URL = "https://pythondiscord.com/pages/rules"
# Type aliases
Infraction = t.Dict[str, t.Union[str, int, bool]]
-APPEAL_EMAIL = "[email protected]"
+APPEAL_SERVER_INVITE = "https://discord.gg/WXrCJxWBnm"
INFRACTION_TITLE = "Please review our rules"
-INFRACTION_APPEAL_EMAIL_FOOTER = f"To appeal this infraction, send an e-mail to {APPEAL_EMAIL}"
+INFRACTION_APPEAL_SERVER_FOOTER = f"\nTo appeal this infraction, join our [appeals server]({APPEAL_SERVER_INVITE})."
INFRACTION_APPEAL_MODMAIL_FOOTER = (
- 'If you would like to discuss or appeal this infraction, '
- 'send a message to the ModMail bot'
+ '\nIf you would like to discuss or appeal this infraction, '
+ 'send a message to the ModMail bot.'
)
INFRACTION_AUTHOR_NAME = "Infraction information"
+LONGEST_EXTRAS = max(len(INFRACTION_APPEAL_SERVER_FOOTER), len(INFRACTION_APPEAL_MODMAIL_FOOTER))
+
INFRACTION_DESCRIPTION_TEMPLATE = (
"**Type:** {type}\n"
"**Expires:** {expires}\n"
@@ -170,8 +172,10 @@ async def notify_infraction(
)
# For case when other fields than reason is too long and this reach limit, then force-shorten string
- if len(text) > 4096:
- text = f"{text[:4093]}..."
+ if len(text) > 4096 - LONGEST_EXTRAS:
+ text = f"{text[:4093-LONGEST_EXTRAS]}..."
+
+ text += INFRACTION_APPEAL_SERVER_FOOTER if infr_type.lower() == 'ban' else INFRACTION_APPEAL_MODMAIL_FOOTER
embed = discord.Embed(
description=text,
@@ -182,10 +186,6 @@ async def notify_infraction(
embed.title = INFRACTION_TITLE
embed.url = RULES_URL
- embed.set_footer(
- text=INFRACTION_APPEAL_EMAIL_FOOTER if infr_type == 'Ban' else INFRACTION_APPEAL_MODMAIL_FOOTER
- )
-
return await send_private_embed(user, embed)
diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py
index a50339ee2..a833eb227 100644
--- a/bot/exts/moderation/infraction/management.py
+++ b/bot/exts/moderation/infraction/management.py
@@ -1,6 +1,6 @@
import textwrap
import typing as t
-from datetime import datetime
+from datetime import datetime, timezone
import dateutil.parser
import discord
@@ -196,14 +196,14 @@ class ModManagement(commands.Cog):
if user:
user_text = messages.format_user(user)
- thumbnail = user.avatar_url_as(static_format="png")
+ thumbnail = user.display_avatar.url
else:
user_text = f"<@{user_id}>"
thumbnail = None
await self.mod_log.send_log_message(
icon_url=constants.Icons.pencil,
- colour=discord.Colour.blurple(),
+ colour=discord.Colour.og_blurple(),
title="Infraction edited",
thumbnail=thumbnail,
text=textwrap.dedent(f"""
@@ -243,8 +243,9 @@ class ModManagement(commands.Cog):
else:
user_str = str(user.id)
+ formatted_infraction_count = self.format_infraction_count(len(infraction_list))
embed = discord.Embed(
- title=f"Infractions for {user_str} ({len(infraction_list)} total)",
+ title=f"Infractions for {user_str} ({formatted_infraction_count} total)",
colour=discord.Colour.orange()
)
await self.send_infraction_list(ctx, embed, infraction_list)
@@ -256,15 +257,70 @@ class ModManagement(commands.Cog):
'bot/infractions/expanded',
params={'search': reason}
)
+
+ formatted_infraction_count = self.format_infraction_count(len(infraction_list))
embed = discord.Embed(
- title=f"Infractions matching `{reason}` ({len(infraction_list)} total)",
+ title=f"Infractions matching `{reason}` ({formatted_infraction_count} total)",
colour=discord.Colour.orange()
)
await self.send_infraction_list(ctx, embed, infraction_list)
# endregion
+ # region: Search for infractions by given actor
+
+ @infraction_group.command(name="by", aliases=("b",))
+ async def search_by_actor(
+ self,
+ ctx: Context,
+ actor: t.Union[t.Literal["m", "me"], UnambiguousUser],
+ oldest_first: bool = False
+ ) -> None:
+ """
+ Search for infractions made by `actor`.
+
+ Use "m" or "me" as the `actor` to get infractions by author.
+
+ Use "1" for `oldest_first` to send oldest infractions first.
+ """
+ if isinstance(actor, str):
+ actor = ctx.author
+
+ if oldest_first:
+ ordering = 'inserted_at' # oldest infractions first
+ else:
+ ordering = '-inserted_at' # newest infractions first
+
+ infraction_list = await self.bot.api_client.get(
+ 'bot/infractions/expanded',
+ params={
+ 'actor__id': str(actor.id),
+ 'ordering': ordering
+ }
+ )
+
+ formatted_infraction_count = self.format_infraction_count(len(infraction_list))
+ embed = discord.Embed(
+ title=f"Infractions by {actor} ({formatted_infraction_count} total)",
+ colour=discord.Colour.orange()
+ )
+
+ await self.send_infraction_list(ctx, embed, infraction_list)
+
+ # endregion
# region: Utility functions
+ @staticmethod
+ def format_infraction_count(infraction_count: int) -> str:
+ """
+ Returns a string-formatted infraction count.
+
+ API limits returned infractions to a maximum of 100, so if `infraction_count`
+ is 100 then we return `"100+"`. Otherwise, return `str(infraction_count)`.
+ """
+ if infraction_count == 100:
+ return "100+"
+ return str(infraction_count)
+
async def send_infraction_list(
self,
ctx: Context,
@@ -314,8 +370,11 @@ class ModManagement(commands.Cog):
if expires_at is None:
duration = "*Permanent*"
else:
- date_from = datetime.fromtimestamp(float(time.DISCORD_TIMESTAMP_REGEX.match(created).group(1)))
- date_to = dateutil.parser.isoparse(expires_at).replace(tzinfo=None)
+ date_from = datetime.fromtimestamp(
+ float(time.DISCORD_TIMESTAMP_REGEX.match(created).group(1)),
+ timezone.utc
+ )
+ date_to = dateutil.parser.isoparse(expires_at)
duration = humanize_delta(relativedelta(date_to, date_from))
lines = textwrap.dedent(f"""
diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py
index fbb3684e7..91709e5e5 100644
--- a/bot/exts/moderation/modlog.py
+++ b/bot/exts/moderation/modlog.py
@@ -2,13 +2,13 @@ import asyncio
import difflib
import itertools
import typing as t
-from datetime import datetime
+from datetime import datetime, timezone
from itertools import zip_longest
import discord
from dateutil.relativedelta import relativedelta
from deepdiff import DeepDiff
-from discord import Colour
+from discord import Colour, Message, Thread
from discord.abc import GuildChannel
from discord.ext.commands import Cog, Context
from discord.utils import escape_markdown
@@ -41,7 +41,6 @@ class ModLog(Cog, name="ModLog"):
self.bot = bot
self._ignored = {event: [] for event in Event}
- self._cached_deletes = []
self._cached_edits = []
async def upload_log(
@@ -58,7 +57,7 @@ class ModLog(Cog, name="ModLog"):
'bot/deleted-messages',
json={
'actor': actor_id,
- 'creation': datetime.utcnow().isoformat(),
+ 'creation': datetime.now(timezone.utc).isoformat(),
'deletedmessage_set': [
{
'id': message.id,
@@ -251,7 +250,7 @@ class ModLog(Cog, name="ModLog"):
message = f"**#{after.name}** (`{after.id}`)\n{message}"
await self.send_log_message(
- Icons.hash_blurple, Colour.blurple(),
+ Icons.hash_blurple, Colour.og_blurple(),
"Channel updated", message
)
@@ -326,7 +325,7 @@ class ModLog(Cog, name="ModLog"):
message = f"**{after.name}** (`{after.id}`)\n{message}"
await self.send_log_message(
- Icons.crown_blurple, Colour.blurple(),
+ Icons.crown_blurple, Colour.og_blurple(),
"Role updated", message
)
@@ -376,9 +375,9 @@ class ModLog(Cog, name="ModLog"):
message = f"**{after.name}** (`{after.id}`)\n{message}"
await self.send_log_message(
- Icons.guild_update, Colour.blurple(),
+ Icons.guild_update, Colour.og_blurple(),
"Guild updated", message,
- thumbnail=after.icon_url_as(format="png")
+ thumbnail=after.icon.with_static_format("png")
)
@Cog.listener()
@@ -394,7 +393,7 @@ class ModLog(Cog, name="ModLog"):
await self.send_log_message(
Icons.user_ban, Colours.soft_red,
"User banned", format_user(member),
- thumbnail=member.avatar_url_as(static_format="png"),
+ thumbnail=member.display_avatar.url,
channel_id=Channels.user_log
)
@@ -404,7 +403,7 @@ class ModLog(Cog, name="ModLog"):
if member.guild.id != GuildConstant.id:
return
- now = datetime.utcnow()
+ now = datetime.now(timezone.utc)
difference = abs(relativedelta(now, member.created_at))
message = format_user(member) + "\n\n**Account age:** " + humanize_delta(difference)
@@ -415,7 +414,7 @@ class ModLog(Cog, name="ModLog"):
await self.send_log_message(
Icons.sign_in, Colours.soft_green,
"User joined", message,
- thumbnail=member.avatar_url_as(static_format="png"),
+ thumbnail=member.display_avatar.url,
channel_id=Channels.user_log
)
@@ -432,7 +431,7 @@ class ModLog(Cog, name="ModLog"):
await self.send_log_message(
Icons.sign_out, Colours.soft_red,
"User left", format_user(member),
- thumbnail=member.avatar_url_as(static_format="png"),
+ thumbnail=member.display_avatar.url,
channel_id=Channels.user_log
)
@@ -447,9 +446,9 @@ class ModLog(Cog, name="ModLog"):
return
await self.send_log_message(
- Icons.user_unban, Colour.blurple(),
+ Icons.user_unban, Colour.og_blurple(),
"User unbanned", format_user(member),
- thumbnail=member.avatar_url_as(static_format="png"),
+ thumbnail=member.display_avatar.url,
channel_id=Channels.mod_log
)
@@ -512,35 +511,62 @@ class ModLog(Cog, name="ModLog"):
await self.send_log_message(
icon_url=Icons.user_update,
- colour=Colour.blurple(),
+ colour=Colour.og_blurple(),
title="Member updated",
text=message,
- thumbnail=after.avatar_url_as(static_format="png"),
+ thumbnail=after.display_avatar.url,
channel_id=Channels.user_log
)
- @Cog.listener()
- async def on_message_delete(self, message: discord.Message) -> None:
- """Log message delete event to message change log."""
+ def is_message_blacklisted(self, message: Message) -> bool:
+ """Return true if the message is in a blacklisted thread or channel."""
+ # Ignore bots or DMs
+ if message.author.bot or not message.guild:
+ return True
+
+ return self.is_channel_ignored(message.channel.id)
+
+ def is_channel_ignored(self, channel_id: int) -> bool:
+ """
+ Return true if the channel, or parent channel in the case of threads, passed should be ignored by modlog.
+
+ Currently ignored channels are:
+ 1. Channels not in the guild we care about (constants.Guild.id).
+ 2. Channels that mods do not have view permissions to
+ 3. Channels in constants.Guild.modlog_blacklist
+ """
+ channel = self.bot.get_channel(channel_id)
+
+ # Ignore not found channels, DMs, and messages outside of the main guild.
+ if not channel or not hasattr(channel, "guild") or channel.guild.id != GuildConstant.id:
+ return True
+
+ # Look at the parent channel of a thread.
+ if isinstance(channel, Thread):
+ channel = channel.parent
+
+ # Mod team doesn't have view permission to the channel.
+ if not channel.permissions_for(channel.guild.get_role(Roles.mod_team)).view_channel:
+ return True
+
+ return channel.id in GuildConstant.modlog_blacklist
+
+ async def log_cached_deleted_message(self, message: discord.Message) -> None:
+ """
+ Log the message's details to message change log.
+
+ This is called when a cached message is deleted.
+ """
channel = message.channel
author = message.author
- # Ignore DMs.
- if not message.guild:
+ if self.is_message_blacklisted(message):
return
- if message.guild.id != GuildConstant.id or channel.id in GuildConstant.modlog_blacklist:
- return
-
- self._cached_deletes.append(message.id)
-
if message.id in self._ignored[Event.message_delete]:
self._ignored[Event.message_delete].remove(message.id)
return
- if author.bot:
- return
-
if channel.category:
response = (
f"**Author:** {format_user(author)}\n"
@@ -581,17 +607,14 @@ class ModLog(Cog, name="ModLog"):
channel_id=Channels.message_log
)
- @Cog.listener()
- async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> None:
- """Log raw message delete event to message change log."""
- if event.guild_id != GuildConstant.id or event.channel_id in GuildConstant.modlog_blacklist:
- return
+ async def log_uncached_deleted_message(self, event: discord.RawMessageDeleteEvent) -> None:
+ """
+ Log the message's details to message change log.
- await asyncio.sleep(1) # Wait here in case the normal event was fired
-
- if event.message_id in self._cached_deletes:
- # It was in the cache and the normal event was fired, so we can just ignore it
- self._cached_deletes.remove(event.message_id)
+ This is called when a message absent from the cache is deleted.
+ Hence, the message contents aren't logged.
+ """
+ if self.is_channel_ignored(event.channel_id):
return
if event.message_id in self._ignored[Event.message_delete]:
@@ -623,14 +646,17 @@ class ModLog(Cog, name="ModLog"):
)
@Cog.listener()
+ async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> None:
+ """Log message deletions to message change log."""
+ if event.cached_message is not None:
+ await self.log_cached_deleted_message(event.cached_message)
+ else:
+ await self.log_uncached_deleted_message(event)
+
+ @Cog.listener()
async def on_message_edit(self, msg_before: discord.Message, msg_after: discord.Message) -> None:
"""Log message edit event to message change log."""
- if (
- not msg_before.guild
- or msg_before.guild.id != GuildConstant.id
- or msg_before.channel.id in GuildConstant.modlog_blacklist
- or msg_before.author.bot
- ):
+ if self.is_message_blacklisted(msg_before):
return
self._cached_edits.append(msg_before.id)
@@ -694,7 +720,7 @@ class ModLog(Cog, name="ModLog"):
footer = None
await self.send_log_message(
- Icons.message_edit, Colour.blurple(), "Message edited", response,
+ Icons.message_edit, Colour.og_blurple(), "Message edited", response,
channel_id=Channels.message_log, timestamp_override=timestamp, footer=footer
)
@@ -707,12 +733,7 @@ class ModLog(Cog, name="ModLog"):
except discord.NotFound: # Was deleted before we got the event
return
- if (
- not message.guild
- or message.guild.id != GuildConstant.id
- or message.channel.id in GuildConstant.modlog_blacklist
- or message.author.bot
- ):
+ if self.is_message_blacklisted(message):
return
await asyncio.sleep(1) # Wait here in case the normal event was fired
@@ -742,16 +763,95 @@ class ModLog(Cog, name="ModLog"):
)
await self.send_log_message(
- Icons.message_edit, Colour.blurple(), "Message edited (Before)",
+ Icons.message_edit, Colour.og_blurple(), "Message edited (Before)",
before_response, channel_id=Channels.message_log
)
await self.send_log_message(
- Icons.message_edit, Colour.blurple(), "Message edited (After)",
+ Icons.message_edit, Colour.og_blurple(), "Message edited (After)",
after_response, channel_id=Channels.message_log
)
@Cog.listener()
+ async def on_thread_update(self, before: Thread, after: Thread) -> None:
+ """Log thread archiving, un-archiving and name edits."""
+ if self.is_channel_ignored(after.id):
+ log.trace("Ignoring update of thread %s (%d)", after.mention, after.id)
+ return
+
+ if before.name != after.name:
+ await self.send_log_message(
+ Icons.hash_blurple,
+ Colour.og_blurple(),
+ "Thread name edited",
+ (
+ f"Thread {after.mention} (`{after.id}`) from {after.parent.mention} (`{after.parent.id}`): "
+ f"`{before.name}` -> `{after.name}`"
+ )
+ )
+ return
+
+ if not before.archived and after.archived:
+ colour = Colours.soft_red
+ action = "archived"
+ icon = Icons.hash_red
+ elif before.archived and not after.archived:
+ colour = Colours.soft_green
+ action = "un-archived"
+ icon = Icons.hash_green
+ else:
+ return
+
+ await self.send_log_message(
+ icon,
+ colour,
+ f"Thread {action}",
+ (
+ f"Thread {after.mention} ({after.name}, `{after.id}`) from {after.parent.mention} "
+ f"(`{after.parent.id}`) was {action}"
+ )
+ )
+
+ @Cog.listener()
+ async def on_thread_delete(self, thread: Thread) -> None:
+ """Log thread deletion."""
+ if self.is_channel_ignored(thread.id):
+ log.trace("Ignoring deletion of thread %s (%d)", thread.mention, thread.id)
+ return
+
+ await self.send_log_message(
+ Icons.hash_red,
+ Colours.soft_red,
+ "Thread deleted",
+ (
+ f"Thread {thread.mention} ({thread.name}, `{thread.id}`) from {thread.parent.mention} "
+ f"(`{thread.parent.id}`) deleted"
+ )
+ )
+
+ @Cog.listener()
+ async def on_thread_join(self, thread: Thread) -> None:
+ """Log thread creation."""
+ # If we are in the thread already we can most probably assume we already logged it?
+ # We don't really have a better way of doing this since the API doesn't make any difference between the two
+ if thread.me:
+ return
+
+ if self.is_channel_ignored(thread.id):
+ log.trace("Ignoring creation of thread %s (%d)", thread.mention, thread.id)
+ return
+
+ await self.send_log_message(
+ Icons.hash_green,
+ Colours.soft_green,
+ "Thread created",
+ (
+ f"Thread {thread.mention} ({thread.name}, `{thread.id}`) from {thread.parent.mention} "
+ f"(`{thread.parent.id}`) created"
+ )
+ )
+
+ @Cog.listener()
async def on_voice_state_update(
self,
member: discord.Member,
@@ -761,7 +861,8 @@ class ModLog(Cog, name="ModLog"):
"""Log member voice state changes to the voice log channel."""
if (
member.guild.id != GuildConstant.id
- or (before.channel and before.channel.id in GuildConstant.modlog_blacklist)
+ or (before.channel and self.is_channel_ignored(before.channel.id))
+ or (after.channel and self.is_channel_ignored(after.channel.id))
):
return
@@ -783,7 +884,7 @@ class ModLog(Cog, name="ModLog"):
diff_values = {**diff.get("values_changed", {}), **diff.get("type_changes", {})}
icon = Icons.voice_state_blue
- colour = Colour.blurple()
+ colour = Colour.og_blurple()
changes = []
for attr, values in diff_values.items():
@@ -820,7 +921,7 @@ class ModLog(Cog, name="ModLog"):
colour=colour,
title="Voice state updated",
text=message,
- thumbnail=member.avatar_url_as(static_format="png"),
+ thumbnail=member.display_avatar.url,
channel_id=Channels.voice_log
)
diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py
index a7ccb8162..20a8c39d7 100644
--- a/bot/exts/moderation/modpings.py
+++ b/bot/exts/moderation/modpings.py
@@ -1,7 +1,9 @@
+import asyncio
import datetime
+import arrow
from async_rediscache import RedisCache
-from dateutil.parser import isoparse
+from dateutil.parser import isoparse, parse as dateutil_parse
from discord import Embed, Member
from discord.ext.commands import Cog, Context, group, has_any_role
@@ -11,9 +13,12 @@ from bot.converters import Expiry
from bot.log import get_logger
from bot.utils import scheduling
from bot.utils.scheduling import Scheduler
+from bot.utils.time import TimestampFormats, discord_timestamp
log = get_logger(__name__)
+MAXIMUM_WORK_LIMIT = 16
+
class ModPings(Cog):
"""Commands for a moderator to turn moderator pings on and off."""
@@ -23,13 +28,23 @@ class ModPings(Cog):
# The cache's values are the times when the role should be re-applied to them, stored in ISO format.
pings_off_mods = RedisCache()
+ # RedisCache[discord.Member.id, 'start timestamp|total worktime in seconds']
+ # The cache's keys are mod's ID
+ # The cache's values are their pings on schedule timestamp and the total seconds (work time) until pings off
+ modpings_schedule = RedisCache()
+
def __init__(self, bot: Bot):
self.bot = bot
- self._role_scheduler = Scheduler(self.__class__.__name__)
+ self._role_scheduler = Scheduler("ModPingsOnOff")
+ self._modpings_scheduler = Scheduler("ModPingsSchedule")
self.guild = None
self.moderators_role = None
+ self.modpings_schedule_task = scheduling.create_task(
+ self.reschedule_modpings_schedule(),
+ event_loop=self.bot.loop
+ )
self.reschedule_task = scheduling.create_task(
self.reschedule_roles(),
name="mod-pings-reschedule",
@@ -57,9 +72,56 @@ class ModPings(Cog):
if mod.id not in pings_off:
await self.reapply_role(mod)
else:
- expiry = isoparse(pings_off[mod.id]).replace(tzinfo=None)
+ expiry = isoparse(pings_off[mod.id])
self._role_scheduler.schedule_at(expiry, mod.id, self.reapply_role(mod))
+ async def reschedule_modpings_schedule(self) -> None:
+ """Reschedule moderators schedule ping."""
+ await self.bot.wait_until_guild_available()
+ schedule_cache = await self.modpings_schedule.to_dict()
+
+ log.info("Scheduling modpings schedule for applicable moderators found in cache.")
+ for mod_id, schedule in schedule_cache.items():
+ start_timestamp, work_time = schedule.split("|")
+ start = datetime.datetime.fromtimestamp(float(start_timestamp))
+
+ mod = await self.bot.fetch_user(mod_id)
+ self._modpings_scheduler.schedule_at(
+ start,
+ mod_id,
+ self.add_role_schedule(mod, work_time, start)
+ )
+
+ async def remove_role_schedule(self, mod: Member, work_time: int, schedule_start: datetime.datetime) -> None:
+ """Removes the moderator's role to the given moderator."""
+ log.trace(f"Removing moderator role from mod with ID {mod.id}")
+ await mod.remove_roles(self.moderators_role, reason="Moderator schedule time expired.")
+
+ # Remove the task before scheduling it again
+ self._modpings_scheduler.cancel(mod.id)
+
+ # Add the task again
+ log.trace(f"Adding mod pings schedule task again for mod with ID {mod.id}")
+ schedule_start += datetime.timedelta(days=1)
+ self._modpings_scheduler.schedule_at(
+ schedule_start,
+ mod.id,
+ self.add_role_schedule(mod, work_time, schedule_start)
+ )
+
+ async def add_role_schedule(self, mod: Member, work_time: int, schedule_start: datetime.datetime) -> None:
+ """Adds the moderator's role to the given moderator."""
+ # If the moderator has pings off, then skip adding role
+ if mod.id in await self.pings_off_mods.to_dict():
+ log.trace(f"Skipping adding moderator role to mod with ID {mod.id} - found in pings off cache.")
+ else:
+ log.trace(f"Applying moderator role to mod with ID {mod.id}")
+ await mod.add_roles(self.moderators_role, reason="Moderator scheduled time started!")
+
+ log.trace(f"Sleeping for {work_time} seconds, worktime for mod with ID {mod.id}")
+ await asyncio.sleep(work_time)
+ await self.remove_role_schedule(mod, work_time, schedule_start)
+
async def reapply_role(self, mod: Member) -> None:
"""Reapply the moderator's role to the given moderator."""
log.trace(f"Re-applying role to mod with ID {mod.id}.")
@@ -92,7 +154,7 @@ class ModPings(Cog):
The duration cannot be longer than 30 days.
"""
- delta = duration - datetime.datetime.utcnow()
+ delta = duration - arrow.utcnow()
if delta > datetime.timedelta(days=30):
await ctx.send(":x: Cannot remove the role for longer than 30 days.")
return
@@ -131,12 +193,66 @@ class ModPings(Cog):
await ctx.send(f"{Emojis.check_mark} Moderators role has been re-applied.")
+ @modpings_group.group(
+ name='schedule',
+ aliases=('s',),
+ invoke_without_command=True
+ )
+ @has_any_role(*MODERATION_ROLES)
+ async def schedule_modpings(self, ctx: Context, start: str, end: str) -> None:
+ """Schedule modpings role to be added at <start> and removed at <end> everyday at UTC time!"""
+ start, end = dateutil_parse(start), dateutil_parse(end)
+
+ if end < start:
+ end += datetime.timedelta(days=1)
+
+ if (end - start) > datetime.timedelta(hours=MAXIMUM_WORK_LIMIT):
+ await ctx.send(
+ f":x: {ctx.author.mention} You can't have the modpings role for"
+ f" more than {MAXIMUM_WORK_LIMIT} hours!"
+ )
+ return
+
+ if start < datetime.datetime.utcnow():
+ # The datetime has already gone for the day, so make it tomorrow
+ # otherwise the scheduler would schedule it immediately
+ start += datetime.timedelta(days=1)
+
+ work_time = (end - start).total_seconds()
+
+ await self.modpings_schedule.set(ctx.author.id, f"{start.timestamp()}|{work_time}")
+
+ if ctx.author.id in self._modpings_scheduler:
+ self._modpings_scheduler.cancel(ctx.author.id)
+
+ self._modpings_scheduler.schedule_at(
+ start,
+ ctx.author.id,
+ self.add_role_schedule(ctx.author, work_time, start)
+ )
+
+ await ctx.send(
+ f"{Emojis.ok_hand} {ctx.author.mention} Scheduled mod pings from "
+ f"{discord_timestamp(start, TimestampFormats.TIME)} to "
+ f"{discord_timestamp(end, TimestampFormats.TIME)}!"
+ )
+
+ @schedule_modpings.command(name='delete', aliases=('del', 'd'))
+ async def modpings_schedule_delete(self, ctx: Context) -> None:
+ """Delete your modpings schedule."""
+ self._modpings_scheduler.cancel(ctx.author.id)
+ await self.modpings_schedule.delete(ctx.author.id)
+ await ctx.send(f"{Emojis.ok_hand} {ctx.author.mention} Deleted your modpings schedule!")
+
def cog_unload(self) -> None:
"""Cancel role tasks when the cog unloads."""
log.trace("Cog unload: canceling role tasks.")
self.reschedule_task.cancel()
self._role_scheduler.cancel_all()
+ self.modpings_schedule_task.cancel()
+ self._modpings_scheduler.cancel_all()
+
def setup(bot: Bot) -> None:
"""Load the ModPings cog."""
diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py
index 133ebaba5..511520252 100644
--- a/bot/exts/moderation/silence.py
+++ b/bot/exts/moderation/silence.py
@@ -5,9 +5,10 @@ from datetime import datetime, timedelta, timezone
from typing import Optional, OrderedDict, Union
from async_rediscache import RedisCache
-from discord import Guild, PermissionOverwrite, TextChannel, VoiceChannel
+from discord import Guild, PermissionOverwrite, TextChannel, Thread, VoiceChannel
from discord.ext import commands, tasks
from discord.ext.commands import Context
+from discord.utils import MISSING
from bot import constants
from bot.bot import Bot
@@ -48,7 +49,16 @@ class SilenceNotifier(tasks.Loop):
"""Loop notifier for posting notices to `alert_channel` containing added channels."""
def __init__(self, alert_channel: TextChannel):
- super().__init__(self._notifier, seconds=1, minutes=0, hours=0, count=None, reconnect=True, loop=None)
+ super().__init__(
+ self._notifier,
+ seconds=1,
+ minutes=0,
+ hours=0,
+ count=None,
+ reconnect=True,
+ loop=None,
+ time=MISSING
+ )
self._silenced_channels = {}
self._alert_channel = alert_channel
@@ -173,6 +183,12 @@ class Silence(commands.Cog):
channel_info = f"#{channel} ({channel.id})"
log.debug(f"{ctx.author} is silencing channel {channel_info}.")
+ # Since threads don't have specific overrides, we cannot silence them individually.
+ # The parent channel has to be muted or the thread should be archived.
+ if isinstance(channel, Thread):
+ await ctx.send(":x: Threads cannot be silenced.")
+ return
+
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 self.send_message(MSG_SILENCE_FAIL, ctx.channel, channel, alert_target=False)
@@ -223,7 +239,13 @@ class Silence(commands.Cog):
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)
+ prev_overwrites = dict(
+ send_messages=overwrite.send_messages,
+ add_reactions=overwrite.add_reactions,
+ create_private_threads=overwrite.create_private_threads,
+ create_public_threads=overwrite.create_public_threads,
+ send_messages_in_threads=overwrite.send_messages_in_threads
+ )
else:
role = self._verified_voice_role
@@ -323,7 +345,15 @@ class Silence(commands.Cog):
# 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, speak=None, connect=None)
+ overwrite.update(
+ send_messages=None,
+ add_reactions=None,
+ create_private_threads=None,
+ create_public_threads=None,
+ send_messages_in_threads=None,
+ speak=None,
+ connect=None
+ )
else:
overwrite.update(**json.loads(prev_overwrites))
diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py
index ed5571d2a..37338d19c 100644
--- a/bot/exts/moderation/verification.py
+++ b/bot/exts/moderation/verification.py
@@ -5,9 +5,7 @@ from discord.ext.commands import Cog, Context, command, has_any_role
from bot import constants
from bot.bot import Bot
-from bot.decorators import in_whitelist
from bot.log import get_logger
-from bot.utils.checks import InWhitelistCheckFailure
log = get_logger(__name__)
@@ -29,11 +27,11 @@ You can find a copy of our rules for reference at <https://pythondiscord.com/pag
Additionally, if you'd like to receive notifications for the announcements \
we post in <#{constants.Channels.announcements}>
-from time to time, you can send `!subscribe` to <#{constants.Channels.bot_commands}> at any time \
+from time to time, you can send `{constants.Bot.prefix}subscribe` to <#{constants.Channels.bot_commands}> at any time \
to assign yourself the **Announcements** role. We'll mention this role every time we make an announcement.
-If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to \
-<#{constants.Channels.bot_commands}>.
+If you'd like to unsubscribe from the announcement notifications, simply send `{constants.Bot.prefix}subscribe` to \
+<#{constants.Channels.bot_commands}> and click the role again!.
To introduce you to our community, we've made the following video:
https://youtu.be/ZH26PuX3re0
@@ -61,11 +59,9 @@ async def safe_dm(coro: t.Coroutine) -> None:
class Verification(Cog):
"""
- User verification and role management.
+ User verification.
Statistics are collected in the 'verification.' namespace.
-
- Additionally, this cog offers the !subscribe and !unsubscribe commands,
"""
def __init__(self, bot: Bot) -> None:
@@ -108,67 +104,8 @@ class Verification(Cog):
log.exception("DM dispatch failed on unexpected error code")
# endregion
- # region: subscribe commands
-
- @command(name='subscribe')
- @in_whitelist(channels=(constants.Channels.bot_commands,))
- async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args
- """Subscribe to announcement notifications by assigning yourself the role."""
- has_role = False
-
- for role in ctx.author.roles:
- if role.id == constants.Roles.announcements:
- has_role = True
- break
-
- if has_role:
- await ctx.send(f"{ctx.author.mention} You're already subscribed!")
- return
-
- log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.")
- await ctx.author.add_roles(discord.Object(constants.Roles.announcements), reason="Subscribed to announcements")
-
- log.trace(f"Deleting the message posted by {ctx.author}.")
-
- await ctx.send(
- f"{ctx.author.mention} Subscribed to <#{constants.Channels.announcements}> notifications.",
- )
-
- @command(name='unsubscribe')
- @in_whitelist(channels=(constants.Channels.bot_commands,))
- async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args
- """Unsubscribe from announcement notifications by removing the role from yourself."""
- has_role = False
-
- for role in ctx.author.roles:
- if role.id == constants.Roles.announcements:
- has_role = True
- break
-
- if not has_role:
- await ctx.send(f"{ctx.author.mention} You're already unsubscribed!")
- return
-
- log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.")
- await ctx.author.remove_roles(
- discord.Object(constants.Roles.announcements), reason="Unsubscribed from announcements"
- )
-
- log.trace(f"Deleting the message posted by {ctx.author}.")
-
- await ctx.send(
- f"{ctx.author.mention} Unsubscribed from <#{constants.Channels.announcements}> notifications."
- )
-
- # endregion
# region: miscellaneous
- # This cannot be static (must have a __func__ attribute).
- async def cog_command_error(self, ctx: Context, error: Exception) -> None:
- """Check for & ignore any InWhitelistCheckFailure."""
- if isinstance(error, InWhitelistCheckFailure):
- error.handled = True
-
@command(name='verify')
@has_any_role(*constants.MODERATION_ROLES)
async def perform_manual_verification(self, ctx: Context, user: discord.Member) -> None:
diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py
index 88733176f..ae55a03a0 100644
--- a/bot/exts/moderation/voice_gate.py
+++ b/bot/exts/moderation/voice_gate.py
@@ -1,7 +1,8 @@
import asyncio
from contextlib import suppress
-from datetime import datetime, timedelta
+from datetime import timedelta
+import arrow
import discord
from async_rediscache import RedisCache
from discord import Colour, Member, VoiceState
@@ -165,11 +166,17 @@ class VoiceGate(Cog):
return
checks = {
- "joined_at": ctx.author.joined_at > datetime.utcnow() - timedelta(days=GateConf.minimum_days_member),
+ "joined_at": (
+ ctx.author.joined_at > arrow.utcnow() - timedelta(days=GateConf.minimum_days_member)
+ ),
"total_messages": data["total_messages"] < GateConf.minimum_messages,
"voice_banned": data["voice_banned"],
- "activity_blocks": data["activity_blocks"] < GateConf.minimum_activity_blocks
}
+ if activity_blocks := data.get("activity_blocks"):
+ # activity_blocks is not included in the response if the user has a lot of messages.
+ # Only check if the user has enough activity blocks if it is included.
+ checks["activity_blocks"] = activity_blocks < GateConf.minimum_activity_blocks
+
failed = any(checks.values())
failed_reasons = [MESSAGE_FIELD_MAP[key] for key, value in checks.items() if value is True]
[self.bot.stats.incr(f"voice_gate.failed.{key}") for key, value in checks.items() if value is True]
diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py
index 8a64e83ff..34d445912 100644
--- a/bot/exts/moderation/watchchannels/_watchchannel.py
+++ b/bot/exts/moderation/watchchannels/_watchchannel.py
@@ -250,7 +250,7 @@ class WatchChannel(metaclass=CogABCMeta):
await self.webhook_send(
cleaned_content,
username=msg.author.display_name,
- avatar_url=msg.author.avatar_url
+ avatar_url=msg.author.display_avatar.url
)
if msg.attachments:
@@ -264,7 +264,7 @@ class WatchChannel(metaclass=CogABCMeta):
await self.webhook_send(
embed=e,
username=msg.author.display_name,
- avatar_url=msg.author.avatar_url
+ avatar_url=msg.author.display_avatar.url
)
except discord.HTTPException as exc:
self.log.exception(
@@ -298,10 +298,9 @@ class WatchChannel(metaclass=CogABCMeta):
message_jump = f"in [#{msg.channel.name}]({msg.jump_url})"
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=256, placeholder="..."))
+ embed = Embed(description=f"{msg.author.mention} {message_jump}\n\n{footer}")
- await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url)
+ await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.display_avatar.url)
async def list_watched_users(
self, ctx: Context, oldest_first: bool = False, update_cache: bool = True
diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py
index 2fafaec97..8fa0be5b1 100644
--- a/bot/exts/recruitment/talentpool/_cog.py
+++ b/bot/exts/recruitment/talentpool/_cog.py
@@ -483,12 +483,9 @@ class TalentPool(Cog, name="Talentpool"):
@has_any_role(*MODERATION_ROLES)
async def get_review(self, ctx: Context, user_id: int) -> None:
"""Get the user's review as a markdown file."""
- review = (await self.reviewer.make_review(user_id))[0]
- if review:
- file = discord.File(StringIO(review), f"{user_id}_review.md")
- await ctx.send(file=file)
- else:
- await ctx.send(f"There doesn't appear to be an active nomination for {user_id}")
+ review, _, _ = await self.reviewer.make_review(user_id)
+ file = discord.File(StringIO(review), f"{user_id}_review.md")
+ await ctx.send(file=file)
@nomination_group.command(aliases=('review',))
@has_any_role(*MODERATION_ROLES)
@@ -501,7 +498,7 @@ class TalentPool(Cog, name="Talentpool"):
await ctx.message.add_reaction(Emojis.check_mark)
@Cog.listener()
- async def on_member_ban(self, guild: Guild, user: Union[MemberOrUser]) -> None:
+ async def on_member_ban(self, guild: Guild, user: MemberOrUser) -> None:
"""Remove `user` from the talent pool after they are banned."""
await self.end_nomination(user.id, "User was banned.")
@@ -516,6 +513,9 @@ class TalentPool(Cog, name="Talentpool"):
if payload.channel_id != Channels.nomination_voting:
return
+ if payload.user_id == self.bot.user.id:
+ return
+
message: PartialMessage = self.bot.get_channel(payload.channel_id).get_partial_message(payload.message_id)
emoji = str(payload.emoji)
diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py
index dcf73c2cb..0e7194892 100644
--- a/bot/exts/recruitment/talentpool/_review.py
+++ b/bot/exts/recruitment/talentpool/_review.py
@@ -8,13 +8,14 @@ from collections import Counter
from datetime import datetime, timedelta
from typing import List, Optional, Union
+import arrow
from dateutil.parser import isoparse
-from discord import Embed, Emoji, Member, Message, NoMoreItems, PartialMessage, TextChannel
+from discord import Embed, Emoji, Member, Message, NoMoreItems, NotFound, 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
+from bot.constants import Channels, Colours, Emojis, Guild, Roles
from bot.log import get_logger
from bot.utils.members import get_or_fetch_member
from bot.utils.messages import count_unique_users_reaction, pin_no_system_message
@@ -35,9 +36,8 @@ MAX_MESSAGE_SIZE = 2000
MAX_EMBED_SIZE = 4000
# 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:\*\*",
+ r"<@!?(\d+)> \(.+#\d{4}\) for Helper!\n\n",
re.MULTILINE
)
@@ -68,23 +68,23 @@ class Reviewer:
log.trace(f"Scheduling review of user with ID {user_id}")
user_data = self._pool.cache.get(user_id)
- inserted_at = isoparse(user_data['inserted_at']).replace(tzinfo=None)
+ inserted_at = isoparse(user_data['inserted_at'])
review_at = inserted_at + timedelta(days=MAX_DAYS_IN_POOL)
# If it's over a day overdue, it's probably an old nomination and shouldn't be automatically reviewed.
- if datetime.utcnow() - review_at < timedelta(days=1):
+ if arrow.utcnow() - review_at < timedelta(days=1):
self._review_scheduler.schedule_at(review_at, user_id, self.post_review(user_id, update_database=True))
async def post_review(self, user_id: int, update_database: bool) -> None:
"""Format the review of a user and post it to the nomination voting channel."""
- review, reviewed_emoji = await self.make_review(user_id)
- if not review:
+ review, reviewed_emoji, nominee = await self.make_review(user_id)
+ if not nominee:
return
guild = self.bot.get_guild(Guild.id)
channel = guild.get_channel(Channels.nomination_voting)
- log.trace(f"Posting the review of {user_id}")
+ log.trace(f"Posting the review of {nominee} ({nominee.id})")
messages = await self._bulk_send(channel, review)
await pin_no_system_message(messages[0])
@@ -94,12 +94,17 @@ class Reviewer:
for reaction in (reviewed_emoji, "\N{THUMBS UP SIGN}", "\N{THUMBS DOWN SIGN}"):
await last_message.add_reaction(reaction)
+ thread = await last_message.create_thread(
+ name=f"Nomination - {nominee}",
+ )
+ await thread.send(fr"<@&{Roles.mod_team}> <@&{Roles.admins}>")
+
if update_database:
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."""
+ async def make_review(self, user_id: int) -> typing.Tuple[str, Optional[Emoji], Optional[Member]]:
+ """Format a generic review of a user and return it with the reviewed emoji and the user themselves."""
log.trace(f"Formatting the review of {user_id}")
# Since `cache` is a defaultdict, we should take care
@@ -109,17 +114,17 @@ class Reviewer:
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
+ return f"There doesn't appear to be an active nomination for {user_id}", None, None
guild = self.bot.get_guild(Guild.id)
- member = await get_or_fetch_member(guild, user_id)
+ nominee = await get_or_fetch_member(guild, user_id)
- if not member:
+ if not nominee:
return (
f"I tried to review the user with ID `{user_id}`, but they don't appear to be on the server :pensive:"
- ), None
+ ), None, None
- opening = f"{member.mention} ({member}) for Helper!"
+ opening = f"{nominee.mention} ({nominee}) for Helper!"
current_nominations = "\n\n".join(
f"**<@{entry['actor']}>:** {entry['reason'] or '*no reason given*'}"
@@ -127,7 +132,7 @@ class Reviewer:
)
current_nominations = f"**Nominated by:**\n{current_nominations}"
- review_body = await self._construct_review_body(member)
+ review_body = await self._construct_review_body(nominee)
reviewed_emoji = self._random_ducky(guild)
vote_request = (
@@ -137,7 +142,7 @@ class Reviewer:
)
review = "\n\n".join((opening, current_nominations, review_body, vote_request))
- return review, reviewed_emoji
+ return review, reviewed_emoji, nominee
async def archive_vote(self, message: PartialMessage, passed: bool) -> None:
"""Archive this vote to #nomination-archive."""
@@ -209,8 +214,21 @@ class Reviewer:
colour=colour
))
+ # Thread channel IDs are the same as the message ID of the parent message.
+ nomination_thread = message.guild.get_thread(message.id)
+ if not nomination_thread:
+ try:
+ nomination_thread = await message.guild.fetch_channel(message.id)
+ except NotFound:
+ log.warning(f"Could not find a thread linked to {message.channel.id}-{message.id}")
+ return
+
for message_ in messages:
- await message_.delete()
+ with contextlib.suppress(NotFound):
+ await message_.delete()
+
+ with contextlib.suppress(NotFound):
+ await nomination_thread.edit(archived=True)
async def _construct_review_body(self, member: Member) -> str:
"""Formats the body of the nomination, with details of activity, infractions, and previous nominations."""
@@ -347,7 +365,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))
+ end_time = time_since(isoparse(history[0]['ended_at']))
review = (
f"They were nominated **{nomination_times}** before"
@@ -359,10 +377,10 @@ class Reviewer:
@staticmethod
def _random_ducky(guild: Guild) -> Union[Emoji, str]:
- """Picks a random ducky emoji. If no duckies found returns :eyes:."""
+ """Picks a random ducky emoji. If no duckies found returns 👀."""
duckies = [emoji for emoji in guild.emojis if emoji.name.startswith("ducky")]
if not duckies:
- return ":eyes:"
+ return "\N{EYES}"
return random.choice(duckies)
@staticmethod
diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py
index 8f0094bc9..788692777 100644
--- a/bot/exts/utils/bot.py
+++ b/bot/exts/utils/bot.py
@@ -1,6 +1,7 @@
+from contextlib import suppress
from typing import Optional
-from discord import Embed, TextChannel
+from discord import Embed, Forbidden, TextChannel, Thread
from discord.ext.commands import Cog, Context, command, group, has_any_role
from bot.bot import Bot
@@ -16,6 +17,20 @@ class BotCog(Cog, name="Bot"):
def __init__(self, bot: Bot):
self.bot = bot
+ @Cog.listener()
+ async def on_thread_join(self, thread: Thread) -> None:
+ """
+ Try to join newly created threads.
+
+ Despite the event name being misleading, this is dispatched when new threads are created.
+ """
+ if thread.me:
+ # We have already joined this thread
+ return
+
+ with suppress(Forbidden):
+ await thread.join()
+
@group(invoke_without_command=True, name="bot", hidden=True)
async def botinfo_group(self, ctx: Context) -> None:
"""Bot informational commands."""
diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py
deleted file mode 100644
index fa9b7e219..000000000
--- a/bot/exts/utils/clean.py
+++ /dev/null
@@ -1,274 +0,0 @@
-import random
-import re
-from typing import Iterable, Optional
-
-from discord import Colour, Embed, Message, TextChannel, User, errors
-from discord.ext import commands
-from discord.ext.commands import Cog, Context, group, has_any_role
-
-from bot.bot import Bot
-from bot.constants import Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES, NEGATIVE_REPLIES
-from bot.exts.moderation.modlog import ModLog
-from bot.log import get_logger
-
-log = get_logger(__name__)
-
-
-class Clean(Cog):
- """
- A cog that allows messages to be deleted in bulk, while applying various filters.
-
- You can delete messages sent by a specific user, messages sent by bots, all messages, or messages that match a
- specific regular expression.
-
- The deleted messages are saved and uploaded to the database via an API endpoint, and a URL is returned which can be
- used to view the messages in the Discord dark theme style.
- """
-
- def __init__(self, bot: Bot):
- self.bot = bot
- self.cleaning = False
-
- @property
- def mod_log(self) -> ModLog:
- """Get currently loaded ModLog cog instance."""
- return self.bot.get_cog("ModLog")
-
- async def _clean_messages(
- self,
- amount: int,
- ctx: Context,
- channels: Iterable[TextChannel],
- bots_only: bool = False,
- user: User = None,
- regex: Optional[str] = None,
- until_message: Optional[Message] = None,
- ) -> None:
- """A helper function that does the actual message cleaning."""
- def predicate_bots_only(message: Message) -> bool:
- """Return True if the message was sent by a bot."""
- return message.author.bot
-
- def predicate_specific_user(message: Message) -> bool:
- """Return True if the message was sent by the user provided in the _clean_messages call."""
- return message.author == user
-
- def predicate_regex(message: Message) -> bool:
- """Check if the regex provided in _clean_messages matches the message content or any embed attributes."""
- content = [message.content]
-
- # Add the content for all embed attributes
- for embed in message.embeds:
- content.append(embed.title)
- content.append(embed.description)
- content.append(embed.footer.text)
- content.append(embed.author.name)
- for field in embed.fields:
- content.append(field.name)
- content.append(field.value)
-
- # Get rid of empty attributes and turn it into a string
- content = [attr for attr in content if attr]
- content = "\n".join(content)
-
- # Now let's see if there's a regex match
- if not content:
- return False
- else:
- return bool(re.search(regex.lower(), content.lower()))
-
- # Is this an acceptable amount of messages to clean?
- if amount > CleanMessages.message_limit:
- embed = Embed(
- color=Colour(Colours.soft_red),
- title=random.choice(NEGATIVE_REPLIES),
- description=f"You cannot clean more than {CleanMessages.message_limit} messages."
- )
- await ctx.send(embed=embed)
- return
-
- # Are we already performing a clean?
- if self.cleaning:
- embed = Embed(
- color=Colour(Colours.soft_red),
- title=random.choice(NEGATIVE_REPLIES),
- description="Please wait for the currently ongoing clean operation to complete."
- )
- await ctx.send(embed=embed)
- return
-
- # Set up the correct predicate
- if bots_only:
- predicate = predicate_bots_only # Delete messages from bots
- elif user:
- predicate = predicate_specific_user # Delete messages from specific user
- elif regex:
- predicate = predicate_regex # Delete messages that match regex
- else:
- predicate = None # Delete all messages
-
- # Default to using the invoking context's channel
- if not channels:
- channels = [ctx.channel]
-
- # Delete the invocation first
- self.mod_log.ignore(Event.message_delete, ctx.message.id)
- try:
- await ctx.message.delete()
- except errors.NotFound:
- # Invocation message has already been deleted
- log.info("Tried to delete invocation message, but it was already deleted.")
-
- messages = []
- message_ids = []
- self.cleaning = True
-
- # Find the IDs of the messages to delete. IDs are needed in order to ignore mod log events.
- for channel in channels:
- async for message in channel.history(limit=amount):
-
- # If at any point the cancel command is invoked, we should stop.
- if not self.cleaning:
- return
-
- # If we are looking for specific message.
- if until_message:
-
- # we could use ID's here however in case if the message we are looking for gets deleted,
- # we won't have a way to figure that out thus checking for datetime should be more reliable
- if message.created_at < until_message.created_at:
- # means we have found the message until which we were supposed to be deleting.
- break
-
- # Since we will be using `delete_messages` method of a TextChannel and we need message objects to
- # use it as well as to send logs we will start appending messages here instead adding them from
- # purge.
- messages.append(message)
-
- # If the message passes predicate, let's save it.
- if predicate is None or predicate(message):
- message_ids.append(message.id)
-
- self.cleaning = False
-
- # Now let's delete the actual messages with purge.
- self.mod_log.ignore(Event.message_delete, *message_ids)
- for channel in channels:
- if until_message:
- for i in range(0, len(messages), 100):
- # while purge automatically handles the amount of messages
- # delete_messages only allows for up to 100 messages at once
- # thus we need to paginate the amount to always be <= 100
- await channel.delete_messages(messages[i:i + 100])
- else:
- messages += await channel.purge(limit=amount, check=predicate)
-
- # Reverse the list to restore chronological order
- if messages:
- messages = reversed(messages)
- log_url = await self.mod_log.upload_log(messages, ctx.author.id)
- else:
- # Can't build an embed, nothing to clean!
- embed = Embed(
- color=Colour(Colours.soft_red),
- description="No matching messages could be found."
- )
- await ctx.send(embed=embed, delete_after=10)
- return
-
- # Build the embed and send it
- target_channels = ", ".join(channel.mention for channel in channels)
-
- message = (
- f"**{len(message_ids)}** messages deleted in {target_channels} by "
- f"{ctx.author.mention}\n\n"
- f"A log of the deleted messages can be found [here]({log_url})."
- )
-
- await self.mod_log.send_log_message(
- icon_url=Icons.message_bulk_delete,
- colour=Colour(Colours.soft_red),
- title="Bulk message delete",
- text=message,
- channel_id=Channels.mod_log,
- )
-
- @group(invoke_without_command=True, name="clean", aliases=["clear", "purge"])
- @has_any_role(*MODERATION_ROLES)
- async def clean_group(self, ctx: Context) -> None:
- """Commands for cleaning messages in channels."""
- await ctx.send_help(ctx.command)
-
- @clean_group.command(name="user", aliases=["users"])
- @has_any_role(*MODERATION_ROLES)
- async def clean_user(
- self,
- ctx: Context,
- user: User,
- amount: Optional[int] = 10,
- channels: commands.Greedy[TextChannel] = None
- ) -> None:
- """Delete messages posted by the provided user, stop cleaning after traversing `amount` messages."""
- await self._clean_messages(amount, ctx, user=user, channels=channels)
-
- @clean_group.command(name="all", aliases=["everything"])
- @has_any_role(*MODERATION_ROLES)
- async def clean_all(
- self,
- ctx: Context,
- amount: Optional[int] = 10,
- channels: commands.Greedy[TextChannel] = None
- ) -> None:
- """Delete all messages, regardless of poster, stop cleaning after traversing `amount` messages."""
- await self._clean_messages(amount, ctx, channels=channels)
-
- @clean_group.command(name="bots", aliases=["bot"])
- @has_any_role(*MODERATION_ROLES)
- async def clean_bots(
- self,
- ctx: Context,
- amount: Optional[int] = 10,
- channels: commands.Greedy[TextChannel] = None
- ) -> None:
- """Delete all messages posted by a bot, stop cleaning after traversing `amount` messages."""
- await self._clean_messages(amount, ctx, bots_only=True, channels=channels)
-
- @clean_group.command(name="regex", aliases=["word", "expression"])
- @has_any_role(*MODERATION_ROLES)
- async def clean_regex(
- self,
- ctx: Context,
- regex: str,
- amount: Optional[int] = 10,
- channels: commands.Greedy[TextChannel] = None
- ) -> None:
- """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages."""
- await self._clean_messages(amount, ctx, regex=regex, channels=channels)
-
- @clean_group.command(name="message", aliases=["messages"])
- @has_any_role(*MODERATION_ROLES)
- async def clean_message(self, ctx: Context, message: Message) -> None:
- """Delete all messages until certain message, stop cleaning after hitting the `message`."""
- await self._clean_messages(
- CleanMessages.message_limit,
- ctx,
- channels=[message.channel],
- until_message=message
- )
-
- @clean_group.command(name="stop", aliases=["cancel", "abort"])
- @has_any_role(*MODERATION_ROLES)
- async def clean_cancel(self, ctx: Context) -> None:
- """If there is an ongoing cleaning process, attempt to immediately cancel it."""
- self.cleaning = False
-
- embed = Embed(
- color=Colour.blurple(),
- description="Clean interrupted."
- )
- await ctx.send(embed=embed, delete_after=10)
-
-
-def setup(bot: Bot) -> None:
- """Load the Clean cog."""
- bot.add_cog(Clean(bot))
diff --git a/bot/exts/utils/extensions.py b/bot/exts/utils/extensions.py
index fa5d38917..fda1e49e2 100644
--- a/bot/exts/utils/extensions.py
+++ b/bot/exts/utils/extensions.py
@@ -113,7 +113,7 @@ class Extensions(commands.Cog):
Grey indicates that the extension is unloaded.
Green indicates that the extension is currently loaded.
"""
- embed = Embed(colour=Colour.blurple())
+ embed = Embed(colour=Colour.og_blurple())
embed.set_author(
name="Extensions List",
url=URLs.github_bot_repo,
diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py
index 879735945..e7113c09c 100644
--- a/bot/exts/utils/internal.py
+++ b/bot/exts/utils/internal.py
@@ -5,10 +5,10 @@ import re
import textwrap
import traceback
from collections import Counter
-from datetime import datetime
from io import StringIO
from typing import Any, Optional, Tuple
+import arrow
import discord
from discord.ext.commands import Cog, Context, group, has_any_role, is_owner
@@ -29,7 +29,7 @@ class Internal(Cog):
self.ln = 0
self.stdout = StringIO()
- self.socket_since = datetime.utcnow()
+ self.socket_since = arrow.utcnow()
self.socket_event_total = 0
self.socket_events = Counter()
@@ -37,11 +37,10 @@ class Internal(Cog):
self.eval.add_check(is_owner().predicate)
@Cog.listener()
- async def on_socket_response(self, msg: dict) -> None:
+ async def on_socket_event_type(self, event_type: str) -> None:
"""When a websocket event is received, increase our counters."""
- if event_type := msg.get("t"):
- self.socket_event_total += 1
- self.socket_events[event_type] += 1
+ self.socket_event_total += 1
+ self.socket_events[event_type] += 1
def _format(self, inp: str, out: Any) -> Tuple[str, Optional[discord.Embed]]:
"""Format the eval output into a string & attempt to format it into an Embed."""
@@ -237,14 +236,14 @@ async def func(): # (None,) -> Any
@has_any_role(Roles.admins, Roles.owners, Roles.core_developers)
async def socketstats(self, ctx: Context) -> None:
"""Fetch information on the socket events received from Discord."""
- running_s = (datetime.utcnow() - self.socket_since).total_seconds()
+ running_s = (arrow.utcnow() - self.socket_since).total_seconds()
per_s = self.socket_event_total / running_s
stats_embed = discord.Embed(
title="WebSocket statistics",
description=f"Receiving {per_s:0.2f} events per second.",
- color=discord.Color.blurple()
+ color=discord.Color.og_blurple()
)
for event_type, count in self.socket_events.most_common(25):
diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py
index cf0e3265e..9fb5b7b8f 100644
--- a/bot/exts/utils/ping.py
+++ b/bot/exts/utils/ping.py
@@ -1,5 +1,4 @@
-from datetime import datetime
-
+import arrow
from aiohttp import client_exceptions
from discord import Embed
from discord.ext import commands
@@ -32,7 +31,7 @@ class Latency(commands.Cog):
"""
# datetime.datetime objects do not have the "milliseconds" attribute.
# It must be converted to seconds before converting to milliseconds.
- bot_ping = (datetime.utcnow() - ctx.message.created_at).total_seconds() * 1000
+ bot_ping = (arrow.utcnow() - ctx.message.created_at).total_seconds() * 1000
if bot_ping <= 0:
bot_ping = "Your clock is out of sync, could not calculate ping."
else:
diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py
index 3cb9307a9..90677b2dd 100644
--- a/bot/exts/utils/reminders.py
+++ b/bot/exts/utils/reminders.py
@@ -1,7 +1,7 @@
import random
import textwrap
import typing as t
-from datetime import datetime
+from datetime import datetime, timezone
from operator import itemgetter
import discord
@@ -52,14 +52,14 @@ class Reminders(Cog):
params={'active': 'true'}
)
- now = datetime.utcnow()
+ now = datetime.now(timezone.utc)
for reminder in response:
is_valid, *_ = self.ensure_valid_reminder(reminder)
if not is_valid:
continue
- remind_at = isoparse(reminder['expiration']).replace(tzinfo=None)
+ remind_at = isoparse(reminder['expiration'])
# If the reminder is already overdue ...
if remind_at < now:
@@ -144,7 +144,7 @@ class Reminders(Cog):
def schedule_reminder(self, reminder: dict) -> None:
"""A coroutine which sends the reminder once the time is reached, and cancels the running task."""
- reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None)
+ reminder_datetime = isoparse(reminder['expiration'])
self.scheduler.schedule_at(reminder_datetime, reminder["id"], self.send_reminder(reminder))
async def _edit_reminder(self, reminder_id: int, payload: dict) -> dict:
@@ -183,7 +183,7 @@ class Reminders(Cog):
name="Sorry, your reminder should have arrived earlier!"
)
else:
- embed.colour = discord.Colour.blurple()
+ embed.colour = discord.Colour.og_blurple()
embed.set_author(
icon_url=Icons.remind_blurple,
name="It has arrived!"
@@ -214,7 +214,7 @@ class Reminders(Cog):
@group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True)
async def remind_group(
- self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: str
+ self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: t.Optional[str] = None
) -> None:
"""
Commands for managing your reminders.
@@ -234,7 +234,7 @@ class Reminders(Cog):
@remind_group.command(name="new", aliases=("add", "create"))
async def new_reminder(
- self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: str
+ self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: t.Optional[str] = None
) -> None:
"""
Set yourself a simple reminder.
@@ -283,6 +283,20 @@ class Reminders(Cog):
mention_ids = [mention.id for mention in mentions]
+ # If `content` isn't provided then we try to get message content of a replied message
+ if not content:
+ if reference := ctx.message.reference:
+ if isinstance((resolved_message := reference.resolved), discord.Message):
+ content = resolved_message.content
+ # If we weren't able to get the content of a replied message
+ if content is None:
+ await send_denial(ctx, "Your reminder must have a content and/or reply to a message.")
+ return
+
+ # If the replied message has no content (e.g. only attachments/embeds)
+ if content == "":
+ content = "See referenced message."
+
# Now we can attempt to actually set the reminder.
reminder = await self.bot.api_client.post(
'bot/reminders',
@@ -333,7 +347,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)
+ remind_datetime = isoparse(remind_at)
time = discord_timestamp(remind_datetime, TimestampFormats.RELATIVE)
mentions = ", ".join([
@@ -350,7 +364,7 @@ class Reminders(Cog):
lines.append(text)
embed = discord.Embed()
- embed.colour = discord.Colour.blurple()
+ embed.colour = discord.Colour.og_blurple()
embed.title = f"Reminders for {ctx.author}"
# Remind the user that they have no reminders :^)
@@ -360,7 +374,7 @@ class Reminders(Cog):
return
# Construct the embed and paginate it.
- embed.colour = discord.Colour.blurple()
+ embed.colour = discord.Colour.og_blurple()
await LinePaginator.paginate(
lines,
diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py
index f69bab781..821cebd8c 100644
--- a/bot/exts/utils/utils.py
+++ b/bot/exts/utils/utils.py
@@ -96,7 +96,7 @@ class Utils(Cog):
If a string is provided, the line which matches best will be produced.
"""
embed = Embed(
- colour=Colour.blurple(),
+ colour=Colour.og_blurple(),
title="The Zen of Python",
description=ZEN_OF_PYTHON
)
diff --git a/bot/log.py b/bot/log.py
index b3cecdcf2..100cd06f6 100644
--- a/bot/log.py
+++ b/bot/log.py
@@ -48,16 +48,17 @@ def setup() -> None:
logging.addLevelName(TRACE_LEVEL, "TRACE")
logging.setLoggerClass(CustomLogger)
+ root_log = get_logger()
+
format_string = "%(asctime)s | %(name)s | %(levelname)s | %(message)s"
log_format = logging.Formatter(format_string)
- log_file = Path("logs", "bot.log")
- log_file.parent.mkdir(exist_ok=True)
- file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7, encoding="utf8")
- file_handler.setFormatter(log_format)
-
- root_log = get_logger()
- root_log.addHandler(file_handler)
+ if constants.FILE_LOGS:
+ log_file = Path("logs", "bot.log")
+ log_file.parent.mkdir(exist_ok=True)
+ file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7, encoding="utf8")
+ file_handler.setFormatter(log_format)
+ root_log.addHandler(file_handler)
if "COLOREDLOGS_LEVEL_STYLES" not in os.environ:
coloredlogs.DEFAULT_LEVEL_STYLES = {
diff --git a/bot/monkey_patches.py b/bot/monkey_patches.py
index e56a19da2..b5c0de8d9 100644
--- a/bot/monkey_patches.py
+++ b/bot/monkey_patches.py
@@ -1,9 +1,11 @@
-from datetime import datetime, timedelta
+from datetime import timedelta
+import arrow
from discord import Forbidden, http
from discord.ext import commands
from bot.log import get_logger
+from bot.utils.regex import MESSAGE_ID_RE
log = get_logger(__name__)
@@ -38,14 +40,36 @@ def patch_typing() -> None:
async def honeybadger_type(self, channel_id: int) -> None: # noqa: ANN001
nonlocal last_403
- if last_403 and (datetime.utcnow() - last_403) < timedelta(minutes=5):
+ if last_403 and (arrow.utcnow() - last_403) < timedelta(minutes=5):
log.warning("Not sending typing event, we got a 403 less than 5 minutes ago.")
return
try:
await original(self, channel_id)
except Forbidden:
- last_403 = datetime.utcnow()
+ last_403 = arrow.utcnow()
log.warning("Got a 403 from typing event!")
pass
http.HTTPClient.send_typing = honeybadger_type
+
+
+class FixedPartialMessageConverter(commands.PartialMessageConverter):
+ """
+ Make the Message converter infer channelID from the given context if only a messageID is given.
+
+ Discord.py's Message converter is supposed to infer channelID based
+ on ctx.channel if only a messageID is given. A refactor commit, linked below,
+ a few weeks before d.py's archival broke this defined behaviour of the converter.
+ Currently, if only a messageID is given to the converter, it will only find that message
+ if it's in the bot's cache.
+
+ https://github.com/Rapptz/discord.py/commit/1a4e73d59932cdbe7bf2c281f25e32529fc7ae1f
+ """
+
+ @staticmethod
+ def _get_id_matches(ctx: commands.Context, argument: str) -> tuple[int, int, int]:
+ """Inserts ctx.channel.id before calling super method if argument is just a messageID."""
+ match = MESSAGE_ID_RE.match(argument)
+ if match:
+ argument = f"{ctx.channel.id}-{match.group('message_id')}"
+ return commands.PartialMessageConverter._get_id_matches(ctx, argument)
diff --git a/bot/resources/tags/off-topic.md b/bot/resources/tags/off-topic.md
index 6a864a1d5..287224d7f 100644
--- a/bot/resources/tags/off-topic.md
+++ b/bot/resources/tags/off-topic.md
@@ -1,9 +1,9 @@
**Off-topic channels**
There are three off-topic channels:
-• <#291284109232308226>
-• <#463035241142026251>
• <#463035268514185226>
+• <#463035241142026251>
+• <#291284109232308226>
Their names change randomly every 24 hours, but you can always find them under the `OFF-TOPIC/GENERAL` category in the channel list.
diff --git a/bot/resources/tags/sql-fstring.md b/bot/resources/tags/sql-fstring.md
new file mode 100644
index 000000000..94dd870fd
--- /dev/null
+++ b/bot/resources/tags/sql-fstring.md
@@ -0,0 +1,16 @@
+**SQL & f-strings**
+Don't use f-strings (`f""`) or other forms of "string interpolation" (`%`, `+`, `.format`) to inject data into a SQL query. It is an endless source of bugs and syntax errors. Additionally, in user-facing applications, it presents a major security risk via SQL injection.
+
+Your database library should support "query parameters". A query parameter is a placeholder that you put in the SQL query. When the query is executed, you provide data to the database library, and the library inserts the data into the query for you, **safely**.
+
+For example, the sqlite3 package supports using `?` as a placeholder:
+```py
+query = "SELECT * FROM stocks WHERE symbol = ?;"
+params = ("RHAT",)
+db.execute(query, params)
+```
+Note: Different database libraries support different placeholder styles, e.g. `%s` and `$1`. Consult your library's documentation for details.
+
+**See Also**
+• [Extended Example with SQLite](https://docs.python.org/3/library/sqlite3.html) (search for "Instead, use the DB-API's parameter substitution")
+• [PEP-249](https://www.python.org/dev/peps/pep-0249) - A specification of how database libraries in Python should work
diff --git a/bot/utils/channel.py b/bot/utils/channel.py
index b9e234857..954a10e56 100644
--- a/bot/utils/channel.py
+++ b/bot/utils/channel.py
@@ -1,3 +1,5 @@
+from typing import Union
+
import discord
import bot
@@ -16,8 +18,11 @@ def is_help_channel(channel: discord.TextChannel) -> bool:
return any(is_in_category(channel, category) for category in categories)
-def is_mod_channel(channel: discord.TextChannel) -> bool:
- """True if `channel` is considered a mod channel."""
+def is_mod_channel(channel: Union[discord.TextChannel, discord.Thread]) -> bool:
+ """True if channel, or channel.parent for threads, is considered a mod channel."""
+ if isinstance(channel, discord.Thread):
+ channel = channel.parent
+
if channel.id in constants.MODERATION_CHANNELS:
log.trace(f"Channel #{channel} is a configured mod channel")
return True
diff --git a/bot/utils/checks.py b/bot/utils/checks.py
index 972a5ef38..188285684 100644
--- a/bot/utils/checks.py
+++ b/bot/utils/checks.py
@@ -1,4 +1,3 @@
-import datetime
from typing import Callable, Container, Iterable, Optional, Union
from discord.ext.commands import (
@@ -126,7 +125,7 @@ def cooldown_with_role_bypass(rate: int, per: float, type: BucketType = BucketTy
bypass = set(bypass_roles)
# this handles the actual cooldown logic
- buckets = CooldownMapping(Cooldown(rate, per, type))
+ buckets = CooldownMapping(Cooldown(rate, per), type)
# will be called after the command has been parse but before it has been invoked, ensures that
# the cooldown won't be updated if the user screws up their input to the command
@@ -137,11 +136,11 @@ def cooldown_with_role_bypass(rate: int, per: float, type: BucketType = BucketTy
return
# cooldown logic, taken from discord.py internals
- current = ctx.message.created_at.replace(tzinfo=datetime.timezone.utc).timestamp()
+ current = ctx.message.created_at.timestamp()
bucket = buckets.get_bucket(ctx.message)
retry_after = bucket.update_rate_limit(current)
if retry_after:
- raise CommandOnCooldown(bucket, retry_after)
+ raise CommandOnCooldown(bucket, retry_after, type)
def wrapper(command: Command) -> Command:
# NOTE: this could be changed if a subclass of Command were to be used. I didn't see the need for it
diff --git a/bot/utils/members.py b/bot/utils/members.py
index 77ddf1696..693286045 100644
--- a/bot/utils/members.py
+++ b/bot/utils/members.py
@@ -23,3 +23,26 @@ async def get_or_fetch_member(guild: discord.Guild, member_id: int) -> t.Optiona
return None
log.trace("%s fetched from API.", member)
return member
+
+
+async def handle_role_change(
+ member: discord.Member,
+ coro: t.Callable[..., t.Coroutine],
+ role: discord.Role
+) -> None:
+ """
+ Change `member`'s cooldown role via awaiting `coro` and handle errors.
+
+ `coro` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`.
+ """
+ try:
+ await coro(role)
+ except discord.NotFound:
+ log.debug(f"Failed to change role for {member} ({member.id}): member not found")
+ except discord.Forbidden:
+ log.debug(
+ f"Forbidden to change role for {member} ({member.id}); "
+ f"possibly due to role hierarchy"
+ )
+ except discord.HTTPException as e:
+ log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}")
diff --git a/bot/utils/messages.py b/bot/utils/messages.py
index 053750cc3..e55c07062 100644
--- a/bot/utils/messages.py
+++ b/bot/utils/messages.py
@@ -121,7 +121,7 @@ async def send_attachments(
"""
webhook_send_kwargs = {
'username': message.author.display_name,
- 'avatar_url': message.author.avatar_url,
+ 'avatar_url': message.author.display_avatar.url,
}
webhook_send_kwargs.update(kwargs)
webhook_send_kwargs['username'] = sub_clyde(webhook_send_kwargs['username'])
diff --git a/bot/utils/regex.py b/bot/utils/regex.py
index 7bad1e627..9dc1eba9d 100644
--- a/bot/utils/regex.py
+++ b/bot/utils/regex.py
@@ -1,14 +1,15 @@
import re
INVITE_RE = re.compile(
- r"(?:discord(?:[\.,]|dot)gg|" # Could be discord.gg/
- r"discord(?:[\.,]|dot)com(?:\/|slash)invite|" # or discord.com/invite/
- r"discordapp(?:[\.,]|dot)com(?:\/|slash)invite|" # or discordapp.com/invite/
- r"discord(?:[\.,]|dot)me|" # or discord.me
- r"discord(?:[\.,]|dot)li|" # or discord.li
- r"discord(?:[\.,]|dot)io|" # or discord.io.
- r"(?:[\.,]|dot)gg" # or .gg/
- r")(?:[\/]|slash)" # / or 'slash'
- r"([a-zA-Z0-9\-]+)", # the invite code itself
+ r"(discord([\.,]|dot)gg|" # Could be discord.gg/
+ r"discord([\.,]|dot)com(\/|slash)invite|" # or discord.com/invite/
+ r"discordapp([\.,]|dot)com(\/|slash)invite|" # or discordapp.com/invite/
+ r"discord([\.,]|dot)me|" # or discord.me
+ r"discord([\.,]|dot)li|" # or discord.li
+ r"discord([\.,]|dot)io|" # or discord.io.
+ r"((?<!\w)([\.,]|dot))gg" # or .gg/
+ r")([\/]|slash)" # / or 'slash'
+ r"(?P<invite>[a-zA-Z0-9\-]+)", # the invite code itself
flags=re.IGNORECASE
)
+MESSAGE_ID_RE = re.compile(r'(?P<message_id>[0-9]{15,20})$')
diff --git a/bot/utils/time.py b/bot/utils/time.py
index 8cf7d623b..eaa9b72e9 100644
--- a/bot/utils/time.py
+++ b/bot/utils/time.py
@@ -3,6 +3,7 @@ import re
from enum import Enum
from typing import Optional, Union
+import arrow
import dateutil.parser
from dateutil.relativedelta import relativedelta
@@ -67,9 +68,9 @@ def discord_timestamp(timestamp: ValidTimestamp, format: TimestampFormats = Time
# Convert each possible timestamp class to an integer.
if isinstance(timestamp, datetime.datetime):
- timestamp = (timestamp.replace(tzinfo=None) - datetime.datetime.utcfromtimestamp(0)).total_seconds()
+ timestamp = (timestamp - arrow.get(0)).total_seconds()
elif isinstance(timestamp, datetime.date):
- timestamp = (timestamp - datetime.date.fromtimestamp(0)).total_seconds()
+ timestamp = (timestamp - arrow.get(0)).total_seconds()
elif isinstance(timestamp, datetime.timedelta):
timestamp = timestamp.total_seconds()
elif isinstance(timestamp, relativedelta):
@@ -124,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)
+ date_time = dateutil.parser.isoparse(time_string)
time_delta = time_since(date_time)
return time_delta
@@ -157,7 +158,7 @@ def parse_duration_string(duration: str) -> Optional[relativedelta]:
def relativedelta_to_timedelta(delta: relativedelta) -> datetime.timedelta:
"""Converts a relativedelta object to a timedelta object."""
- utcnow = datetime.datetime.utcnow()
+ utcnow = arrow.utcnow()
return utcnow + delta - utcnow
@@ -196,8 +197,8 @@ def format_infraction_with_duration(
date_to_formatted = format_infraction(date_to)
- date_from = date_from or datetime.datetime.utcnow()
- date_to = dateutil.parser.isoparse(date_to).replace(tzinfo=None, microsecond=0)
+ date_from = date_from or datetime.datetime.now(datetime.timezone.utc)
+ date_to = dateutil.parser.isoparse(date_to).replace(microsecond=0)
delta = relativedelta(date_to, date_from)
if absolute:
@@ -215,15 +216,15 @@ def until_expiration(
"""
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.
+ Returns a human-readable version of the remaining duration between arrow.utcnow() and an expiry.
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 = datetime.datetime.utcnow()
- since = dateutil.parser.isoparse(expiry).replace(tzinfo=None, microsecond=0)
+ now = arrow.utcnow()
+ since = dateutil.parser.isoparse(expiry).replace(microsecond=0)
if since < now:
return None
diff --git a/config-default.yml b/config-default.yml
index d77eacc7e..1e04f5844 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -1,4 +1,5 @@
-debug: !ENV ["BOT_DEBUG", "true"]
+debug: !ENV ["BOT_DEBUG", "true"]
+file_logs: !ENV ["FILE_LOGS", "false"]
bot:
@@ -207,6 +208,7 @@ guild:
incidents_archive: 720668923636351037
mod_alerts: 473092532147060736
mods: &MODS 305126844661760000
+ mod_meta: 775412552795947058
nominations: 822920136150745168
nomination_voting: 822853512709931008
organisation: &ORGANISATION 551789653284356126
@@ -248,15 +250,13 @@ guild:
- *ADMIN_SPAM
- *MODS
- # Modlog cog ignores events which occur in these channels
+ # Modlog cog explicitly ignores events which occur in these channels.
+ # This is on top of implicitly ignoring events in channels that the mod team cannot view.
modlog_blacklist:
- - *ADMINS
- - *ADMINS_VOICE
- *ATTACH_LOG
- *MESSAGE_LOG
- *MOD_LOG
- *STAFF_VOICE
- - *DEV_CORE_VOTING
reminder_whitelist:
- *BOT_CMD
@@ -264,7 +264,12 @@ guild:
- *BLACK_FORMATTER
roles:
+ # Self-assignable roles, see the Subscribe cog
+ advent_of_code: 518565788744024082
announcements: 463658397560995840
+ lovefest: 542431903886606399
+ pyweek_announcements: 897568414044938310
+
contributors: 295488872404484098
help_cooldown: 699189276025421825
muted: &MUTED_ROLE 277914926603829249
@@ -308,6 +313,7 @@ guild:
big_brother: 569133704568373283
dev_log: 680501655111729222
duck_pond: 637821475327311927
+ incidents: 816650601844572212
incidents_archive: 720671599790915702
python_news: &PYNEWS_WEBHOOK 704381182279942324
@@ -371,7 +377,7 @@ urls:
site_logs_view: !JOIN [*STAFF, "/bot/logs"]
# Snekbox
- snekbox_eval_api: "http://snekbox.default.svc.cluster.local/eval"
+ snekbox_eval_api: !ENV ["SNEKBOX_EVAL_API", "http://snekbox.default.svc.cluster.local/eval"]
# Discord API URLs
discord_api: &DISCORD_API "https://discordapp.com/api/v7/"
diff --git a/docker-compose.yml b/docker-compose.yml
index b3ca6baa4..869d9acb6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -90,7 +90,6 @@ services:
context: .
dockerfile: Dockerfile
volumes:
- - ./logs:/bot/logs
- .:/bot:ro
tty: true
depends_on:
diff --git a/poetry.lock b/poetry.lock
index 5e3f575d3..d91941d45 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -264,19 +264,23 @@ murmur = ["mmh3"]
[[package]]
name = "discord.py"
-version = "1.7.3"
+version = "2.0.0a0"
description = "A Python wrapper for the Discord API"
category = "main"
optional = false
-python-versions = ">=3.5.3"
+python-versions = ">=3.8.0"
[package.dependencies]
aiohttp = ">=3.6.0,<3.8.0"
[package.extras]
-docs = ["sphinx (==3.0.3)", "sphinxcontrib-trio (==1.1.2)", "sphinxcontrib-websupport"]
+docs = ["sphinx (==4.0.2)", "sphinxcontrib-trio (==1.1.2)", "sphinxcontrib-websupport"]
+speed = ["orjson (>=3.5.4)"]
voice = ["PyNaCl (>=1.3.0,<1.5)"]
+[package.source]
+type = "url"
+url = "https://github.com/Rapptz/discord.py/archive/45d498c1b76deaf3b394d17ccf56112fa691d160.zip"
[[package]]
name = "distlib"
version = "0.3.3"
@@ -346,7 +350,7 @@ sgmllib3k = "*"
[[package]]
name = "filelock"
-version = "3.3.0"
+version = "3.3.1"
description = "A platform independent file lock."
category = "dev"
optional = false
@@ -718,7 +722,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "pycares"
-version = "4.0.0"
+version = "4.1.2"
description = "Python interface for c-ares"
category = "main"
optional = false
@@ -898,7 +902,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
[[package]]
name = "rapidfuzz"
-version = "1.7.1"
+version = "1.8.0"
description = "rapid fuzzy string matching"
category = "main"
optional = false
@@ -1110,7 +1114,7 @@ multidict = ">=4.0"
[metadata]
lock-version = "1.1"
python-versions = "3.9.*"
-content-hash = "24a2142956e96706dced0172955c0338cb48fb4c067451301613014e23a82d62"
+content-hash = "da321f13297501e62dd1eb362eccb586ea1a9c21ddb395e11a91b93a2f92e9d4"
[metadata.files]
aio-pika = [
@@ -1334,10 +1338,7 @@ deepdiff = [
{file = "deepdiff-4.3.2-py3-none-any.whl", hash = "sha256:59fc1e3e7a28dd0147b0f2b00e3e27181f0f0ef4286b251d5f214a5bcd9a9bc4"},
{file = "deepdiff-4.3.2.tar.gz", hash = "sha256:91360be1d9d93b1d9c13ae9c5048fa83d9cff17a88eb30afaa0d7ff2d0fee17d"},
]
-"discord.py" = [
- {file = "discord.py-1.7.3-py3-none-any.whl", hash = "sha256:c6f64db136de0e18e090f6752ea68bdd4ab0a61b82dfe7acecefa22d6477bb0c"},
- {file = "discord.py-1.7.3.tar.gz", hash = "sha256:462cd0fe307aef8b29cbfa8dd613e548ae4b2cb581d46da9ac0d46fb6ea19408"},
-]
+"discord.py" = []
distlib = [
{file = "distlib-0.3.3-py2.py3-none-any.whl", hash = "sha256:c8b54e8454e5bf6237cc84c20e8264c3e991e824ef27e8f1e81049867d861e31"},
{file = "distlib-0.3.3.zip", hash = "sha256:d982d0751ff6eaaab5e2ec8e691d949ee80eddf01a62eaa96ddb11531fe16b05"},
@@ -1361,8 +1362,8 @@ feedparser = [
{file = "feedparser-6.0.8.tar.gz", hash = "sha256:5ce0410a05ab248c8c7cfca3a0ea2203968ee9ff4486067379af4827a59f9661"},
]
filelock = [
- {file = "filelock-3.3.0-py3-none-any.whl", hash = "sha256:bbc6a0382fe8ec4744ecdf6683a2e07f65eb10ff1aff53fc02a202565446cde0"},
- {file = "filelock-3.3.0.tar.gz", hash = "sha256:8c7eab13dc442dc249e95158bcc12dec724465919bdc9831fdbf0660f03d1785"},
+ {file = "filelock-3.3.1-py3-none-any.whl", hash = "sha256:2b5eb3589e7fdda14599e7eb1a50e09b4cc14f34ed98b8ba56d33bfaafcbef2f"},
+ {file = "filelock-3.3.1.tar.gz", hash = "sha256:34a9f35f95c441e7b38209775d6e0337f9a3759f3565f6c5798f19618527c76f"},
]
flake8 = [
{file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"},
@@ -1675,39 +1676,37 @@ py = [
{file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"},
]
pycares = [
- {file = "pycares-4.0.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:db5a533111a3cfd481e7e4fb2bf8bef69f4fa100339803e0504dd5aecafb96a5"},
- {file = "pycares-4.0.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:fdff88393c25016f417770d82678423fc7a56995abb2df3d2a1e55725db6977d"},
- {file = "pycares-4.0.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0aa97f900a7ffb259be77d640006585e2a907b0cd4edeee0e85cf16605995d5a"},
- {file = "pycares-4.0.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:a34b0e3e693dceb60b8a1169668d606c75cb100ceba0a2df53c234a0eb067fbc"},
- {file = "pycares-4.0.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7661d6bbd51a337e7373cb356efa8be9b4655fda484e068f9455e939aec8d54e"},
- {file = "pycares-4.0.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:57315b8eb8fdbc56b3ad4932bc4b17132bb7c7fd2bd590f7fb84b6b522098aa9"},
- {file = "pycares-4.0.0-cp36-cp36m-win32.whl", hash = "sha256:dca9dc58845a9d083f302732a3130c68ded845ad5d463865d464e53c75a3dd45"},
- {file = "pycares-4.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:c95c964d5dd307e104b44b193095c67bb6b10c9eda1ffe7d44ab7a9e84c476d9"},
- {file = "pycares-4.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:26e67e4f81c80a5955dcf6193f3d9bee3c491fc0056299b383b84d792252fba4"},
- {file = "pycares-4.0.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:cd3011ffd5e1ad55880f7256791dbab9c43ebeda260474a968f19cd0319e1aef"},
- {file = "pycares-4.0.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1b959dd5921d207d759d421eece1b60416df33a7f862465739d5f2c363c2f523"},
- {file = "pycares-4.0.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:6f258c1b74c048a9501a25f732f11b401564005e5e3c18f1ca6cad0c3dc0fb19"},
- {file = "pycares-4.0.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:b17ef48729786e62b574c6431f675f4cb02b27691b49e7428a605a50cd59c072"},
- {file = "pycares-4.0.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:82b3259cb590ddd107a6d2dc52da2a2e9a986bf242e893d58c786af2f8191047"},
- {file = "pycares-4.0.0-cp37-cp37m-win32.whl", hash = "sha256:4876fc790ae32832ae270c4a010a1a77e12ddf8d8e6ad70ad0b0a9d506c985f7"},
- {file = "pycares-4.0.0-cp37-cp37m-win_amd64.whl", hash = "sha256:f60c04c5561b1ddf85ca4e626943cc09d7fb684e1adb22abb632095415a40fd7"},
- {file = "pycares-4.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:615406013cdcd1b445e5d1a551d276c6200b3abe77e534f8a7f7e1551208d14f"},
- {file = "pycares-4.0.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6580aef5d1b29a88c3d72fe73c691eacfd454f86e74d3fdd18f4bad8e8def98b"},
- {file = "pycares-4.0.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8ebb3ba0485f66cae8eed7ce3e9ed6f2c0bfd5e7319d5d0fbbb511064f17e1d4"},
- {file = "pycares-4.0.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:c5362b7690ca481440f6b98395ac6df06aa50518ccb183c560464d1e5e2ab5d4"},
- {file = "pycares-4.0.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:eb60be66accc9a9ea1018b591a1f5800cba83491d07e9acc8c56bc6e6607ab54"},
- {file = "pycares-4.0.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:44896d6e191a6b5a914dbe3aa7c748481bf6ad19a9df33c1e76f8f2dc33fc8f0"},
- {file = "pycares-4.0.0-cp38-cp38-win32.whl", hash = "sha256:09b28fc7bc2cc05f7f69bf1636ddf46086e0a1837b62961e2092fcb40477320d"},
- {file = "pycares-4.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:d4a5081e232c1d181883dcac4675807f3a6cf33911c4173fbea00c0523687ed4"},
- {file = "pycares-4.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:103353577a6266a53e71bfee4cf83825f1401fefa60f0fb8bdec35f13be6a5f2"},
- {file = "pycares-4.0.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:ad6caf580ee69806fc6534be93ddbb6e99bf94296d79ab351c37b2992b17abfd"},
- {file = "pycares-4.0.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:3d5e50c95849f6905d2a9dbf02ed03f82580173e3c5604a39e2ad054185631f1"},
- {file = "pycares-4.0.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:53bc4f181b19576499b02cea4b45391e8dcbe30abd4cd01492f66bfc15615a13"},
- {file = "pycares-4.0.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:d52f9c725d2a826d5ffa37681eb07ffb996bfe21788590ef257664a3898fc0b5"},
- {file = "pycares-4.0.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:3c7fb8d34ee11971c39acfaf98d0fac66725385ccef3bfe1b174c92b210e1aa4"},
- {file = "pycares-4.0.0-cp39-cp39-win32.whl", hash = "sha256:e9773e07684a55f54657df05237267611a77b294ec3bacb5f851c4ffca38a465"},
- {file = "pycares-4.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:38e54037f36c149146ff15f17a4a963fbdd0f9871d4a21cd94ff9f368140f57e"},
- {file = "pycares-4.0.0.tar.gz", hash = "sha256:d0154fc5753b088758fbec9bc137e1b24bb84fc0c6a09725c8bac25a342311cd"},
+ {file = "pycares-4.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:71b99b9e041ae3356b859822c511f286f84c8889ec9ed1fbf6ac30fb4da13e4c"},
+ {file = "pycares-4.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c000942f5fc64e6e046aa61aa53b629b576ba11607d108909727c3c8f211a157"},
+ {file = "pycares-4.1.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:b0e50ddc78252f2e2b6b5f2c73e5b2449dfb6bea7a5a0e21dfd1e2bcc9e17382"},
+ {file = "pycares-4.1.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6831e963a910b0a8cbdd2750ffcdf5f2bb0edb3f53ca69ff18484de2cc3807c4"},
+ {file = "pycares-4.1.2-cp310-cp310-win32.whl", hash = "sha256:ad7b28e1b6bc68edd3d678373fa3af84e39d287090434f25055d21b4716b2fc6"},
+ {file = "pycares-4.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:27a6f09dbfb69bb79609724c0f90dfaa7c215876a7cd9f12d585574d1f922112"},
+ {file = "pycares-4.1.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e5a060f5fa90ae245aa99a4a8ad13ec39c2340400de037c7e8d27b081e1a3c64"},
+ {file = "pycares-4.1.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:056330275dea42b7199494047a745e1d9785d39fb8c4cd469dca043532240b80"},
+ {file = "pycares-4.1.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0aa897543a786daba74ec5e19638bd38b2b432d179a0e248eac1e62de5756207"},
+ {file = "pycares-4.1.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cbceaa9b2c416aa931627466d3240aecfc905c292c842252e3d77b8630072505"},
+ {file = "pycares-4.1.2-cp36-cp36m-win32.whl", hash = "sha256:112e1385c451069112d6b5ea1f9c378544f3c6b89882ff964e9a64be3336d7e4"},
+ {file = "pycares-4.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:c6680f7fdc0f1163e8f6c2a11d11b9a0b524a61000d2a71f9ccd410f154fb171"},
+ {file = "pycares-4.1.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58a41a2baabcd95266db776c510d349d417919407f03510fc87ac7488730d913"},
+ {file = "pycares-4.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a810d01c9a426ee8b0f36969c2aef5fb966712be9d7e466920beb328cd9cefa3"},
+ {file = "pycares-4.1.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:b266cec81dcea2c3efbbd3dda00af8d7eb0693ae9e47e8706518334b21f27d4a"},
+ {file = "pycares-4.1.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8319afe4838e09df267c421ca93da408f770b945ec6217dda72f1f6a493e37e4"},
+ {file = "pycares-4.1.2-cp37-cp37m-win32.whl", hash = "sha256:4d5da840aa0d9b15fa51107f09270c563a348cb77b14ae9653d0bbdbe326fcc2"},
+ {file = "pycares-4.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:5632f21d92cc0225ba5ff906e4e5dec415ef0b3df322c461d138190681cd5d89"},
+ {file = "pycares-4.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8fd1ff17a26bb004f0f6bb902ba7dddd810059096ae0cc3b45e4f5be46315d19"},
+ {file = "pycares-4.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:439799be4b7576e907139a7f9b3c8a01b90d3e38af4af9cd1fc6c1ee9a42b9e6"},
+ {file = "pycares-4.1.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:40079ed58efa91747c50aac4edf8ecc7e570132ab57dc0a4030eb0d016a6cab8"},
+ {file = "pycares-4.1.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4e190471a015f8225fa38069617192e06122771cce2b169ac7a60bfdbd3d4ab2"},
+ {file = "pycares-4.1.2-cp38-cp38-win32.whl", hash = "sha256:2b837315ed08c7df009b67725fe1f50489e99de9089f58ec1b243dc612f172aa"},
+ {file = "pycares-4.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:c7eba3c8354b730a54d23237d0b6445a2f68570fa68d0848887da23a3f3b71f3"},
+ {file = "pycares-4.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2f5f84fe9f83eab9cd68544b165b74ba6e3412d029cc9ab20098d9c332869fc5"},
+ {file = "pycares-4.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569eef8597b5e02b1bc4644b9f272160304d8c9985357d7ecfcd054da97c0771"},
+ {file = "pycares-4.1.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e1489aa25d14dbf7176110ead937c01176ed5a0ebefd3b092bbd6b202241814c"},
+ {file = "pycares-4.1.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:dc942692fca0e27081b7bb414bb971d34609c80df5e953f6d0c62ecc8019acd9"},
+ {file = "pycares-4.1.2-cp39-cp39-win32.whl", hash = "sha256:ed71dc4290d9c3353945965604ef1f6a4de631733e9819a7ebc747220b27e641"},
+ {file = "pycares-4.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:ec00f3594ee775665167b1a1630edceefb1b1283af9ac57480dba2fb6fd6c360"},
+ {file = "pycares-4.1.2.tar.gz", hash = "sha256:03490be0e7b51a0c8073f877bec347eff31003f64f57d9518d419d9369452837"},
]
pycodestyle = [
{file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"},
@@ -1793,57 +1792,64 @@ pyyaml = [
{file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"},
]
rapidfuzz = [
- {file = "rapidfuzz-1.7.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1ca9888e867aed2bb8d51571270e5f8393d718bb189fe1a7c0b047b8fd72bad3"},
- {file = "rapidfuzz-1.7.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:f336cd32a2a72eb9d7694618c9065ef3a2af330ab7e54bc0ec69d3b2eb08080e"},
- {file = "rapidfuzz-1.7.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:76124767ac3d3213a1aad989f80b156b225defef8addc825a5b631d3164c3213"},
- {file = "rapidfuzz-1.7.1-cp27-cp27m-win32.whl", hash = "sha256:c1090deb95e5369fff47c223c0ed3472644efc56817e288ebeaaa34822a1235c"},
- {file = "rapidfuzz-1.7.1-cp27-cp27m-win_amd64.whl", hash = "sha256:83f94c89e8f16679e0def3c7afa6c9ba477d837fd01250d6a1e3fea12267ce24"},
- {file = "rapidfuzz-1.7.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:cdd5962bd009b1457e280b5619d312cd6305b5b8afeff6c27869f98fee839c36"},
- {file = "rapidfuzz-1.7.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:2940960e212b66f00fc58f9b4a13e6f80221141dcbaee9c51f97e0a1f30ff1ab"},
- {file = "rapidfuzz-1.7.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:5ed4304a91043d27b92fe9af5eb87d1586548da6d03cbda5bbc98b00fee227cb"},
- {file = "rapidfuzz-1.7.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:be18495bd84bf2bd3e888270a3cd4dea868ff4b9b8ec6e540f0e195cda554140"},
- {file = "rapidfuzz-1.7.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:d5779e6f548b6f3edfbdfbeeda4158286684dcb2bae3515ce68c510ea48e1b4d"},
- {file = "rapidfuzz-1.7.1-cp35-cp35m-win32.whl", hash = "sha256:80d780c4f6da08eb6801489df54fdbdc5ef2b882bd73f9585ef6e0cf09f1690d"},
- {file = "rapidfuzz-1.7.1-cp35-cp35m-win_amd64.whl", hash = "sha256:3b205c63b8606c2b8595ba8403a8c3ebd39de9f7f44631a2f651f3efe106ae9a"},
- {file = "rapidfuzz-1.7.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8f96588a8a7d021debb4c60d82b15a80995daa99159bbeddd8a37f68f75ee06c"},
- {file = "rapidfuzz-1.7.1-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b8139116a937691dde17f27aafe774647808339305f4683b3a6d9bae6518aa2a"},
- {file = "rapidfuzz-1.7.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba574801c8410cc1f2d690ef65f898f6a660bba22ec8213e0f34dd0f0590bc71"},
- {file = "rapidfuzz-1.7.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2d5194e3cb638af0cc7c02daa61cef07e332fd3f790ec113006302131be9afa6"},
- {file = "rapidfuzz-1.7.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dd9d8eaae888b966422cbcba954390a63b4933d8c513ea0056fd6e42d421d08"},
- {file = "rapidfuzz-1.7.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3725c61b9cf57b6b7a765b92046e7d9e5ccce845835b523954b410a70dc32692"},
- {file = "rapidfuzz-1.7.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e417961e5ca450d6c7448accc5a7e4e9ab0dd3c63729f76215d5e672785920fc"},
- {file = "rapidfuzz-1.7.1-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:26d756284c8c6274b5d558e759415bfb4016fcdf168159b34702c346875d8cc0"},
- {file = "rapidfuzz-1.7.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4887766f0dcc5df43fe4315df4b3c642829e06dc60d5bcb5e682fb76657e8ed1"},
- {file = "rapidfuzz-1.7.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec0a29671d59998b97998b757ab1c636dd3b7721eda41746ae897abe709681a9"},
- {file = "rapidfuzz-1.7.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dff55750fecd8c0f07bc199e48427c86873be2d0e6a3a80df98972847287f5d3"},
- {file = "rapidfuzz-1.7.1-cp37-cp37m-win32.whl", hash = "sha256:e113f741bb18b0ddd14d714d80ce9c6d5322724f3023b920708e82491e7aef28"},
- {file = "rapidfuzz-1.7.1-cp37-cp37m-win_amd64.whl", hash = "sha256:ef20654be0aed240ee44c98ce02639c37422adc3e144d28c4b6d3da043d9fd20"},
- {file = "rapidfuzz-1.7.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:9e27eb57745a4d2a390b056f6f490b712c2f54250c5d2c794dd76062065a8aef"},
- {file = "rapidfuzz-1.7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:de2b0ebb67ee0b78973141dba91f574a325a3425664dbdbad37fd7aca7b28cab"},
- {file = "rapidfuzz-1.7.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:88c65d91dcd3c0595112d16555536c60ac5bcab1a43e517e155a242a39525057"},
- {file = "rapidfuzz-1.7.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:afd525a9b593cc1099f0210e116bcb4d9fc5585728d7bd929e6a4133dacd2d59"},
- {file = "rapidfuzz-1.7.1-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e6d77f104a8d67c01ae4248ced6f0d4ef05e63931afdf49c20decf962318877f"},
- {file = "rapidfuzz-1.7.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7db9d6ad0ab80e9e0f66f157b8e31b1d04ce5fa767b936ca1c212b98092572b1"},
- {file = "rapidfuzz-1.7.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0195c57f4beea0e7691594f59faf62a4be3c818c1955a8b9b712f37adc479d2d"},
- {file = "rapidfuzz-1.7.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9ffca8c8b74d12cd36c051e9befa7c4eb2d34624ce71f22dbfc659af15bf4a1e"},
- {file = "rapidfuzz-1.7.1-cp38-cp38-win32.whl", hash = "sha256:234cb75aa1e21cabad6a8c0718f84e2bfafdd4756b5232d5739545f97e343e59"},
- {file = "rapidfuzz-1.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:058977e93ab736071fcd8828fc6289ec026e9ca4a19f2a0967f9260e63910da8"},
- {file = "rapidfuzz-1.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9d02bb0724326826b1884cc9b9d9fd97ac352c18213f45e465a39ef069a33115"},
- {file = "rapidfuzz-1.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:212d6fa5b824aaa49a921c81d7cdc1d079b3545a30563ae14dc88e17918e76bf"},
- {file = "rapidfuzz-1.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a0cd8117deba10e2a1d6dccb6ff44a4c737adda3048dc45860c5f53cf64db14f"},
- {file = "rapidfuzz-1.7.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:61faa47b6b5d5a0cbe9fa6369df44d3f9435c4cccdb4d38d9de437f18b69dc4d"},
- {file = "rapidfuzz-1.7.1-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1daa756be52a7ee60d553ba667cda3a188ee811c92a9c21df43a4cdadb1eb8ca"},
- {file = "rapidfuzz-1.7.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c98ac10782dadf507e922963c8b8456a79151b4f10dbb08cfc86c1572db366dc"},
- {file = "rapidfuzz-1.7.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:358d80061ca107df6c3e1f67fa7af0f94a62827cb9c44ac09a16e78b38f7c3d5"},
- {file = "rapidfuzz-1.7.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5f90fc31d54fcd74a97d175892555786a8214a3cff43077463915b8a45a191d"},
- {file = "rapidfuzz-1.7.1-cp39-cp39-win32.whl", hash = "sha256:55dffdcdccea6f077a4f09164039411f01f621633be5883c58ceaf94f007a688"},
- {file = "rapidfuzz-1.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:d712a7f680d2074b587650f81865ca838c04fcc6b77c9d2d742de0853aaa24ce"},
- {file = "rapidfuzz-1.7.1-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:729d73a8db5a2b444a19d4aa2be009b2e628d207d7c754f6d280e3c6a59b94cb"},
- {file = "rapidfuzz-1.7.1-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:a1cabbc645395b6175cad79164d9ec621866a004b476e44cac534020b9f6bddb"},
- {file = "rapidfuzz-1.7.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ae697294f456f7f76e5bd30db5a65e8b855e7e09f9a65e144efa1e2c5009553c"},
- {file = "rapidfuzz-1.7.1-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e8ae51c1cf1f034f15216fec2e1eef658c8b3a9cbdcc1a053cc7133ede9d616d"},
- {file = "rapidfuzz-1.7.1-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:dccc072f2a0eeb98d46a79427ef793836ebc5184b1fe544b34607be10705ddc3"},
- {file = "rapidfuzz-1.7.1.tar.gz", hash = "sha256:99495c679174b2a02641f7dc2364a208135cacca77fc4825a86efbfe1e23b0ff"},
+ {file = "rapidfuzz-1.8.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:91f094562c683802e6c972bce27a692dad70d6cd1114e626b29d990c3704c653"},
+ {file = "rapidfuzz-1.8.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:4a20682121e245cf5ad2dbdd771360763ea11b77520632a1034c4bb9ad1e854c"},
+ {file = "rapidfuzz-1.8.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:8810e75d8f9c4453bbd6209c372bf97514359b0b5efff555caf85b15f8a9d862"},
+ {file = "rapidfuzz-1.8.0-cp27-cp27m-win32.whl", hash = "sha256:00cf713d843735b5958d87294f08b05c653a593ced7c4120be34f5d26d7a320a"},
+ {file = "rapidfuzz-1.8.0-cp27-cp27m-win_amd64.whl", hash = "sha256:2baca64e23a623e077f57e5470de21af2765af15aa1088676eb2d475e664eed0"},
+ {file = "rapidfuzz-1.8.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:9bf7a6c61bacedd84023be356e057e1d209dd6997cfaa3c1cee77aa21d642f88"},
+ {file = "rapidfuzz-1.8.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:61b6434e3341ca5158ecb371b1ceb4c1f6110563a72d28bdce4eb2a084493e47"},
+ {file = "rapidfuzz-1.8.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e425e690383f6cf308e8c2e8d630fa9596f67d233344efd8fae11e70a9f5635f"},
+ {file = "rapidfuzz-1.8.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:93db5e693b76d616b09df27ca5c79e0dda169af7f1b8f5ab3262826d981e37e2"},
+ {file = "rapidfuzz-1.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a8c4f76ed1c8a65892d98dc2913027c9acdb219d18f3a441cfa427a32861af9"},
+ {file = "rapidfuzz-1.8.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:71e217fd30901214cc96c0c15057278bafb7072aa9b2be4c97459c1fedf3e731"},
+ {file = "rapidfuzz-1.8.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d579dd447b8e851462e79054b68f94b66b09df8b3abb2aa5ca07fe00912ef5e8"},
+ {file = "rapidfuzz-1.8.0-cp310-cp310-win32.whl", hash = "sha256:5808064555273496dcd594d659bd28ee8d399149dd31575321034424455dc955"},
+ {file = "rapidfuzz-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:798fef1671ca66c78b47802228e9583f7ab32b99bdfe3984ebb1f96e93e38b5f"},
+ {file = "rapidfuzz-1.8.0-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:c9e0ed210831f5c73533bf11099ea7897db491e76c3443bef281d9c1c67d7f3a"},
+ {file = "rapidfuzz-1.8.0-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:c819bb19eb615a31ddc9cb8248a285bf04f58158b53ce096451178631f99b652"},
+ {file = "rapidfuzz-1.8.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:942ee45564f28ef70320d1229f02dc998bd93e3519c1f3a80f33ce144b51039c"},
+ {file = "rapidfuzz-1.8.0-cp35-cp35m-win32.whl", hash = "sha256:7e6ae2e5a3bc9acc51e118f25d32b8efcd431c5d8deb408336dd2ed0f21d087c"},
+ {file = "rapidfuzz-1.8.0-cp35-cp35m-win_amd64.whl", hash = "sha256:98901fba67c89ad2506f3946642cf6eb8f489592fb7eb307ebdf8bdb0c4e97f9"},
+ {file = "rapidfuzz-1.8.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:705e1686f406a0c77ef323cdb7369b7cf9e68f2abfcb83ff5f1e0a5b21f5a534"},
+ {file = "rapidfuzz-1.8.0-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:da0c5fe5fdbbd74206c1778af6b8c5ff8dfbe2dd04ae12bbe96642b358acefce"},
+ {file = "rapidfuzz-1.8.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:535253bc9224215131ae450aad6c9f7ef1b24f15c685045eab2b52511268bd06"},
+ {file = "rapidfuzz-1.8.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acdad83f07d886705fce164b0d1f4e3b56788a205602ed3a7fc8b10ceaf05fbf"},
+ {file = "rapidfuzz-1.8.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35097f649831f8375d6c65a237deccac3aceb573aa7fae1e5d3fa942e89de1c8"},
+ {file = "rapidfuzz-1.8.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6f4db142e5b4b44314166a90e11603220db659bd2f9c23dd5db402c13eac8eb7"},
+ {file = "rapidfuzz-1.8.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:19a3f55f27411d68360540484874beda0b428b062596d5f0f141663ef0738bfd"},
+ {file = "rapidfuzz-1.8.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:22b4c1a7f6fe29bd8dae49f7d5ab085dc42c3964f1a78b6dca22fdf83b5c9bfa"},
+ {file = "rapidfuzz-1.8.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8bfb2fbc147904b78d5c510ee75dc8704b606e956df23f33a9e89abc03f45c3"},
+ {file = "rapidfuzz-1.8.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6dc5111ebfed2c4f2e4d120a9b280ea13ea4fbb60b6915dd239817b4fc092ed"},
+ {file = "rapidfuzz-1.8.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:db5ee2457d97cb967ffe08446a8c595c03fe747fdc2e145266713f9c516d1c4a"},
+ {file = "rapidfuzz-1.8.0-cp37-cp37m-win32.whl", hash = "sha256:12c1b78cc15fc26f555a4bf66088d5afb6354b5a5aa149a123f01a15af6c411b"},
+ {file = "rapidfuzz-1.8.0-cp37-cp37m-win_amd64.whl", hash = "sha256:693e9579048d8db4ff020715dd6f25aa315fd6445bc94e7400d7a94a227dad27"},
+ {file = "rapidfuzz-1.8.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b4fe19df3edcf7de359448b872aec08e6592b4ca2d3df4d8ee57b5812d68bebf"},
+ {file = "rapidfuzz-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f3670b9df0e1f479637cad1577afca7766a02775dc08c14837cf495c82861d7c"},
+ {file = "rapidfuzz-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:61d118f36eb942649b0db344f7b7a19ad7e9b5749d831788187eb03b57ce1bfa"},
+ {file = "rapidfuzz-1.8.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:fce3a2c8a1d10da12aff4a0d367624e8ae9e15c1b84a5144843681d39be0c355"},
+ {file = "rapidfuzz-1.8.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1577ef26e3647ccc4cc9754c34ffaa731639779f4d7779e91a761c72adac093e"},
+ {file = "rapidfuzz-1.8.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fec9b7e60fde51990c3b48fc1aa9dba9ac3acaf78f623dbb645a6fe21a9654e"},
+ {file = "rapidfuzz-1.8.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b954469d93858bc8b48129bc63fd644382a4df5f3fb1b4b290f48eac1d00a2da"},
+ {file = "rapidfuzz-1.8.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:190ba709069a7e5a6b39b7c8bc413a08cfa7f1f4defec5d974c4128b510e0234"},
+ {file = "rapidfuzz-1.8.0-cp38-cp38-win32.whl", hash = "sha256:97b2d13d6323649b43d1b113681e4013ba230bd6e9827cc832dcebee447d7250"},
+ {file = "rapidfuzz-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:81c3091209b75f6611efe2af18834180946d4ce28f41ca8d44fce816187840d2"},
+ {file = "rapidfuzz-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d610afa33e92aa0481a514ffda3ec51ca5df3c684c1c1c795307589c62025931"},
+ {file = "rapidfuzz-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d976f33ca6b5fabbb095c0a662f5b86baf706184fc24c7f125d4ddb54b8bf036"},
+ {file = "rapidfuzz-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0f5ca7bca2af598d4ddcf5b93b64b50654a9ff684e6f18d865f6e13fee442b3e"},
+ {file = "rapidfuzz-1.8.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6dc2aac5ea6b0306dcd28a6d1a89d35ed2c6ac426f2673ee1b92cf3f1d0fd5cd"},
+ {file = "rapidfuzz-1.8.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f145c9831c0454a696a3136a6380ea4e01434e9cc2f2bc10d032864c16d1d0e5"},
+ {file = "rapidfuzz-1.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4ce53291575b56c9d45add73ea013f43bafcea55eee9d5139aa759918d7685f"},
+ {file = "rapidfuzz-1.8.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de5773a39c00a0f23cfc5da9e0e5fd0fb512b0ebe23dc7289a38e1f9a4b5cefc"},
+ {file = "rapidfuzz-1.8.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87a802e55792bfbe192e2d557f38867dbe3671b49b3d5ecd873859c7460746ba"},
+ {file = "rapidfuzz-1.8.0-cp39-cp39-win32.whl", hash = "sha256:9391abf1121df831316222f28cea37397a0f72bd7978f3be6e7da29a7821e4e5"},
+ {file = "rapidfuzz-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:9eeca1b436042b5523dcf314f5822b1131597898c1d967f140d1917541a8a3d1"},
+ {file = "rapidfuzz-1.8.0-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:a01f2495aca479b49d3b3a8863d6ba9bea2043447a1ced74ae5ec5270059cbc1"},
+ {file = "rapidfuzz-1.8.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:b7d4b1a5d16817f8cdb34365c7b58ae22d5cf1b3207720bb2fa0b55968bdb034"},
+ {file = "rapidfuzz-1.8.0-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c738d0d7f1744646d48d19b4c775926082bcefebd2460f45ca383a0e882f5672"},
+ {file = "rapidfuzz-1.8.0-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0fb9c6078c17c12b52e66b7d0a2a1674f6bbbdc6a76e454c8479b95147018123"},
+ {file = "rapidfuzz-1.8.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:1482b385d83670eb069577c9667f72b41eec4f005aee32f1a4ff4e71e88afde2"},
+ {file = "rapidfuzz-1.8.0.tar.gz", hash = "sha256:83fff37acf0367314879231264169dcbc5e7de969a94f4b82055d06a7fddab9a"},
]
redis = [
{file = "redis-3.5.3-py2.py3-none-any.whl", hash = "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"},
diff --git a/pyproject.toml b/pyproject.toml
index 515514c7b..563bf4a27 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,6 +7,7 @@ license = "MIT"
[tool.poetry.dependencies]
python = "3.9.*"
+"discord.py" = {url = "https://github.com/Rapptz/discord.py/archive/45d498c1b76deaf3b394d17ccf56112fa691d160.zip"}
aio-pika = "~=6.1"
aiodns = "~=2.0"
aiohttp = "~=3.7"
@@ -17,7 +18,6 @@ beautifulsoup4 = "~=4.9"
colorama = { version = "~=0.4.3", markers = "sys_platform == 'win32'" }
coloredlogs = "~=14.0"
deepdiff = "~=4.0"
-"discord.py" = "~=1.7.3"
emoji = "~=0.6"
feedparser = "~=6.0.2"
rapidfuzz = "~=1.4"
@@ -45,7 +45,7 @@ flake8-isort = "~=4.0"
pep8-naming = "~=0.9"
pre-commit = "~=2.1"
taskipy = "~=1.7.0"
-pip-licenses = "~=3.5.2"
+pip-licenses = "~=3.5.3"
python-dotenv = "~=0.17.1"
pytest = "~=6.2.4"
pytest-cov = "~=2.12.1"
diff --git a/tests/base.py b/tests/base.py
index ab9287e9a..5e304ea9d 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -103,4 +103,4 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase):
with self.assertRaises(commands.MissingPermissions) as cm:
await cmd.can_run(ctx)
- self.assertCountEqual(permissions.keys(), cm.exception.missing_perms)
+ self.assertCountEqual(permissions.keys(), cm.exception.missing_permissions)
diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py
index 382194a63..35fa0ee59 100644
--- a/tests/bot/exts/backend/test_error_handler.py
+++ b/tests/bot/exts/backend/test_error_handler.py
@@ -107,7 +107,7 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase):
"""Should send error with `ctx.send` when error is `CommandOnCooldown`."""
self.ctx.reset_mock()
cog = ErrorHandler(self.bot)
- error = errors.CommandOnCooldown(10, 9)
+ error = errors.CommandOnCooldown(10, 9, type=None)
self.assertIsNone(await cog.on_command_error(self.ctx, error))
self.ctx.send.assert_awaited_once_with(error)
@@ -544,38 +544,6 @@ class IndividualErrorHandlerTests(unittest.IsolatedAsyncioTestCase):
push_scope_mock.set_extra.has_calls(set_extra_calls)
-class OtherErrorHandlerTests(unittest.IsolatedAsyncioTestCase):
- """Other `ErrorHandler` tests."""
-
- def setUp(self):
- self.bot = MockBot()
- self.ctx = MockContext()
-
- async def test_get_help_command_command_specified(self):
- """Should return coroutine of help command of specified command."""
- self.ctx.command = "foo"
- result = ErrorHandler.get_help_command(self.ctx)
- expected = self.ctx.send_help("foo")
- self.assertEqual(result.__qualname__, expected.__qualname__)
- self.assertEqual(result.cr_frame.f_locals, expected.cr_frame.f_locals)
-
- # Await coroutines to avoid warnings
- await result
- await expected
-
- async def test_get_help_command_no_command_specified(self):
- """Should return coroutine of help command."""
- self.ctx.command = None
- result = ErrorHandler.get_help_command(self.ctx)
- expected = self.ctx.send_help()
- self.assertEqual(result.__qualname__, expected.__qualname__)
- self.assertEqual(result.cr_frame.f_locals, expected.cr_frame.f_locals)
-
- # Await coroutines to avoid warnings
- await result
- await expected
-
-
class ErrorHandlerSetupTests(unittest.TestCase):
"""Tests for `ErrorHandler` `setup` function."""
diff --git a/tests/bot/exts/filters/test_filtering.py b/tests/bot/exts/filters/test_filtering.py
new file mode 100644
index 000000000..8ae59c1f1
--- /dev/null
+++ b/tests/bot/exts/filters/test_filtering.py
@@ -0,0 +1,40 @@
+import unittest
+from unittest.mock import patch
+
+from bot.exts.filters import filtering
+from tests.helpers import MockBot, autospec
+
+
+class FilteringCogTests(unittest.IsolatedAsyncioTestCase):
+ """Tests the `Filtering` cog."""
+
+ def setUp(self):
+ """Instantiate the bot and cog."""
+ self.bot = MockBot()
+ with patch("bot.utils.scheduling.create_task", new=lambda task, **_: task.close()):
+ self.cog = filtering.Filtering(self.bot)
+
+ @autospec(filtering.Filtering, "_get_filterlist_items", pass_mocks=False, return_value=["TOKEN"])
+ async def test_token_filter(self):
+ """Ensure that a filter token is correctly detected in a message."""
+ messages = {
+ "": False,
+ "no matches": False,
+ "TOKEN": True,
+
+ # See advisory https://github.com/python-discord/bot/security/advisories/GHSA-j8c3-8x46-8pp6
+ "https://google.com TOKEN": True,
+ "https://google.com something else": False,
+ }
+
+ for message, match in messages.items():
+ with self.subTest(input=message, match=match):
+ result, _ = await self.cog._has_watch_regex_match(message)
+
+ self.assertEqual(
+ match,
+ bool(result),
+ msg=f"Hit was {'expected' if match else 'not expected'} for this input."
+ )
+ if result:
+ self.assertEqual("TOKEN", result.group())
diff --git a/tests/bot/exts/filters/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py
index 05e790723..4db27269a 100644
--- a/tests/bot/exts/filters/test_token_remover.py
+++ b/tests/bot/exts/filters/test_token_remover.py
@@ -26,7 +26,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
self.msg.guild.get_member.return_value.bot = False
self.msg.guild.get_member.return_value.__str__.return_value = "Woody"
self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name)
- self.msg.author.avatar_url_as.return_value = "picture-lemon.png"
+ self.msg.author.display_avatar.url = "picture-lemon.png"
def test_extract_user_id_valid(self):
"""Should consider user IDs valid if they decode into an integer ID."""
@@ -376,7 +376,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
colour=Colour(constants.Colours.soft_red),
title="Token removed!",
text=log_msg + "\n" + userid_log_message,
- thumbnail=self.msg.author.avatar_url_as.return_value,
+ thumbnail=self.msg.author.display_avatar.url,
channel_id=constants.Channels.mod_alerts,
ping_everyone=True,
)
diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py
index d8250befb..632287322 100644
--- a/tests/bot/exts/info/test_information.py
+++ b/tests/bot/exts/info/test_information.py
@@ -42,7 +42,7 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase):
embed = kwargs.pop('embed')
self.assertEqual(embed.title, "Role information (Total 1 role)")
- self.assertEqual(embed.colour, discord.Colour.blurple())
+ self.assertEqual(embed.colour, discord.Colour.og_blurple())
self.assertEqual(embed.description, f"\n`{self.moderator_role.id}` - {self.moderator_role.mention}\n")
async def test_role_info_command(self):
@@ -50,7 +50,7 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase):
dummy_role = helpers.MockRole(
name="Dummy",
id=112233445566778899,
- colour=discord.Colour.blurple(),
+ colour=discord.Colour.og_blurple(),
position=10,
members=[self.ctx.author],
permissions=discord.Permissions(0)
@@ -80,7 +80,7 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase):
admin_embed = admin_kwargs["embed"]
self.assertEqual(dummy_embed.title, "Dummy info")
- self.assertEqual(dummy_embed.colour, discord.Colour.blurple())
+ self.assertEqual(dummy_embed.colour, discord.Colour.og_blurple())
self.assertEqual(dummy_embed.fields[0].value, str(dummy_role.id))
self.assertEqual(dummy_embed.fields[1].value, f"#{dummy_role.colour.value:0>6x}")
@@ -417,14 +417,14 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
f"{COG_PATH}.basic_user_infraction_counts",
new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
)
- async def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self):
- """The embed should be created with a blurple colour if the user has no assigned roles."""
+ async def test_create_user_embed_uses_og_blurple_colour_when_user_has_no_roles(self):
+ """The embed should be created with the og blurple colour if the user has no assigned roles."""
ctx = helpers.MockContext()
user = helpers.MockMember(id=217, colour=discord.Colour.default())
embed = await self.cog.create_user_embed(ctx, user)
- self.assertEqual(embed.colour, discord.Colour.blurple())
+ self.assertEqual(embed.colour, discord.Colour.og_blurple())
@unittest.mock.patch(
f"{COG_PATH}.basic_user_infraction_counts",
@@ -435,10 +435,9 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
ctx = helpers.MockContext()
user = helpers.MockMember(id=217, colour=0)
- user.avatar_url_as.return_value = "avatar url"
+ user.display_avatar.url = "avatar url"
embed = await self.cog.create_user_embed(ctx, user)
- user.avatar_url_as.assert_called_once_with(static_format="png")
self.assertEqual(embed.thumbnail.url, "avatar url")
diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py
index eb256f1fd..72eebb254 100644
--- a/tests/bot/exts/moderation/infraction/test_utils.py
+++ b/tests/bot/exts/moderation/infraction/test_utils.py
@@ -139,14 +139,14 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
type="Ban",
expires="2020-02-26 09:20 (23 hours and 59 minutes)",
reason="No reason provided."
- ),
+ ) + utils.INFRACTION_APPEAL_SERVER_FOOTER,
colour=Colours.soft_red,
url=utils.RULES_URL
).set_author(
name=utils.INFRACTION_AUTHOR_NAME,
url=utils.RULES_URL,
icon_url=Icons.token_removed
- ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER),
+ ),
"send_result": True
},
{
@@ -157,14 +157,14 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
type="Warning",
expires="N/A",
reason="Test reason."
- ),
+ ) + utils.INFRACTION_APPEAL_MODMAIL_FOOTER,
colour=Colours.soft_red,
url=utils.RULES_URL
).set_author(
name=utils.INFRACTION_AUTHOR_NAME,
url=utils.RULES_URL,
icon_url=Icons.token_removed
- ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER),
+ ),
"send_result": False
},
# Note that this test case asserts that the DM that *would* get sent to the user is formatted
@@ -177,14 +177,14 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
type="Note",
expires="N/A",
reason="No reason provided."
- ),
+ ) + utils.INFRACTION_APPEAL_MODMAIL_FOOTER,
colour=Colours.soft_red,
url=utils.RULES_URL
).set_author(
name=utils.INFRACTION_AUTHOR_NAME,
url=utils.RULES_URL,
icon_url=Icons.defcon_denied
- ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER),
+ ),
"send_result": False
},
{
@@ -195,14 +195,14 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
type="Mute",
expires="2020-02-26 09:20 (23 hours and 59 minutes)",
reason="Test"
- ),
+ ) + utils.INFRACTION_APPEAL_MODMAIL_FOOTER,
colour=Colours.soft_red,
url=utils.RULES_URL
).set_author(
name=utils.INFRACTION_AUTHOR_NAME,
url=utils.RULES_URL,
icon_url=Icons.defcon_denied
- ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER),
+ ),
"send_result": False
},
{
@@ -213,14 +213,14 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
type="Mute",
expires="N/A",
reason="foo bar" * 4000
- )[:4093] + "...",
+ )[:4093-utils.LONGEST_EXTRAS] + "..." + utils.INFRACTION_APPEAL_MODMAIL_FOOTER,
colour=Colours.soft_red,
url=utils.RULES_URL
).set_author(
name=utils.INFRACTION_AUTHOR_NAME,
url=utils.RULES_URL,
icon_url=Icons.defcon_denied
- ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER),
+ ),
"send_result": True
}
]
diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py
index c98edf08a..cfe0c4b03 100644
--- a/tests/bot/exts/moderation/test_incidents.py
+++ b/tests/bot/exts/moderation/test_incidents.py
@@ -3,13 +3,16 @@ import enum
import logging
import typing as t
import unittest
-from unittest.mock import AsyncMock, MagicMock, call, patch
+from unittest import mock
+from unittest.mock import AsyncMock, MagicMock, Mock, call, patch
import aiohttp
import discord
+from async_rediscache import RedisSession
from bot.constants import Colours
from bot.exts.moderation import incidents
+from bot.utils.messages import format_user
from tests.helpers import (
MockAsyncWebhook, MockAttachment, MockBot, MockMember, MockMessage, MockReaction, MockRole, MockTextChannel,
MockUser
@@ -276,6 +279,22 @@ class TestIncidents(unittest.IsolatedAsyncioTestCase):
the instance as they wish.
"""
+ session = None
+
+ async def flush(self):
+ """Flush everything from the database to prevent carry-overs between tests."""
+ with await self.session.pool as connection:
+ await connection.flushall()
+
+ async def asyncSetUp(self): # noqa: N802
+ self.session = RedisSession(use_fakeredis=True)
+ await self.session.connect()
+ await self.flush()
+
+ async def asyncTearDown(self): # noqa: N802
+ if self.session:
+ await self.session.close()
+
def setUp(self):
"""
Prepare a fresh `Incidents` instance for each test.
@@ -372,7 +391,7 @@ class TestArchive(TestIncidents):
# Define our own `incident` to be archived
incident = MockMessage(
content="this is an incident",
- author=MockUser(name="author_name", avatar_url="author_avatar"),
+ author=MockUser(name="author_name", display_avatar=Mock(url="author_avatar")),
id=123,
)
built_embed = MagicMock(discord.Embed, id=123) # We patch `make_embed` to return this
@@ -506,7 +525,7 @@ class TestProcessEvent(TestIncidents):
with patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", mock_task):
await self.cog_instance.process_event(
reaction=incidents.Signal.ACTIONED.value,
- incident=MockMessage(),
+ incident=MockMessage(id=123),
member=MockMember(roles=[MockRole(id=1)])
)
@@ -526,7 +545,7 @@ class TestProcessEvent(TestIncidents):
with patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", mock_task):
await self.cog_instance.process_event(
reaction=incidents.Signal.ACTIONED.value,
- incident=MockMessage(),
+ incident=MockMessage(id=123),
member=MockMember(roles=[MockRole(id=1)])
)
except asyncio.TimeoutError:
@@ -761,3 +780,74 @@ class TestOnMessage(TestIncidents):
await self.cog_instance.on_message(MockMessage())
mock_add_signals.assert_not_called()
+
+
+class TestMessageLinkEmbeds(TestIncidents):
+ """Tests for `extract_message_links` coroutine."""
+
+ async def test_shorten_text(self):
+ """Test all cases of text shortening by mocking messages."""
+ tests = {
+ "thisisasingleword"*10: "thisisasinglewordthisisasinglewordthisisasinglewor...",
+
+ "\n".join("Lets make a new line test".split()): "Lets\nmake\na...",
+
+ 'Hello, World!' * 300: (
+ "Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!"
+ "Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!"
+ "Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!"
+ "Hello, World!Hello, World!H..."
+ )
+ }
+
+ for content, expected_conversion in tests.items():
+ with self.subTest(content=content, expected_conversion=expected_conversion):
+ conversion = incidents.shorten_text(content)
+ self.assertEqual(conversion, expected_conversion)
+
+ async def extract_and_form_message_link_embeds(self):
+ """
+ Extract message links from a mocked message and form the message link embed.
+
+ Considers all types of message links, discord supports.
+ """
+ self.guild_id_patcher = mock.patch("bot.exts.backend.sync._cog.constants.Guild.id", 5)
+ self.guild_id = self.guild_id_patcher.start()
+
+ msg = MockMessage(id=555, content="Hello, World!" * 3000)
+ msg.channel.mention = "#lemonade-stand"
+
+ msg_links = [
+ # Valid Message links
+ f"https://discord.com/channels/{self.guild_id}/{msg.channel.discord_id}/{msg.discord_id}",
+ f"http://canary.discord.com/channels/{self.guild_id}/{msg.channel.discord_id}/{msg.discord_id}",
+
+ # Invalid Message links
+ f"https://discord.com/channels/{msg.channel.discord_id}/{msg.discord_id}",
+ f"https://discord.com/channels/{self.guild_id}/{msg.channel.discord_id}000/{msg.discord_id}",
+ ]
+
+ incident_msg = MockMessage(
+ id=777,
+ content=(
+ f"I would like to report the following messages, "
+ f"as they break our rules: \n{', '.join(msg_links)}"
+ )
+ )
+
+ with patch(
+ "bot.exts.moderation.incidents.Incidents.extract_message_links", AsyncMock()
+ ) as mock_extract_message_links:
+ embeds = mock_extract_message_links(incident_msg)
+ description = (
+ f"**Author:** {format_user(msg.author)}\n"
+ f"**Channel:** {msg.channel.mention} ({msg.channel.category}/#{msg.channel.name})\n"
+ f"**Content:** {('Hello, World!' * 3000)[:300] + '...'}\n"
+ )
+
+ # Check number of embeds returned with number of valid links
+ self.assertEqual(len(embeds), 2)
+
+ # Check for the embed descriptions
+ for embed in embeds:
+ self.assertEqual(embed.description, description)
diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py
index ef8394be8..92ce3418a 100644
--- a/tests/bot/exts/moderation/test_silence.py
+++ b/tests/bot/exts/moderation/test_silence.py
@@ -431,7 +431,13 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase):
asyncio.run(self.cog._async_init()) # Populate instance attributes.
self.text_channel = MockTextChannel()
- self.text_overwrite = PermissionOverwrite(send_messages=True, add_reactions=False)
+ self.text_overwrite = PermissionOverwrite(
+ send_messages=True,
+ add_reactions=False,
+ create_private_threads=True,
+ create_public_threads=False,
+ send_messages_in_threads=True
+ )
self.text_channel.overwrites_for.return_value = self.text_overwrite
self.voice_channel = MockVoiceChannel()
@@ -502,9 +508,39 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase):
async def test_skipped_already_silenced(self):
"""Permissions were not set and `False` was returned for an already silenced channel."""
subtests = (
- (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,
+ MockTextChannel(),
+ PermissionOverwrite(
+ send_messages=False,
+ add_reactions=False,
+ create_private_threads=False,
+ create_public_threads=False,
+ send_messages_in_threads=False
+ )
+ ),
+ (
+ True,
+ MockTextChannel(),
+ PermissionOverwrite(
+ send_messages=True,
+ add_reactions=True,
+ create_private_threads=True,
+ create_public_threads=True,
+ send_messages_in_threads=True
+ )
+ ),
+ (
+ True,
+ MockTextChannel(),
+ PermissionOverwrite(
+ send_messages=False,
+ add_reactions=False,
+ create_private_threads=False,
+ create_public_threads=False,
+ send_messages_in_threads=False
+ )
+ ),
(False, MockVoiceChannel(), PermissionOverwrite(connect=False, speak=False)),
(True, MockVoiceChannel(), PermissionOverwrite(connect=True, speak=True)),
(True, MockVoiceChannel(), PermissionOverwrite(connect=False, speak=False)),
@@ -552,11 +588,16 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase):
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']
- del prev_overwrite_dict['add_reactions']
- del new_overwrite_dict['send_messages']
- del new_overwrite_dict['add_reactions']
+ # Remove related permission keys because they were changed by the method.
+ for perm_name in (
+ "send_messages",
+ "add_reactions",
+ "create_private_threads",
+ "create_public_threads",
+ "send_messages_in_threads"
+ ):
+ del prev_overwrite_dict[perm_name]
+ del new_overwrite_dict[perm_name]
self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict)
@@ -594,7 +635,10 @@ 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}'
+ overwrite_json = (
+ '{"send_messages": true, "add_reactions": false, "create_private_threads": true, '
+ '"create_public_threads": false, "send_messages_in_threads": true}'
+ )
await self.cog._set_silence_overwrites(self.text_channel)
self.cog.previous_overwrites.set.assert_awaited_once_with(self.text_channel.id, overwrite_json)
diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py
index c23d66663..1bb678db2 100644
--- a/tests/bot/test_converters.py
+++ b/tests/bot/test_converters.py
@@ -1,6 +1,6 @@
-import datetime
import re
import unittest
+from datetime import MAXYEAR, datetime, timezone
from unittest.mock import MagicMock, patch
from dateutil.relativedelta import relativedelta
@@ -17,7 +17,7 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase):
cls.context = MagicMock
cls.context.author = 'bob'
- cls.fixed_utc_now = datetime.datetime.fromisoformat('2019-01-01T00:00:00')
+ cls.fixed_utc_now = datetime.fromisoformat('2019-01-01T00:00:00+00:00')
async def test_package_name_for_valid(self):
"""PackageName returns valid package names unchanged."""
@@ -96,7 +96,7 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase):
expected_datetime = self.fixed_utc_now + relativedelta(**duration_dict)
with patch('bot.converters.datetime') as mock_datetime:
- mock_datetime.utcnow.return_value = self.fixed_utc_now
+ mock_datetime.now.return_value = self.fixed_utc_now
with self.subTest(duration=duration, duration_dict=duration_dict):
converted_datetime = await converter.convert(self.context, duration)
@@ -142,52 +142,53 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase):
async def test_duration_converter_out_of_range(self, mock_datetime):
"""Duration converter should raise BadArgument if datetime raises a ValueError."""
mock_datetime.__add__.side_effect = ValueError
- mock_datetime.utcnow.return_value = mock_datetime
+ mock_datetime.now.return_value = mock_datetime
- duration = f"{datetime.MAXYEAR}y"
+ duration = f"{MAXYEAR}y"
exception_message = f"`{duration}` results in a datetime outside the supported range."
with self.assertRaisesRegex(BadArgument, re.escape(exception_message)):
await Duration().convert(self.context, duration)
async def test_isodatetime_converter_for_valid(self):
"""ISODateTime converter returns correct datetime for valid datetime string."""
+ utc = timezone.utc
test_values = (
# `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ`
- ('2019-09-02T02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)),
- ('2019-09-02 02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+ ('2019-09-02T02:03:05Z', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)),
+ ('2019-09-02 02:03:05Z', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)),
# `YYYY-mm-ddTHH:MM:SS±HH:MM` | `YYYY-mm-dd HH:MM:SS±HH:MM`
- ('2019-09-02T03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)),
- ('2019-09-02 03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)),
- ('2019-09-02T00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)),
- ('2019-09-02 00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+ ('2019-09-02T03:18:05+01:15', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)),
+ ('2019-09-02 03:18:05+01:15', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)),
+ ('2019-09-02T00:48:05-01:15', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)),
+ ('2019-09-02 00:48:05-01:15', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)),
# `YYYY-mm-ddTHH:MM:SS±HHMM` | `YYYY-mm-dd HH:MM:SS±HHMM`
- ('2019-09-02T03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)),
- ('2019-09-02 03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)),
- ('2019-09-02T00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)),
- ('2019-09-02 00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+ ('2019-09-02T03:18:05+0115', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)),
+ ('2019-09-02 03:18:05+0115', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)),
+ ('2019-09-02T00:48:05-0115', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)),
+ ('2019-09-02 00:48:05-0115', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)),
# `YYYY-mm-ddTHH:MM:SS±HH` | `YYYY-mm-dd HH:MM:SS±HH`
- ('2019-09-02 03:03:05+01', datetime.datetime(2019, 9, 2, 2, 3, 5)),
- ('2019-09-02T01:03:05-01', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+ ('2019-09-02 03:03:05+01', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)),
+ ('2019-09-02T01:03:05-01', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)),
# `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS`
- ('2019-09-02T02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)),
- ('2019-09-02 02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+ ('2019-09-02T02:03:05', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)),
+ ('2019-09-02 02:03:05', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)),
# `YYYY-mm-ddTHH:MM` | `YYYY-mm-dd HH:MM`
- ('2019-11-12T09:15', datetime.datetime(2019, 11, 12, 9, 15)),
- ('2019-11-12 09:15', datetime.datetime(2019, 11, 12, 9, 15)),
+ ('2019-11-12T09:15', datetime(2019, 11, 12, 9, 15, tzinfo=utc)),
+ ('2019-11-12 09:15', datetime(2019, 11, 12, 9, 15, tzinfo=utc)),
# `YYYY-mm-dd`
- ('2019-04-01', datetime.datetime(2019, 4, 1)),
+ ('2019-04-01', datetime(2019, 4, 1, tzinfo=utc)),
# `YYYY-mm`
- ('2019-02-01', datetime.datetime(2019, 2, 1)),
+ ('2019-02-01', datetime(2019, 2, 1, tzinfo=utc)),
# `YYYY`
- ('2025', datetime.datetime(2025, 1, 1)),
+ ('2025', datetime(2025, 1, 1, tzinfo=utc)),
)
converter = ISODateTime()
@@ -195,7 +196,6 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase):
for datetime_string, expected_dt in test_values:
with self.subTest(datetime_string=datetime_string, expected_dt=expected_dt):
converted_dt = await converter.convert(self.context, datetime_string)
- self.assertIsNone(converted_dt.tzinfo)
self.assertEqual(converted_dt, expected_dt)
async def test_isodatetime_converter_for_invalid(self):
diff --git a/tests/bot/utils/test_checks.py b/tests/bot/utils/test_checks.py
index 883465e0b..4ae11d5d3 100644
--- a/tests/bot/utils/test_checks.py
+++ b/tests/bot/utils/test_checks.py
@@ -32,6 +32,7 @@ class ChecksTests(unittest.IsolatedAsyncioTestCase):
async def test_has_no_roles_check_without_guild(self):
"""`has_no_roles_check` should return `False` when `Context.guild` is None."""
self.ctx.channel = MagicMock(DMChannel)
+ self.ctx.guild = None
self.assertFalse(await checks.has_no_roles_check(self.ctx))
async def test_has_no_roles_check_returns_false_with_unwanted_role(self):
diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py
index 8edffd1c9..a3dcbfc0a 100644
--- a/tests/bot/utils/test_time.py
+++ b/tests/bot/utils/test_time.py
@@ -72,9 +72,9 @@ 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 = (
- ('3000-12-12T00:01:00Z', datetime(3000, 12, 11, 12, 5, 5), 6,
+ ('3000-12-12T00:01:00Z', datetime(3000, 12, 11, 12, 5, 5, tzinfo=timezone.utc), 6,
'<t:32533488060:f> (11 hours, 55 minutes and 55 seconds)'),
- ('3000-11-23T20:09:00Z', datetime(3000, 4, 25, 20, 15), 20,
+ ('3000-11-23T20:09:00Z', datetime(3000, 4, 25, 20, 15, tzinfo=timezone.utc), 20,
'<t:32531918940:f> (6 months, 28 days, 23 hours and 54 minutes)')
)
@@ -84,16 +84,21 @@ 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."""
+ utc = timezone.utc
test_cases = (
- ('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-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5, tzinfo=utc), 2,
+ '<t:1576108860:f> (12 hours and 55 seconds)'),
+ ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5, tzinfo=utc), 1, '<t:1576108860:f> (12 hours)'),
+ ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59, tzinfo=utc), 2, '<t:1576108800:f> (1 minute)'),
+ ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15, tzinfo=utc), 2,
+ '<t:1574539740:f> (7 days and 23 hours)'),
+ ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15, tzinfo=utc), 2,
+ '<t:1574539740:f> (6 months and 28 days)'),
+ ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53, tzinfo=utc), 2, '<t:1574542680:f> (5 minutes)'),
+ ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0, tzinfo=utc), 2, '<t:1574553600:f> (1 minute)'),
+ ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0, tzinfo=utc), 2,
+ '<t:1574553540:f> (2 years and 4 months)'),
+ ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5, tzinfo=utc), 2,
'<t:1574553540:f> (9 minutes and 55 seconds)'),
(None, datetime(2019, 11, 23, 23, 49, 5), 2, None),
)
diff --git a/tests/helpers.py b/tests/helpers.py
index 83b9b2363..9d4988d23 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -39,7 +39,7 @@ class HashableMixin(discord.mixins.EqualityComparable):
class ColourMixin:
- """A mixin for Mocks that provides the aliasing of color->colour like discord.py does."""
+ """A mixin for Mocks that provides the aliasing of (accent_)color->(accent_)colour like discord.py does."""
@property
def color(self) -> discord.Colour:
@@ -49,6 +49,14 @@ class ColourMixin:
def color(self, color: discord.Colour) -> None:
self.colour = color
+ @property
+ def accent_color(self) -> discord.Colour:
+ return self.accent_colour
+
+ @accent_color.setter
+ def accent_color(self, color: discord.Colour) -> None:
+ self.accent_colour = color
+
class CustomMockMixin:
"""
@@ -242,7 +250,13 @@ class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin
# Create a User instance to get a realistic Mock of `discord.User`
-user_instance = discord.User(data=unittest.mock.MagicMock(), state=unittest.mock.MagicMock())
+_user_data_mock = collections.defaultdict(unittest.mock.MagicMock, {
+ "accent_color": 0
+})
+user_instance = discord.User(
+ data=unittest.mock.MagicMock(get=unittest.mock.Mock(side_effect=_user_data_mock.get)),
+ state=unittest.mock.MagicMock()
+)
class MockUser(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin):
@@ -428,7 +442,12 @@ message_instance = discord.Message(state=state, channel=channel, data=message_da
# Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context`
-context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock())
+context_instance = Context(
+ message=unittest.mock.MagicMock(),
+ prefix="$",
+ bot=MockBot(),
+ view=None
+)
context_instance.invoked_from_error_handler = None
@@ -537,7 +556,7 @@ class MockReaction(CustomMockMixin, unittest.mock.MagicMock):
self.__str__.return_value = str(self.emoji)
-webhook_instance = discord.Webhook(data=unittest.mock.MagicMock(), adapter=unittest.mock.MagicMock())
+webhook_instance = discord.Webhook(data=unittest.mock.MagicMock(), session=unittest.mock.MagicMock())
class MockAsyncWebhook(CustomMockMixin, unittest.mock.MagicMock):
diff --git a/tox.ini b/tox.ini
index b8293a3b6..9472c32f9 100644
--- a/tox.ini
+++ b/tox.ini
@@ -5,7 +5,7 @@ import-order-style=pycharm
application_import_names=bot,tests
exclude=.cache,.venv,.git,constants.py
ignore=
- B311,W503,E226,S311,T000
+ B311,W503,E226,S311,T000,E731
# Missing Docstrings
D100,D104,D105,D107,
# Docstring Whitespace