aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/lint-test.yml4
-rw-r--r--README.md1
-rw-r--r--bot/constants.py1
-rw-r--r--bot/converters.py13
-rw-r--r--bot/decorators.py7
-rw-r--r--bot/exts/filters/antimalware.py10
-rw-r--r--bot/exts/filters/antispam.py1
-rw-r--r--bot/exts/filters/filter_lists.py13
-rw-r--r--bot/exts/filters/filtering.py11
-rw-r--r--bot/exts/help_channels/_channel.py34
-rw-r--r--bot/exts/help_channels/_cog.py45
-rw-r--r--bot/exts/info/information.py14
-rw-r--r--bot/exts/info/subscribe.py2
-rw-r--r--bot/exts/moderation/clean.py42
-rw-r--r--bot/exts/moderation/defcon.py31
-rw-r--r--bot/exts/moderation/incidents.py1
-rw-r--r--bot/exts/moderation/infraction/_scheduler.py9
-rw-r--r--bot/exts/moderation/infraction/_utils.py2
-rw-r--r--bot/exts/moderation/infraction/infractions.py165
-rw-r--r--bot/exts/moderation/infraction/management.py19
-rw-r--r--bot/exts/moderation/infraction/superstarify.py14
-rw-r--r--bot/exts/moderation/modlog.py11
-rw-r--r--bot/exts/moderation/modpings.py7
-rw-r--r--bot/exts/moderation/slowmode.py3
-rw-r--r--bot/exts/moderation/stream.py13
-rw-r--r--bot/exts/moderation/voice_gate.py4
-rw-r--r--bot/exts/moderation/watchchannels/_watchchannel.py7
-rw-r--r--bot/exts/recruitment/talentpool/_cog.py13
-rw-r--r--bot/exts/recruitment/talentpool/_review.py8
-rw-r--r--bot/exts/utils/bot.py17
-rw-r--r--bot/exts/utils/reminders.py28
-rw-r--r--bot/exts/utils/snekbox.py21
-rw-r--r--bot/exts/utils/thread_bumper.py147
-rw-r--r--bot/exts/utils/utils.py5
-rw-r--r--bot/monkey_patches.py3
-rw-r--r--bot/resources/tags/contribute.md2
-rw-r--r--bot/resources/tags/traceback.md21
-rw-r--r--bot/utils/regex.py15
-rw-r--r--bot/utils/scheduling.py4
-rw-r--r--bot/utils/time.py274
-rw-r--r--docker-compose.yml1
-rw-r--r--poetry.lock187
-rw-r--r--pyproject.toml3
-rw-r--r--tests/bot/exts/info/test_information.py9
-rw-r--r--tests/bot/exts/moderation/infraction/test_infractions.py175
-rw-r--r--tests/bot/exts/moderation/test_clean.py104
-rw-r--r--tests/bot/exts/utils/test_snekbox.py24
-rw-r--r--tests/bot/utils/test_time.py47
48 files changed, 1063 insertions, 529 deletions
diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml
index f2c9dfb6c..57cc544d9 100644
--- a/.github/workflows/lint-test.yml
+++ b/.github/workflows/lint-test.yml
@@ -46,6 +46,10 @@ jobs:
PYTHONUSERBASE: ${{ github.workspace }}/.cache/py-user-base
PRE_COMMIT_HOME: ${{ github.workspace }}/.cache/pre-commit-cache
+ # See https://github.com/pre-commit/pre-commit/issues/2178#issuecomment-1002163763
+ # for why we set this.
+ SETUPTOOLS_USE_DISTUTILS: stdlib
+
steps:
- name: Add custom PYTHONUSERBASE to PATH
run: echo '${{ env.PYTHONUSERBASE }}/bin/' >> $GITHUB_PATH
diff --git a/README.md b/README.md
index 9df905dc8..06df4fd9a 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,6 @@
[![Lint & Test][1]][2]
[![Build][3]][4]
[![Deploy][5]][6]
-[![Coverage Status](https://coveralls.io/repos/github/python-discord/bot/badge.svg)](https://coveralls.io/github/python-discord/bot)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
This project is a Discord bot specifically for use with the Python Discord server. It provides numerous utilities
diff --git a/bot/constants.py b/bot/constants.py
index 1b713a7e3..77c01bfa3 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -445,6 +445,7 @@ class Channels(metaclass=YAMLGetter):
incidents_archive: int
mod_alerts: int
mod_meta: int
+ mods: int
nominations: int
nomination_voting: int
organisation: int
diff --git a/bot/converters.py b/bot/converters.py
index 559e759e1..3522a32aa 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -6,9 +6,9 @@ from datetime import datetime, timezone
from ssl import CertificateError
import dateutil.parser
-import dateutil.tz
import discord
from aiohttp import ClientConnectorError
+from botcore.regex import DISCORD_INVITE
from dateutil.relativedelta import relativedelta
from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, MemberConverter, UserConverter
from discord.utils import escape_markdown, snowflake_time
@@ -20,9 +20,8 @@ from bot.errors import InvalidInfraction
from bot.exts.info.doc import _inventory_parser
from bot.exts.info.tags import TagIdentifier
from bot.log import get_logger
+from bot.utils import time
from bot.utils.extensions import EXTENSIONS, unqualify
-from bot.utils.regex import INVITE_RE
-from bot.utils.time import parse_duration_string
if t.TYPE_CHECKING:
from bot.exts.info.source import SourceType
@@ -72,7 +71,7 @@ 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.match(server_invite)
+ invite_code = DISCORD_INVITE.match(server_invite)
if invite_code:
response = await ctx.bot.http_session.get(
f"{URLs.discord_invite_api}/{invite_code.group('invite')}"
@@ -338,7 +337,7 @@ class DurationDelta(Converter):
The units need to be provided in descending order of magnitude.
"""
- if not (delta := parse_duration_string(duration)):
+ if not (delta := time.parse_duration_string(duration)):
raise BadArgument(f"`{duration}` is not a valid duration string.")
return delta
@@ -454,9 +453,9 @@ class ISODateTime(Converter):
raise BadArgument(f"`{datetime_string}` is not a valid ISO-8601 datetime string")
if dt.tzinfo:
- dt = dt.astimezone(dateutil.tz.UTC)
+ dt = dt.astimezone(timezone.utc)
else: # Without a timezone, assume it represents UTC.
- dt = dt.replace(tzinfo=dateutil.tz.UTC)
+ dt = dt.replace(tzinfo=timezone.utc)
return dt
diff --git a/bot/decorators.py b/bot/decorators.py
index 048a2a09a..f4331264f 100644
--- a/bot/decorators.py
+++ b/bot/decorators.py
@@ -188,7 +188,7 @@ def respect_role_hierarchy(member_arg: function.Argument) -> t.Callable:
"""
def decorator(func: types.FunctionType) -> types.FunctionType:
@command_wraps(func)
- async def wrapper(*args, **kwargs) -> None:
+ async def wrapper(*args, **kwargs) -> t.Any:
log.trace(f"{func.__name__}: respect role hierarchy decorator called")
bound_args = function.get_bound_args(func, args, kwargs)
@@ -196,8 +196,7 @@ def respect_role_hierarchy(member_arg: function.Argument) -> t.Callable:
if not isinstance(target, Member):
log.trace("The target is not a discord.Member; skipping role hierarchy check.")
- await func(*args, **kwargs)
- return
+ return await func(*args, **kwargs)
ctx = function.get_arg_value(1, bound_args)
cmd = ctx.command.name
@@ -214,7 +213,7 @@ def respect_role_hierarchy(member_arg: function.Argument) -> t.Callable:
)
else:
log.trace(f"{func.__name__}: {target.top_role=} < {actor.top_role=}; calling func")
- await func(*args, **kwargs)
+ return await func(*args, **kwargs)
return wrapper
return decorator
diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py
index d727f7940..6cccf3680 100644
--- a/bot/exts/filters/antimalware.py
+++ b/bot/exts/filters/antimalware.py
@@ -18,14 +18,8 @@ PY_EMBED_DESCRIPTION = (
TXT_LIKE_FILES = {".txt", ".csv", ".json"}
TXT_EMBED_DESCRIPTION = (
- "**Uh-oh!** It looks like your message got zapped by our spam filter. "
- "We currently don't allow `{blocked_extension}` attachments, "
- "so here are some tips to help you travel safely: \n\n"
- "• If you attempted to send a message longer than 2000 characters, try shortening your message "
- "to fit within the character limit or use a pasting service (see below) \n\n"
- "• If you tried to show someone your code, you can use codeblocks \n(run `!code-blocks` in "
- "{cmd_channel_mention} for more information) or use a pasting service like: "
- f"\n\n{URLs.site_schema}{URLs.site_paste}"
+ "You either uploaded a `{blocked_extension}` file or entered a message that was too long. "
+ f"Please use our [paste bin]({URLs.site_schema}{URLs.site_paste}) instead."
)
DISALLOWED_EMBED_DESCRIPTION = (
diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py
index ddfd11231..bcd845a43 100644
--- a/bot/exts/filters/antispam.py
+++ b/bot/exts/filters/antispam.py
@@ -103,6 +103,7 @@ class DeletionContext:
mod_alert_message += content
await modlog.send_log_message(
+ content=", ".join(str(m.id) for m in self.members), # quality-of-life improvement for mobile moderators
icon_url=Icons.filtering,
colour=Colour(Colours.soft_red),
title="Spam detected!",
diff --git a/bot/exts/filters/filter_lists.py b/bot/exts/filters/filter_lists.py
index ee5bd89f3..a883ddf54 100644
--- a/bot/exts/filters/filter_lists.py
+++ b/bot/exts/filters/filter_lists.py
@@ -1,3 +1,4 @@
+import re
from typing import Optional
from discord import Colour, Embed
@@ -72,6 +73,18 @@ class FilterLists(Cog):
elif list_type == "FILE_FORMAT" and not content.startswith("."):
content = f".{content}"
+ # If it's a filter token, validate the passed regex
+ elif list_type == "FILTER_TOKEN":
+ try:
+ re.compile(content)
+ except re.error as e:
+ await ctx.message.add_reaction("❌")
+ await ctx.send(
+ f"{ctx.author.mention} that's not a valid regex! "
+ f"Regex error message: {e.msg}."
+ )
+ return
+
# Try to add the item to the database
log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}")
payload = {
diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py
index ad904d147..f44b28125 100644
--- a/bot/exts/filters/filtering.py
+++ b/bot/exts/filters/filtering.py
@@ -10,6 +10,7 @@ import discord.errors
import regex
import tldextract
from async_rediscache import RedisCache
+from botcore.regex import DISCORD_INVITE
from dateutil.relativedelta import relativedelta
from discord import Colour, HTTPException, Member, Message, NotFound, TextChannel
from discord.ext.commands import Cog
@@ -23,7 +24,6 @@ from bot.exts.moderation.modlog import ModLog
from bot.log import get_logger
from bot.utils import scheduling
from bot.utils.messages import format_user
-from bot.utils.regex import INVITE_RE
log = get_logger(__name__)
@@ -256,6 +256,7 @@ class Filtering(Cog):
)
await self.mod_log.send_log_message(
+ content=str(member.id), # quality-of-life improvement for mobile moderators
icon_url=Icons.token_removed,
colour=Colours.soft_red,
title="Username filtering alert",
@@ -423,9 +424,12 @@ 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
+ content = str(msg.author.id) # quality-of-life improvement for mobile moderators
+
+ # If we are going to autoban, we don't want to ping and don't need the user ID
if reason and "[autoban]" in reason:
ping_everyone = False
+ content = None
eval_msg = "using !eval " if is_eval else ""
footer = f"Reason: {reason}" if reason else None
@@ -439,6 +443,7 @@ class Filtering(Cog):
# Send pretty mod log embed to mod-alerts
await self.mod_log.send_log_message(
+ content=content,
icon_url=Icons.filtering,
colour=Colour(Colours.soft_red),
title=f"{_filter['type'].title()} triggered!",
@@ -566,7 +571,7 @@ class Filtering(Cog):
# discord\.gg/gdudes-pony-farm
text = text.replace("\\", "")
- invites = [m.group("invite") for m in INVITE_RE.finditer(text)]
+ invites = [m.group("invite") for m in DISCORD_INVITE.finditer(text)]
invite_data = dict()
for invite in invites:
if invite in invite_data:
diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py
index e43c1e789..ff9e6a347 100644
--- a/bot/exts/help_channels/_channel.py
+++ b/bot/exts/help_channels/_channel.py
@@ -1,3 +1,4 @@
+import re
import typing as t
from datetime import timedelta
from enum import Enum
@@ -16,6 +17,7 @@ log = get_logger(__name__)
MAX_CHANNELS_PER_CATEGORY = 50
EXCLUDED_CHANNELS = (constants.Channels.cooldown,)
+CLAIMED_BY_RE = re.compile(r"Channel claimed by <@!?(?P<user_id>\d{17,20})>\.$")
class ClosingReason(Enum):
@@ -157,3 +159,35 @@ async def move_to_bottom(channel: discord.TextChannel, category_id: int, **optio
# Now that the channel is moved, we can edit the other attributes
if options:
await channel.edit(**options)
+
+
+async def ensure_cached_claimant(channel: discord.TextChannel) -> None:
+ """
+ Ensure there is a claimant cached for each help channel.
+
+ Check the redis cache first, return early if there is already a claimant cached.
+ If there isn't an entry in redis, search for the "Claimed by X." embed in channel history.
+ Stopping early if we discover a dormant message first.
+
+ If a claimant could not be found, send a warning to #helpers and set the claimant to the bot.
+ """
+ if await _caches.claimants.get(channel.id):
+ return
+
+ async for message in channel.history(limit=1000):
+ if message.author.id != bot.instance.user.id:
+ # We only care about bot messages
+ continue
+ if message.embeds:
+ if _message._match_bot_embed(message, _message.DORMANT_MSG):
+ log.info("Hit the dormant message embed before finding a claimant in %s (%d).", channel, channel.id)
+ break
+ user_id = CLAIMED_BY_RE.match(message.embeds[0].description).group("user_id")
+ await _caches.claimants.set(channel.id, int(user_id))
+ return
+
+ await bot.instance.get_channel(constants.Channels.helpers).send(
+ f"I couldn't find a claimant for {channel.mention} in that last 1000 messages. "
+ "Please use your helper powers to close the channel if/when appropriate."
+ )
+ await _caches.claimants.set(channel.id, bot.instance.user.id)
diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py
index 60209ba6e..f276a7993 100644
--- a/bot/exts/help_channels/_cog.py
+++ b/bot/exts/help_channels/_cog.py
@@ -105,11 +105,36 @@ class HelpChannels(commands.Cog):
"""
Claim the channel in which the question `message` was sent.
- Move the channel to the In Use category and pin the `message`. Add a cooldown to the
- claimant to prevent them from asking another question. Lastly, make a new channel available.
+ Send an embed stating the claimant, move the channel to the In Use category, and pin the `message`.
+ Add a cooldown to the claimant to prevent them from asking another question.
+ Lastly, make a new channel available.
"""
log.info(f"Channel #{message.channel} was claimed by `{message.author.id}`.")
- await self.move_to_in_use(message.channel)
+
+ try:
+ await self.move_to_in_use(message.channel)
+ except discord.DiscordServerError:
+ try:
+ await message.channel.send(
+ "The bot encountered a Discord API error while trying to move this channel, please try again later."
+ )
+ except Exception as e:
+ log.warning("Error occurred while sending fail claim message:", exc_info=e)
+ log.info(
+ "500 error from Discord when moving #%s (%d) to in-use for %s (%d). Cancelling claim.",
+ message.channel.name,
+ message.channel.id,
+ message.author.name,
+ message.author.id,
+ )
+ self.bot.stats.incr("help.failed_claims.500_on_move")
+ return
+
+ embed = discord.Embed(
+ description=f"Channel claimed by {message.author.mention}.",
+ color=constants.Colours.bright_green,
+ )
+ await message.channel.send(embed=embed)
# Handle odd edge case of `message.author` not being a `discord.Member` (see bot#1839)
if not isinstance(message.author, discord.Member):
@@ -301,6 +326,7 @@ class HelpChannels(commands.Cog):
log.trace("Moving or rescheduling in-use channels.")
for channel in _channel.get_category_channels(self.in_use_category):
+ await _channel.ensure_cached_claimant(channel)
await self.move_idle_channel(channel, has_task=False)
# Prevent the command from being used until ready.
@@ -426,18 +452,21 @@ class HelpChannels(commands.Cog):
async def _unclaim_channel(
self,
channel: discord.TextChannel,
- claimant_id: int,
+ claimant_id: t.Optional[int],
closed_on: _channel.ClosingReason
) -> None:
"""Actual implementation of `unclaim_channel`. See that for full documentation."""
await _caches.claimants.delete(channel.id)
await _caches.session_participants.delete(channel.id)
- claimant = 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")
+ if not claimant_id:
+ log.info("No claimant given when un-claiming %s (%d). Skipping role removal.", channel, channel.id)
else:
- await members.handle_role_change(claimant, claimant.remove_roles, self.cooldown_role)
+ 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 members.handle_role_change(claimant, claimant.remove_roles, self.cooldown_role)
await _message.unpin(channel)
await _stats.report_complete_session(channel.id, closed_on)
diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py
index 1f95c460f..e616b9208 100644
--- a/bot/exts/info/information.py
+++ b/bot/exts/info/information.py
@@ -18,10 +18,10 @@ from bot.decorators import in_whitelist
from bot.errors import NonExistentRoleError
from bot.log import get_logger
from bot.pagination import LinePaginator
+from bot.utils import time
from bot.utils.channel import is_mod_channel, is_staff_channel
from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check
from bot.utils.members import get_or_fetch_member
-from bot.utils.time import TimestampFormats, discord_timestamp, humanize_delta
log = get_logger(__name__)
@@ -83,7 +83,7 @@ class Information(Cog):
defcon_info = ""
if cog := self.bot.get_cog("Defcon"):
- threshold = humanize_delta(cog.threshold) if cog.threshold else "-"
+ threshold = time.humanize_delta(cog.threshold) if cog.threshold else "-"
defcon_info = f"Defcon threshold: {threshold}\n"
verification = f"Verification level: {ctx.guild.verification_level.name}\n"
@@ -173,15 +173,13 @@ class Information(Cog):
"""Returns an embed full of server information."""
embed = Embed(colour=Colour.og_blurple(), title="Server Information")
- created = discord_timestamp(ctx.guild.created_at, TimestampFormats.RELATIVE)
+ created = time.format_relative(ctx.guild.created_at)
num_roles = len(ctx.guild.roles) - 1 # Exclude @everyone
# 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.Channels.bot_commands
):
features = f"\nFeatures: {', '.join(ctx.guild.features)}"
else:
@@ -249,7 +247,7 @@ class Information(Cog):
"""Creates an embed containing information on the `user`."""
on_server = bool(await get_or_fetch_member(ctx.guild, user.id))
- created = discord_timestamp(user.created_at, TimestampFormats.RELATIVE)
+ created = time.format_relative(user.created_at)
name = str(user)
if on_server and user.nick:
@@ -272,7 +270,7 @@ class Information(Cog):
if on_server:
if user.joined_at:
- joined = discord_timestamp(user.joined_at, TimestampFormats.RELATIVE)
+ joined = time.format_relative(user.joined_at)
else:
joined = "Unable to get join date"
@@ -472,7 +470,7 @@ class Information(Cog):
If `json` is True, send the information in a copy-pasteable Python format.
"""
- if ctx.author not in message.channel.members:
+ if not message.channel.permissions_for(ctx.author).read_messages:
await ctx.send(":x: You do not have permissions to see the channel this message is in.")
return
diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py
index 1299d5d59..eff0c13b8 100644
--- a/bot/exts/info/subscribe.py
+++ b/bot/exts/info/subscribe.py
@@ -171,7 +171,7 @@ class Subscribe(commands.Cog):
self.assignable_roles.sort(key=operator.methodcaller("is_currently_available"), reverse=True)
@commands.cooldown(1, 10, commands.BucketType.member)
- @commands.command(name="subscribe")
+ @commands.command(name="subscribe", aliases=("unsubscribe",))
@redirect_output(
destination_channel=constants.Channels.bot_commands,
bypass_roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES,
diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py
index e61ef7880..cb6836258 100644
--- a/bot/exts/moderation/clean.py
+++ b/bot/exts/moderation/clean.py
@@ -331,12 +331,17 @@ class Clean(Cog):
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."""
+ async def _modlog_cleaned_messages(
+ self,
+ messages: list[Message],
+ channels: CleanChannels,
+ ctx: Context
+ ) -> Optional[str]:
+ """Log the deleted messages to the modlog, returning the log url 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
+ return None
# Reverse the list to have reverse chronological order
log_messages = reversed(messages)
@@ -362,7 +367,7 @@ class Clean(Cog):
channel_id=Channels.mod_log,
)
- return True
+ return log_url
# endregion
@@ -375,8 +380,9 @@ class Clean(Cog):
regex: Optional[re.Pattern] = None,
first_limit: Optional[CleanLimit] = None,
second_limit: Optional[CleanLimit] = None,
- ) -> None:
- """A helper function that does the actual message cleaning."""
+ attempt_delete_invocation: bool = True,
+ ) -> Optional[str]:
+ """A helper function that does the actual message cleaning, returns the log url if logging was successful."""
self._validate_input(channels, bots_only, users, first_limit, second_limit)
# Are we already performing a clean?
@@ -384,7 +390,7 @@ class Clean(Cog):
await self._send_expiring_message(
ctx, ":x: Please wait for the currently ongoing clean operation to complete."
)
- return
+ return None
self.cleaning = True
deletion_channels = self._channels_set(channels, ctx, first_limit, second_limit)
@@ -399,8 +405,9 @@ class Clean(Cog):
# Needs to be called after standardizing the input.
predicate = self._build_predicate(first_limit, second_limit, bots_only, users, regex)
- # Delete the invocation first
- await self._delete_invocation(ctx)
+ if attempt_delete_invocation:
+ # Delete the invocation first
+ await self._delete_invocation(ctx)
if self._use_cache(first_limit):
log.trace(f"Messages for cleaning by {ctx.author.id} will be searched in the cache.")
@@ -418,7 +425,7 @@ class Clean(Cog):
if not self.cleaning:
# Means that the cleaning was canceled
- return
+ return None
# Now let's delete the actual messages with purge.
self.mod_log.ignore(Event.message_delete, *message_ids)
@@ -427,11 +434,18 @@ class Clean(Cog):
if not channels:
channels = deletion_channels
- logged = await self._modlog_cleaned_messages(deleted_messages, channels, ctx)
+ log_url = 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)
+ success_message = (
+ f"{Emojis.ok_hand} Deleted {len(deleted_messages)} messages. "
+ f"A log of the deleted messages can be found here {log_url}."
+ )
+ if log_url and is_mod_channel(ctx.channel):
+ await ctx.reply(success_message)
+ elif log_url:
+ if mods := self.bot.get_channel(Channels.mods):
+ await mods.send(f"{ctx.author.mention} {success_message}")
+ return log_url
# region: Commands
diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py
index 14db37367..178be734d 100644
--- a/bot/exts/moderation/defcon.py
+++ b/bot/exts/moderation/defcon.py
@@ -17,12 +17,9 @@ from bot.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_RO
from bot.converters import DurationDelta, Expiry
from bot.exts.moderation.modlog import ModLog
from bot.log import get_logger
-from bot.utils import scheduling
+from bot.utils import scheduling, time
from bot.utils.messages import format_user
from bot.utils.scheduling import Scheduler
-from bot.utils.time import (
- TimestampFormats, discord_timestamp, humanize_delta, parse_duration_string, relativedelta_to_timedelta
-)
log = get_logger(__name__)
@@ -88,7 +85,7 @@ class Defcon(Cog):
try:
settings = await self.defcon_settings.to_dict()
- self.threshold = parse_duration_string(settings["threshold"]) if settings.get("threshold") else None
+ self.threshold = time.parse_duration_string(settings["threshold"]) if settings.get("threshold") else None
self.expiry = datetime.fromisoformat(settings["expiry"]) if settings.get("expiry") else None
except RedisError:
log.exception("Unable to get DEFCON settings!")
@@ -102,7 +99,7 @@ class Defcon(Cog):
self.scheduler.schedule_at(self.expiry, 0, self._remove_threshold())
self._update_notifier()
- log.info(f"DEFCON synchronized: {humanize_delta(self.threshold) if self.threshold else '-'}")
+ log.info(f"DEFCON synchronized: {time.humanize_delta(self.threshold) if self.threshold else '-'}")
self._update_channel_topic()
@@ -112,7 +109,7 @@ class Defcon(Cog):
if self.threshold:
now = arrow.utcnow()
- if now - member.created_at < relativedelta_to_timedelta(self.threshold):
+ if now - member.created_at < time.relativedelta_to_timedelta(self.threshold):
log.info(f"Rejecting user {member}: Account is too new")
message_sent = False
@@ -151,11 +148,12 @@ class Defcon(Cog):
@has_any_role(*MODERATION_ROLES)
async def status(self, ctx: Context) -> None:
"""Check the current status of DEFCON mode."""
+ expiry = time.format_relative(self.expiry) if self.expiry else "-"
embed = Embed(
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 "-"}
+ **Threshold:** {time.humanize_delta(self.threshold) if self.threshold else "-"}
+ **Expires:** {expiry}
**Verification level:** {ctx.guild.verification_level.name}
"""
)
@@ -213,7 +211,8 @@ class Defcon(Cog):
def _update_channel_topic(self) -> None:
"""Update the #defcon channel topic with the current DEFCON status."""
- new_topic = f"{BASE_CHANNEL_TOPIC}\n(Threshold: {humanize_delta(self.threshold) if self.threshold else '-'})"
+ threshold = time.humanize_delta(self.threshold) if self.threshold else '-'
+ new_topic = f"{BASE_CHANNEL_TOPIC}\n(Threshold: {threshold})"
self.mod_log.ignore(Event.guild_channel_update, Channels.defcon)
scheduling.create_task(self.channel.edit(topic=new_topic))
@@ -255,12 +254,12 @@ class Defcon(Cog):
expiry_message = ""
if expiry:
- activity_duration = relativedelta(expiry, arrow.utcnow().datetime)
- expiry_message = f" for the next {humanize_delta(activity_duration, max_units=2)}"
+ formatted_expiry = time.humanize_delta(expiry, max_units=2)
+ expiry_message = f" for the next {formatted_expiry}"
if self.threshold:
channel_message = (
- f"updated; accounts must be {humanize_delta(self.threshold)} "
+ f"updated; accounts must be {time.humanize_delta(self.threshold)} "
f"old to join the server{expiry_message}"
)
else:
@@ -290,7 +289,7 @@ class Defcon(Cog):
def _log_threshold_stat(self, threshold: relativedelta) -> None:
"""Adds the threshold to the bot stats in days."""
- threshold_days = relativedelta_to_timedelta(threshold).total_seconds() / SECONDS_IN_DAY
+ threshold_days = time.relativedelta_to_timedelta(threshold).total_seconds() / SECONDS_IN_DAY
self.bot.stats.gauge("defcon.threshold", threshold_days)
async def _send_defcon_log(self, action: Action, actor: User) -> None:
@@ -298,7 +297,7 @@ class Defcon(Cog):
info = action.value
log_msg: str = (
f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n"
- f"{info.template.format(threshold=(humanize_delta(self.threshold) if self.threshold else '-'))}"
+ f"{info.template.format(threshold=(time.humanize_delta(self.threshold) if self.threshold else '-'))}"
)
status_msg = f"DEFCON {action.name.lower()}"
@@ -317,7 +316,7 @@ class Defcon(Cog):
@tasks.loop(hours=1)
async def defcon_notifier(self) -> None:
"""Routinely notify moderators that DEFCON is active."""
- await self.channel.send(f"Defcon is on and is set to {humanize_delta(self.threshold)}.")
+ await self.channel.send(f"Defcon is on and is set to {time.humanize_delta(self.threshold)}.")
def cog_unload(self) -> None:
"""Cancel the notifer and threshold removal tasks when the cog unloads."""
diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py
index 77dfad255..b579416a6 100644
--- a/bot/exts/moderation/incidents.py
+++ b/bot/exts/moderation/incidents.py
@@ -229,6 +229,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[d
),
timestamp=message.created_at
)
+ embed.set_author(name=message.author, icon_url=message.author.display_avatar.url)
embed.add_field(
name="Content",
value=shorten_text(message.content) if message.content else "[No Message Content]"
diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py
index 97d29eb60..e607bf752 100644
--- a/bot/exts/moderation/infraction/_scheduler.py
+++ b/bot/exts/moderation/infraction/_scheduler.py
@@ -136,7 +136,7 @@ class InfractionScheduler:
infr_type = infraction["type"]
icon = _utils.INFRACTION_ICONS[infr_type][0]
reason = infraction["reason"]
- expiry = time.format_infraction_with_duration(infraction["expires_at"])
+ expiry = time.format_with_duration(infraction["expires_at"])
expiry_datetime = arrow.get(infraction["expires_at"])
id_ = infraction['id']
@@ -387,20 +387,15 @@ class InfractionScheduler:
actor = infraction["actor"]
type_ = infraction["type"]
id_ = infraction["id"]
- inserted_at = infraction["inserted_at"]
- expiry = infraction["expires_at"]
log.info(f"Marking infraction #{id_} as inactive (expired).")
- expiry = dateutil.parser.isoparse(expiry) if expiry else None
- created = time.format_infraction_with_duration(inserted_at, expiry)
-
log_content = None
log_text = {
"Member": f"<@{user_id}>",
"Actor": f"<@{actor}>",
"Reason": infraction["reason"],
- "Created": created,
+ "Created": time.format_with_duration(infraction["inserted_at"], infraction["expires_at"]),
}
try:
diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py
index e683c9db4..4df833ffb 100644
--- a/bot/exts/moderation/infraction/_utils.py
+++ b/bot/exts/moderation/infraction/_utils.py
@@ -21,7 +21,7 @@ INFRACTION_ICONS = {
"note": (Icons.user_warn, None),
"superstar": (Icons.superstarify, Icons.unsuperstarify),
"warning": (Icons.user_warn, None),
- "voice_ban": (Icons.voice_state_red, Icons.voice_state_green),
+ "voice_mute": (Icons.voice_state_red, Icons.voice_state_green),
}
RULES_URL = "https://pythondiscord.com/pages/rules"
diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py
index e495a94b3..af42ab1b8 100644
--- a/bot/exts/moderation/infraction/infractions.py
+++ b/bot/exts/moderation/infraction/infractions.py
@@ -9,7 +9,7 @@ from discord.ext.commands import Context, command
from bot import constants
from bot.bot import Bot
from bot.constants import Event
-from bot.converters import Duration, Expiry, MemberOrUser, UnambiguousMemberOrUser
+from bot.converters import Age, Duration, Expiry, MemberOrUser, UnambiguousMemberOrUser
from bot.decorators import respect_role_hierarchy
from bot.exts.moderation.infraction import _utils
from bot.exts.moderation.infraction._scheduler import InfractionScheduler
@@ -19,6 +19,11 @@ from bot.utils.messages import format_user
log = get_logger(__name__)
+if t.TYPE_CHECKING:
+ from bot.exts.moderation.clean import Clean
+ from bot.exts.moderation.infraction.management import ModManagement
+ from bot.exts.moderation.watchchannels.bigbrother import BigBrother
+
class Infractions(InfractionScheduler, commands.Cog):
"""Apply and pardon infractions on users for moderation purposes."""
@@ -27,7 +32,7 @@ class Infractions(InfractionScheduler, commands.Cog):
category_description = "Server moderation tools."
def __init__(self, bot: Bot):
- super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning", "voice_ban"})
+ super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning", "voice_mute"})
self.category = "Moderation"
self._muted_role = discord.Object(constants.Roles.muted)
@@ -91,8 +96,8 @@ class Infractions(InfractionScheduler, commands.Cog):
"""
await self.apply_ban(ctx, user, reason, expires_at=duration)
- @command(aliases=('pban',))
- async def purgeban(
+ @command(aliases=("cban", "purgeban", "pban"))
+ async def cleanban(
self,
ctx: Context,
user: UnambiguousMemberOrUser,
@@ -101,14 +106,62 @@ class Infractions(InfractionScheduler, commands.Cog):
reason: t.Optional[str] = None
) -> None:
"""
- Same as ban but removes all their messages of the last 24 hours.
+ Same as ban, but also cleans all their messages from the last hour.
If duration is specified, it temporarily bans that user for the given duration.
"""
- await self.apply_ban(ctx, user, reason, 1, expires_at=duration)
+ clean_cog: t.Optional[Clean] = self.bot.get_cog("Clean")
+ if clean_cog is None:
+ # If we can't get the clean cog, fall back to native purgeban.
+ await self.apply_ban(ctx, user, reason, purge_days=1, expires_at=duration)
+ return
+
+ infraction = await self.apply_ban(ctx, user, reason, expires_at=duration)
+ if not infraction or not infraction.get("id"):
+ # Ban was unsuccessful, quit early.
+ await ctx.send(":x: Failed to apply ban.")
+ log.error("Failed to apply ban to user %d", user.id)
+ return
+
+ # Calling commands directly skips Discord.py's convertors, so we need to convert args manually.
+ clean_time = await Age().convert(ctx, "1h")
+
+ log_url = await clean_cog._clean_messages(
+ ctx,
+ users=[user],
+ channels="*",
+ first_limit=clean_time,
+ attempt_delete_invocation=False,
+ )
+ if not log_url:
+ # Cleaning failed, or there were no messages to clean, exit early.
+ return
+
+ infr_manage_cog: t.Optional[ModManagement] = self.bot.get_cog("ModManagement")
+ if infr_manage_cog is None:
+ # If we can't get the mod management cog, don't bother appending the log.
+ return
+
+ # Overwrite the context's send function so infraction append
+ # doesn't output the update infraction confirmation message.
+ async def send(*args, **kwargs) -> None:
+ pass
+ ctx.send = send
+ await infr_manage_cog.infraction_append(ctx, infraction, None, reason=f"[Clean log]({log_url})")
- @command(aliases=('vban',))
- async def voiceban(
+ @command(aliases=("vban",))
+ async def voiceban(self, ctx: Context) -> None:
+ """
+ NOT IMPLEMENTED.
+
+ Permanently ban a user from joining voice channels.
+
+ If duration is specified, it temporarily voice bans that user for the given duration.
+ """
+ await ctx.send(":x: This command is not yet implemented. Maybe you meant to use `voicemute`?")
+
+ @command(aliases=("vmute",))
+ async def voicemute(
self,
ctx: Context,
user: UnambiguousMemberOrUser,
@@ -117,11 +170,11 @@ class Infractions(InfractionScheduler, commands.Cog):
reason: t.Optional[str]
) -> None:
"""
- Permanently ban user from using voice channels.
+ Permanently mute user in voice channels.
- If duration is specified, it temporarily voice bans that user for the given duration.
+ If duration is specified, it temporarily voice mutes that user for the given duration.
"""
- await self.apply_voice_ban(ctx, user, reason, expires_at=duration)
+ await self.apply_voice_mute(ctx, user, reason, expires_at=duration)
# endregion
# region: Temporary infractions
@@ -186,16 +239,25 @@ class Infractions(InfractionScheduler, commands.Cog):
await self.apply_ban(ctx, user, reason, expires_at=duration)
@command(aliases=("tempvban", "tvban"))
- async def tempvoiceban(
- self,
- ctx: Context,
- user: UnambiguousMemberOrUser,
- duration: Expiry,
- *,
- reason: t.Optional[str]
+ async def tempvoiceban(self, ctx: Context) -> None:
+ """
+ NOT IMPLEMENTED.
+
+ Temporarily voice bans that user for the given duration.
+ """
+ await ctx.send(":x: This command is not yet implemented. Maybe you meant to use `tempvoicemute`?")
+
+ @command(aliases=("tempvmute", "tvmute"))
+ async def tempvoicemute(
+ self,
+ ctx: Context,
+ user: UnambiguousMemberOrUser,
+ duration: Expiry,
+ *,
+ reason: t.Optional[str]
) -> None:
"""
- Temporarily voice ban a user for the given reason and duration.
+ Temporarily voice mute a user for the given reason and duration.
A unit of time should be appended to the duration.
Units (∗case-sensitive):
@@ -209,7 +271,7 @@ class Infractions(InfractionScheduler, commands.Cog):
Alternatively, an ISO 8601 timestamp can be provided for the duration.
"""
- await self.apply_voice_ban(ctx, user, reason, expires_at=duration)
+ await self.apply_voice_mute(ctx, user, reason, expires_at=duration)
# endregion
# region: Permanent shadow infractions
@@ -271,9 +333,18 @@ class Infractions(InfractionScheduler, commands.Cog):
await self.pardon_infraction(ctx, "ban", user)
@command(aliases=("uvban",))
- async def unvoiceban(self, ctx: Context, user: UnambiguousMemberOrUser) -> None:
- """Prematurely end the active voice ban infraction for the user."""
- await self.pardon_infraction(ctx, "voice_ban", user)
+ async def unvoiceban(self, ctx: Context) -> None:
+ """
+ NOT IMPLEMENTED.
+
+ Temporarily voice bans that user for the given duration.
+ """
+ await ctx.send(":x: This command is not yet implemented. Maybe you meant to use `unvoicemute`?")
+
+ @command(aliases=("uvmute",))
+ async def unvoicemute(self, ctx: Context, user: UnambiguousMemberOrUser) -> None:
+ """Prematurely end the active voice mute infraction for the user."""
+ await self.pardon_infraction(ctx, "voice_mute", user)
# endregion
# region: Base apply functions
@@ -339,7 +410,7 @@ class Infractions(InfractionScheduler, commands.Cog):
reason: t.Optional[str],
purge_days: t.Optional[int] = 0,
**kwargs
- ) -> None:
+ ) -> t.Optional[dict]:
"""
Apply a ban infraction with kwargs passed to `post_infraction`.
@@ -347,7 +418,7 @@ class Infractions(InfractionScheduler, commands.Cog):
"""
if isinstance(user, Member) and user.top_role >= ctx.me.top_role:
await ctx.send(":x: I can't ban users above or equal to me in the role hierarchy.")
- return
+ return None
# In the case of a permanent ban, we don't need get_active_infractions to tell us if one is active
is_temporary = kwargs.get("expires_at") is not None
@@ -356,19 +427,19 @@ class Infractions(InfractionScheduler, commands.Cog):
if active_infraction:
if is_temporary:
log.trace("Tempban ignored as it cannot overwrite an active ban.")
- return
+ return None
if active_infraction.get('expires_at') is None:
log.trace("Permaban already exists, notify.")
await ctx.send(f":x: User is already permanently banned (#{active_infraction['id']}).")
- return
+ return None
log.trace("Old tempban is being replaced by new permaban.")
await self.pardon_infraction(ctx, "ban", user, send_msg=is_temporary)
infraction = await _utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs)
if infraction is None:
- return
+ return None
infraction["purge"] = "purge " if purge_days else ""
@@ -380,27 +451,25 @@ class Infractions(InfractionScheduler, commands.Cog):
action = ctx.guild.ban(user, reason=reason, delete_message_days=purge_days)
await self.apply_infraction(ctx, infraction, user, action)
+ bb_cog: t.Optional[BigBrother] = self.bot.get_cog("Big Brother")
if infraction.get('expires_at') is not None:
log.trace(f"Ban isn't permanent; user {user} won't be unwatched by Big Brother.")
- return
-
- bb_cog = self.bot.get_cog("Big Brother")
- if not bb_cog:
+ elif not bb_cog:
log.error(f"Big Brother cog not loaded; perma-banned user {user} won't be unwatched.")
- return
-
- log.trace(f"Big Brother cog loaded; attempting to unwatch perma-banned user {user}.")
+ else:
+ log.trace(f"Big Brother cog loaded; attempting to unwatch perma-banned user {user}.")
+ bb_reason = "User has been permanently banned from the server. Automatically removed."
+ await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False)
- bb_reason = "User has been permanently banned from the server. Automatically removed."
- await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False)
+ return infraction
@respect_role_hierarchy(member_arg=2)
- async def apply_voice_ban(self, ctx: Context, user: MemberOrUser, reason: t.Optional[str], **kwargs) -> None:
- """Apply a voice ban infraction with kwargs passed to `post_infraction`."""
- if await _utils.get_active_infraction(ctx, user, "voice_ban"):
+ async def apply_voice_mute(self, ctx: Context, user: MemberOrUser, reason: t.Optional[str], **kwargs) -> None:
+ """Apply a voice mute infraction with kwargs passed to `post_infraction`."""
+ if await _utils.get_active_infraction(ctx, user, "voice_mute"):
return
- infraction = await _utils.post_infraction(ctx, user, "voice_ban", reason, active=True, **kwargs)
+ infraction = await _utils.post_infraction(ctx, user, "voice_mute", reason, active=True, **kwargs)
if infraction is None:
return
@@ -414,7 +483,7 @@ class Infractions(InfractionScheduler, commands.Cog):
if not isinstance(user, Member):
return
- await user.move_to(None, reason="Disconnected from voice to apply voiceban.")
+ await user.move_to(None, reason="Disconnected from voice to apply voice mute.")
await user.remove_roles(self._voice_verified_role, reason=reason)
await self.apply_infraction(ctx, infraction, user, action())
@@ -471,7 +540,7 @@ class Infractions(InfractionScheduler, commands.Cog):
return log_text
- async def pardon_voice_ban(
+ async def pardon_voice_mute(
self,
user_id: int,
guild: discord.Guild,
@@ -487,9 +556,9 @@ class Infractions(InfractionScheduler, commands.Cog):
# DM user about infraction expiration
notified = await _utils.notify_pardon(
user=user,
- title="Voice ban ended",
- content="You have been unbanned and can verify yourself again in the server.",
- icon_url=_utils.INFRACTION_ICONS["voice_ban"][1]
+ title="Voice mute ended",
+ content="You have been unmuted and can verify yourself again in the server.",
+ icon_url=_utils.INFRACTION_ICONS["voice_mute"][1]
)
log_text["DM"] = "Sent" if notified else "**Failed**"
@@ -514,8 +583,8 @@ class Infractions(InfractionScheduler, commands.Cog):
return await self.pardon_mute(user_id, guild, reason, notify=notify)
elif infraction["type"] == "ban":
return await self.pardon_ban(user_id, guild, reason)
- elif infraction["type"] == "voice_ban":
- return await self.pardon_voice_ban(user_id, guild, notify=notify)
+ elif infraction["type"] == "voice_mute":
+ return await self.pardon_voice_mute(user_id, guild, notify=notify)
# endregion
diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py
index 45a629293..3a2ee7ad0 100644
--- a/bot/exts/moderation/infraction/management.py
+++ b/bot/exts/moderation/infraction/management.py
@@ -20,7 +20,6 @@ from bot.pagination import LinePaginator
from bot.utils import messages, time
from bot.utils.channel import is_mod_channel
from bot.utils.members import get_or_fetch_member
-from bot.utils.time import humanize_delta, until_expiration
log = get_logger(__name__)
@@ -155,7 +154,7 @@ class ModManagement(commands.Cog):
await ctx.send(":x: Expiration is in the past.")
return
request_data['expires_at'] = duration.isoformat()
- expiry = time.format_infraction_with_duration(request_data['expires_at'])
+ expiry = time.format_with_duration(duration)
confirm_messages.append(f"set to expire on {expiry}")
else:
confirm_messages.append("expiry unchanged")
@@ -187,8 +186,8 @@ class ModManagement(commands.Cog):
self.infractions_cog.schedule_expiration(new_infraction)
log_text += f"""
- Previous expiry: {until_expiration(infraction['expires_at']) or "Permanent"}
- New expiry: {until_expiration(new_infraction['expires_at']) or "Permanent"}
+ Previous expiry: {time.until_expiration(infraction['expires_at'])}
+ New expiry: {time.until_expiration(new_infraction['expires_at'])}
""".rstrip()
changes = ' & '.join(confirm_messages)
@@ -356,7 +355,8 @@ class ModManagement(commands.Cog):
active = infraction["active"]
user = infraction["user"]
expires_at = infraction["expires_at"]
- created = time.format_infraction(infraction["inserted_at"])
+ inserted_at = infraction["inserted_at"]
+ created = time.discord_timestamp(inserted_at)
dm_sent = infraction["dm_sent"]
# Format the user string.
@@ -369,19 +369,14 @@ class ModManagement(commands.Cog):
user_str = f"<@{user['id']}> ({name}#{user['discriminator']:04})"
if active:
- remaining = time.until_expiration(expires_at) or "Expired"
+ remaining = time.until_expiration(expires_at)
else:
remaining = "Inactive"
if expires_at is None:
duration = "*Permanent*"
else:
- date_from = arrow.Arrow.fromtimestamp(
- float(time.DISCORD_TIMESTAMP_REGEX.match(created).group(1)),
- tzutc()
- )
- date_to = arrow.get(expires_at)
- duration = humanize_delta(relativedelta(date_to.datetime, date_from.datetime))
+ duration = time.humanize_delta(inserted_at, expires_at)
# Format `dm_sent`
if dm_sent is None:
diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py
index 08c92b8f3..3f1bffd76 100644
--- a/bot/exts/moderation/infraction/superstarify.py
+++ b/bot/exts/moderation/infraction/superstarify.py
@@ -14,9 +14,9 @@ from bot.converters import Duration, Expiry
from bot.exts.moderation.infraction import _utils
from bot.exts.moderation.infraction._scheduler import InfractionScheduler
from bot.log import get_logger
+from bot.utils import time
from bot.utils.members import get_or_fetch_member
from bot.utils.messages import format_user
-from bot.utils.time import format_infraction
log = get_logger(__name__)
NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy"
@@ -57,7 +57,9 @@ class Superstarify(InfractionScheduler, Cog):
return
infraction = active_superstarifies[0]
- forced_nick = self.get_nick(infraction["id"], before.id)
+ infr_id = infraction["id"]
+
+ forced_nick = self.get_nick(infr_id, before.id)
if after.display_name == forced_nick:
return # Nick change was triggered by this event. Ignore.
@@ -67,13 +69,15 @@ class Superstarify(InfractionScheduler, Cog):
)
await after.edit(
nick=forced_nick,
- reason=f"Superstarified member tried to escape the prison: {infraction['id']}"
+ reason=f"Superstarified member tried to escape the prison: {infr_id}"
)
notified = await _utils.notify_infraction(
+ bot=self.bot,
user=after,
+ infr_id=infr_id,
infr_type="Superstarify",
- expires_at=format_infraction(infraction["expires_at"]),
+ expires_at=time.discord_timestamp(infraction["expires_at"]),
reason=(
"You have tried to change your nickname on the **Python Discord** server "
f"from **{before.display_name}** to **{after.display_name}**, but as you "
@@ -150,7 +154,7 @@ class Superstarify(InfractionScheduler, Cog):
id_ = infraction["id"]
forced_nick = self.get_nick(id_, member.id)
- expiry_str = format_infraction(infraction["expires_at"])
+ expiry_str = time.discord_timestamp(infraction["expires_at"])
# Apply the infraction
async def action() -> None:
diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py
index fc9204998..32ea0dc6a 100644
--- a/bot/exts/moderation/modlog.py
+++ b/bot/exts/moderation/modlog.py
@@ -16,8 +16,8 @@ from discord.utils import escape_markdown
from bot.bot import Bot
from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, Roles, URLs
from bot.log import get_logger
+from bot.utils import time
from bot.utils.messages import format_user
-from bot.utils.time import humanize_delta
log = get_logger(__name__)
@@ -116,7 +116,7 @@ class ModLog(Cog, name="ModLog"):
if ping_everyone:
if content:
- content = f"<@&{Roles.moderators}>\n{content}"
+ content = f"<@&{Roles.moderators}> {content}"
else:
content = f"<@&{Roles.moderators}>"
@@ -407,7 +407,7 @@ class ModLog(Cog, name="ModLog"):
now = datetime.now(timezone.utc)
difference = abs(relativedelta(now, member.created_at))
- message = format_user(member) + "\n\n**Account age:** " + humanize_delta(difference)
+ message = format_user(member) + "\n\n**Account age:** " + time.humanize_delta(difference)
if difference.days < 1 and difference.months < 1 and difference.years < 1: # New user account!
message = f"{Emojis.new} {message}"
@@ -713,7 +713,7 @@ class ModLog(Cog, name="ModLog"):
# datetime as the baseline and create a human-readable delta between this edit event
# and the last time the message was edited
timestamp = msg_before.edited_at
- delta = humanize_delta(relativedelta(msg_after.edited_at, msg_before.edited_at))
+ delta = time.humanize_delta(msg_after.edited_at, msg_before.edited_at)
footer = f"Last edited {delta} ago"
else:
# Message was not previously edited, use the created_at datetime as the baseline, no
@@ -729,6 +729,9 @@ class ModLog(Cog, name="ModLog"):
@Cog.listener()
async def on_raw_message_edit(self, event: discord.RawMessageUpdateEvent) -> None:
"""Log raw message edit event to message change log."""
+ if event.guild_id is None:
+ return # ignore DM edits
+
await self.bot.wait_until_guild_available()
try:
channel = self.bot.get_channel(int(event.data["channel_id"]))
diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py
index 20a8c39d7..b5cd29b12 100644
--- a/bot/exts/moderation/modpings.py
+++ b/bot/exts/moderation/modpings.py
@@ -11,9 +11,8 @@ from bot.bot import Bot
from bot.constants import Colours, Emojis, Guild, Icons, MODERATION_ROLES, Roles
from bot.converters import Expiry
from bot.log import get_logger
-from bot.utils import scheduling
+from bot.utils import scheduling, time
from bot.utils.scheduling import Scheduler
-from bot.utils.time import TimestampFormats, discord_timestamp
log = get_logger(__name__)
@@ -233,8 +232,8 @@ class ModPings(Cog):
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)}!"
+ f"{time.discord_timestamp(start, time.TimestampFormats.TIME)} to "
+ f"{time.discord_timestamp(end, time.TimestampFormats.TIME)}!"
)
@schedule_modpings.command(name='delete', aliases=('del', 'd'))
diff --git a/bot/exts/moderation/slowmode.py b/bot/exts/moderation/slowmode.py
index da04d1e98..b6a771441 100644
--- a/bot/exts/moderation/slowmode.py
+++ b/bot/exts/moderation/slowmode.py
@@ -39,8 +39,7 @@ class Slowmode(Cog):
if channel is None:
channel = ctx.channel
- delay = relativedelta(seconds=channel.slowmode_delay)
- humanized_delay = time.humanize_delta(delay)
+ humanized_delay = time.humanize_delta(seconds=channel.slowmode_delay)
await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.')
diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py
index 99bbd8721..985cc6eb1 100644
--- a/bot/exts/moderation/stream.py
+++ b/bot/exts/moderation/stream.py
@@ -14,9 +14,8 @@ from bot.constants import (
from bot.converters import Expiry
from bot.log import get_logger
from bot.pagination import LinePaginator
-from bot.utils import scheduling
+from bot.utils import scheduling, time
from bot.utils.members import get_or_fetch_member
-from bot.utils.time import discord_timestamp, format_infraction_with_duration
log = get_logger(__name__)
@@ -131,11 +130,15 @@ class Stream(commands.Cog):
await member.add_roles(discord.Object(Roles.video), reason="Temporary streaming access granted")
- await ctx.send(f"{Emojis.check_mark} {member.mention} can now stream until {discord_timestamp(duration)}.")
+ await ctx.send(f"{Emojis.check_mark} {member.mention} can now stream until {time.discord_timestamp(duration)}.")
# Convert here for nicer logging
- revoke_time = format_infraction_with_duration(str(duration))
- log.debug(f"Successfully gave {member} ({member.id}) permission to stream until {revoke_time}.")
+ humanized_duration = time.humanize_delta(duration, arrow.utcnow(), max_units=2)
+ end_time = duration.strftime("%Y-%m-%d %H:%M:%S")
+ log.debug(
+ f"Successfully gave {member} ({member.id}) permission "
+ f"to stream for {humanized_duration} (until {end_time})."
+ )
@commands.command(aliases=("pstream",))
@commands.has_any_role(*MODERATION_ROLES)
diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py
index a382b13d1..fa66b00dd 100644
--- a/bot/exts/moderation/voice_gate.py
+++ b/bot/exts/moderation/voice_gate.py
@@ -30,7 +30,7 @@ FAILED_MESSAGE = (
MESSAGE_FIELD_MAP = {
"joined_at": f"have been on the server for less than {GateConf.minimum_days_member} days",
- "voice_banned": "have an active voice ban infraction",
+ "voice_gate_blocked": "have an active voice infraction",
"total_messages": f"have sent less than {GateConf.minimum_messages} messages",
"activity_blocks": f"have been active for fewer than {GateConf.minimum_activity_blocks} ten-minute blocks",
}
@@ -170,7 +170,7 @@ class VoiceGate(Cog):
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"],
+ "voice_gate_blocked": data["voice_gate_blocked"],
"activity_blocks": data["activity_blocks"] < GateConf.minimum_activity_blocks,
}
diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py
index 34d445912..ee9b6ba45 100644
--- a/bot/exts/moderation/watchchannels/_watchchannel.py
+++ b/bot/exts/moderation/watchchannels/_watchchannel.py
@@ -18,9 +18,8 @@ from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE
from bot.exts.moderation.modlog import ModLog
from bot.log import CustomLogger, get_logger
from bot.pagination import LinePaginator
-from bot.utils import CogABCMeta, messages, scheduling
+from bot.utils import CogABCMeta, messages, scheduling, time
from bot.utils.members import get_or_fetch_member
-from bot.utils.time import get_time_delta
log = get_logger(__name__)
@@ -286,7 +285,7 @@ class WatchChannel(metaclass=CogABCMeta):
actor = actor.display_name if actor else self.watched_users[user_id]['actor']
inserted_at = self.watched_users[user_id]['inserted_at']
- time_delta = get_time_delta(inserted_at)
+ time_delta = time.format_relative(inserted_at)
reason = self.watched_users[user_id]['reason']
@@ -360,7 +359,7 @@ class WatchChannel(metaclass=CogABCMeta):
if member:
line += f" ({member.name}#{member.discriminator})"
inserted_at = user_data['inserted_at']
- line += f", added {get_time_delta(inserted_at)}"
+ line += f", added {time.format_relative(inserted_at)}"
if not member: # Cross off users who left the server.
line = f"~~{line}~~"
list_data["info"][user_id] = line
diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py
index 8fa0be5b1..0554bf37a 100644
--- a/bot/exts/recruitment/talentpool/_cog.py
+++ b/bot/exts/recruitment/talentpool/_cog.py
@@ -17,7 +17,6 @@ from bot.log import get_logger
from bot.pagination import LinePaginator
from bot.utils import scheduling, time
from bot.utils.members import get_or_fetch_member
-from bot.utils.time import get_time_delta
AUTOREVIEW_ENABLED_KEY = "autoreview_enabled"
REASON_MAX_CHARS = 1000
@@ -181,7 +180,7 @@ class TalentPool(Cog, name="Talentpool"):
if member:
line += f" ({member.name}#{member.discriminator})"
inserted_at = user_data['inserted_at']
- line += f", added {get_time_delta(inserted_at)}"
+ line += f", added {time.format_relative(inserted_at)}"
if not member: # Cross off users who left the server.
line = f"~~{line}~~"
if user_data['reviewed']:
@@ -260,7 +259,7 @@ class TalentPool(Cog, name="Talentpool"):
return
if len(reason) > REASON_MAX_CHARS:
- await ctx.send(f":x: Maxiumum allowed characters for the reason is {REASON_MAX_CHARS}.")
+ await ctx.send(f":x: Maximum allowed characters for the reason is {REASON_MAX_CHARS}.")
return
# Manual request with `raise_for_status` as False because we want the actual response
@@ -445,7 +444,7 @@ class TalentPool(Cog, name="Talentpool"):
async def edit_end_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None:
"""Edits the unnominate reason for the nomination with the given `id`."""
if len(reason) > REASON_MAX_CHARS:
- await ctx.send(f":x: Maxiumum allowed characters for the end reason is {REASON_MAX_CHARS}.")
+ await ctx.send(f":x: Maximum allowed characters for the end reason is {REASON_MAX_CHARS}.")
return
try:
@@ -562,7 +561,7 @@ class TalentPool(Cog, name="Talentpool"):
actor = await get_or_fetch_member(guild, actor_id)
reason = site_entry["reason"] or "*None*"
- created = time.format_infraction(site_entry["inserted_at"])
+ created = time.discord_timestamp(site_entry["inserted_at"])
entries.append(
f"Actor: {actor.mention if actor else actor_id}\nCreated: {created}\nReason: {reason}"
)
@@ -571,7 +570,7 @@ class TalentPool(Cog, name="Talentpool"):
active = nomination_object["active"]
- start_date = time.format_infraction(nomination_object["inserted_at"])
+ start_date = time.discord_timestamp(nomination_object["inserted_at"])
if active:
lines = textwrap.dedent(
f"""
@@ -585,7 +584,7 @@ class TalentPool(Cog, name="Talentpool"):
"""
)
else:
- end_date = time.format_infraction(nomination_object["ended_at"])
+ end_date = time.discord_timestamp(nomination_object["ended_at"])
lines = textwrap.dedent(
f"""
===============
diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py
index 0e7194892..b4d177622 100644
--- a/bot/exts/recruitment/talentpool/_review.py
+++ b/bot/exts/recruitment/talentpool/_review.py
@@ -17,10 +17,10 @@ from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.constants import Channels, Colours, Emojis, Guild, Roles
from bot.log import get_logger
+from bot.utils import time
from bot.utils.members import get_or_fetch_member
from bot.utils.messages import count_unique_users_reaction, pin_no_system_message
from bot.utils.scheduling import Scheduler
-from bot.utils.time import get_time_delta, time_since
if typing.TYPE_CHECKING:
from bot.exts.recruitment.talentpool._cog import TalentPool
@@ -273,7 +273,7 @@ class Reviewer:
last_channel = user_activity["top_channel_activity"][-1]
channels += f", and {last_channel[1]} in {last_channel[0]}"
- joined_at_formatted = time_since(member.joined_at)
+ joined_at_formatted = time.format_relative(member.joined_at)
review = (
f"{member.name} joined the server **{joined_at_formatted}**"
f" and has **{messages} messages**{channels}."
@@ -321,7 +321,7 @@ class Reviewer:
infractions += ", with the last infraction issued "
# Infractions were ordered by time since insertion descending.
- infractions += get_time_delta(infraction_list[0]['inserted_at'])
+ infractions += time.format_relative(infraction_list[0]['inserted_at'])
return f"They have {infractions}."
@@ -365,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']))
+ end_time = time.format_relative(history[0]['ended_at'])
review = (
f"They were nominated **{nomination_times}** before"
diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py
index 788692777..8f0094bc9 100644
--- a/bot/exts/utils/bot.py
+++ b/bot/exts/utils/bot.py
@@ -1,7 +1,6 @@
-from contextlib import suppress
from typing import Optional
-from discord import Embed, Forbidden, TextChannel, Thread
+from discord import Embed, TextChannel
from discord.ext.commands import Cog, Context, command, group, has_any_role
from bot.bot import Bot
@@ -17,20 +16,6 @@ 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/reminders.py b/bot/exts/utils/reminders.py
index 90677b2dd..ad82d49c9 100644
--- a/bot/exts/utils/reminders.py
+++ b/bot/exts/utils/reminders.py
@@ -13,13 +13,12 @@ from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Role
from bot.converters import Duration, UnambiguousUser
from bot.log import get_logger
from bot.pagination import LinePaginator
-from bot.utils import scheduling
+from bot.utils import scheduling, time
from bot.utils.checks import has_any_role_check, has_no_roles_check
from bot.utils.lock import lock_arg
from bot.utils.members import get_or_fetch_member
from bot.utils.messages import send_denial
from bot.utils.scheduling import Scheduler
-from bot.utils.time import TimestampFormats, discord_timestamp
log = get_logger(__name__)
@@ -67,20 +66,19 @@ class Reminders(Cog):
else:
self.schedule_reminder(reminder)
- def ensure_valid_reminder(self, reminder: dict) -> t.Tuple[bool, discord.User, discord.TextChannel]:
- """Ensure reminder author and channel can be fetched otherwise delete the reminder."""
- user = self.bot.get_user(reminder['author'])
+ def ensure_valid_reminder(self, reminder: dict) -> t.Tuple[bool, discord.TextChannel]:
+ """Ensure reminder channel can be fetched otherwise delete the reminder."""
channel = self.bot.get_channel(reminder['channel_id'])
is_valid = True
- if not user or not channel:
+ if not channel:
is_valid = False
log.info(
f"Reminder {reminder['id']} invalid: "
- f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}."
+ f"Channel {reminder['channel_id']}={channel}."
)
scheduling.create_task(self.bot.api_client.delete(f"bot/reminders/{reminder['id']}"))
- return is_valid, user, channel
+ return is_valid, channel
@staticmethod
async def _send_confirmation(
@@ -169,9 +167,9 @@ class Reminders(Cog):
self.schedule_reminder(reminder)
@lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True)
- async def send_reminder(self, reminder: dict, expected_time: datetime = None) -> None:
+ async def send_reminder(self, reminder: dict, expected_time: t.Optional[time.Timestamp] = None) -> None:
"""Send the reminder."""
- is_valid, user, channel = self.ensure_valid_reminder(reminder)
+ is_valid, channel = self.ensure_valid_reminder(reminder)
if not is_valid:
# No need to cancel the task too; it'll simply be done once this coroutine returns.
return
@@ -207,7 +205,7 @@ class Reminders(Cog):
f"There was an error when trying to reply to a reminder invocation message, {e}, "
"fall back to using jump_url"
)
- await channel.send(content=f"{user.mention} {additional_mentions}", embed=embed)
+ await channel.send(content=f"<@{reminder['author']}> {additional_mentions}", embed=embed)
log.debug(f"Deleting reminder #{reminder['id']} (the user has been reminded).")
await self.bot.api_client.delete(f"bot/reminders/{reminder['id']}")
@@ -310,7 +308,8 @@ class Reminders(Cog):
}
)
- mention_string = f"Your reminder will arrive on {discord_timestamp(expiration, TimestampFormats.DAY_TIME)}"
+ formatted_time = time.discord_timestamp(expiration, time.TimestampFormats.DAY_TIME)
+ mention_string = f"Your reminder will arrive on {formatted_time}"
if mentions:
mention_string += f" and will mention {len(mentions)} other(s)"
@@ -347,8 +346,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)
- time = discord_timestamp(remind_datetime, TimestampFormats.RELATIVE)
+ expiry = time.format_relative(remind_at)
mentions = ", ".join([
# Both Role and User objects have the `name` attribute
@@ -357,7 +355,7 @@ class Reminders(Cog):
mention_string = f"\n**Mentions:** {mentions}" if mentions else ""
text = textwrap.dedent(f"""
- **Reminder #{id_}:** *expires {time}* (ID: {id_}){mention_string}
+ **Reminder #{id_}:** *expires {expiry}* (ID: {id_}){mention_string}
{content}
""").strip()
diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py
index fbfc58d0b..cc3a2e1d7 100644
--- a/bot/exts/utils/snekbox.py
+++ b/bot/exts/utils/snekbox.py
@@ -7,7 +7,8 @@ from functools import partial
from signal import Signals
from typing import Optional, Tuple
-from discord import HTTPException, Message, NotFound, Reaction, User
+from botcore.regex import FORMATTED_CODE_REGEX, RAW_CODE_REGEX
+from discord import AllowedMentions, HTTPException, Message, NotFound, Reaction, User
from discord.ext.commands import Cog, Context, command, guild_only
from bot.bot import Bot
@@ -20,21 +21,6 @@ from bot.utils.messages import wait_for_deletion
log = get_logger(__name__)
ESCAPE_REGEX = re.compile("[`\u202E\u200B]{3,}")
-FORMATTED_CODE_REGEX = re.compile(
- r"(?P<delim>(?P<block>```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block
- r"(?(block)(?:(?P<lang>[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline)
- r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code
- r"(?P<code>.*?)" # extract all code inside the markup
- r"\s*" # any more whitespace before the end of the code markup
- r"(?P=delim)", # match the exact same delimiter from the start again
- re.DOTALL | re.IGNORECASE # "." also matches newlines, case insensitive
-)
-RAW_CODE_REGEX = re.compile(
- r"^(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code
- r"(?P<code>.*?)" # extract all the rest as code
- r"\s*$", # any trailing whitespace until the end of the string
- re.DOTALL # "." also matches newlines
-)
MAX_PASTE_LEN = 10000
@@ -218,7 +204,8 @@ class Snekbox(Cog):
if filter_triggered:
response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.")
else:
- response = await ctx.send(msg)
+ allowed_mentions = AllowedMentions(everyone=False, roles=False, users=[ctx.author])
+ response = await ctx.send(msg, allowed_mentions=allowed_mentions)
scheduling.create_task(wait_for_deletion(response, (ctx.author.id,)), event_loop=self.bot.loop)
log.info(f"{ctx.author}'s job had a return code of {results['returncode']}")
diff --git a/bot/exts/utils/thread_bumper.py b/bot/exts/utils/thread_bumper.py
new file mode 100644
index 000000000..35057f1fe
--- /dev/null
+++ b/bot/exts/utils/thread_bumper.py
@@ -0,0 +1,147 @@
+import typing as t
+
+import discord
+from async_rediscache import RedisCache
+from discord.ext import commands
+
+from bot import constants
+from bot.bot import Bot
+from bot.log import get_logger
+from bot.pagination import LinePaginator
+from bot.utils import channel, scheduling
+
+log = get_logger(__name__)
+
+
+class ThreadBumper(commands.Cog):
+ """Cog that allow users to add the current thread to a list that get reopened on archive."""
+
+ # RedisCache[discord.Thread.id, "sentinel"]
+ threads_to_bump = RedisCache()
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.init_task = scheduling.create_task(self.ensure_bumped_threads_are_active(), event_loop=self.bot.loop)
+
+ async def unarchive_threads_not_manually_archived(self, threads: list[discord.Thread]) -> None:
+ """
+ Iterate through and unarchive any threads that weren't manually archived recently.
+
+ This is done by extracting the manually archived threads from the audit log.
+
+ Only the last 200 thread_update logs are checked,
+ as this is assumed to be more than enough to cover bot downtime.
+ """
+ guild = self.bot.get_guild(constants.Guild.id)
+
+ recent_manually_archived_thread_ids = []
+ async for thread_update in guild.audit_logs(limit=200, action=discord.AuditLogAction.thread_update):
+ if getattr(thread_update.after, "archived", False):
+ recent_manually_archived_thread_ids.append(thread_update.target.id)
+
+ for thread in threads:
+ if thread.id in recent_manually_archived_thread_ids:
+ log.info(
+ "#%s (%d) was manually archived. Leaving archived, and removing from bumped threads.",
+ thread.name,
+ thread.id
+ )
+ await self.threads_to_bump.delete(thread.id)
+ else:
+ await thread.edit(archived=False)
+
+ async def ensure_bumped_threads_are_active(self) -> None:
+ """Ensure bumped threads are active, since threads could have been archived while the bot was down."""
+ await self.bot.wait_until_guild_available()
+
+ threads_to_maybe_bump = []
+ for thread_id, _ in await self.threads_to_bump.items():
+ try:
+ thread = await channel.get_or_fetch_channel(thread_id)
+ except discord.NotFound:
+ log.info("Thread %d has been deleted, removing from bumped threads.", thread_id)
+ await self.threads_to_bump.delete(thread_id)
+ continue
+
+ if thread.archived:
+ threads_to_maybe_bump.append(thread)
+
+ await self.unarchive_threads_not_manually_archived(threads_to_maybe_bump)
+
+ @commands.group(name="bump")
+ async def thread_bump_group(self, ctx: commands.Context) -> None:
+ """A group of commands to manage the bumping of threads."""
+ if not ctx.invoked_subcommand:
+ await ctx.send_help(ctx.command)
+
+ @thread_bump_group.command(name="add", aliases=("a",))
+ async def add_thread_to_bump_list(self, ctx: commands.Context, thread: t.Optional[discord.Thread]) -> None:
+ """Add a thread to the bump list."""
+ await self.init_task
+
+ if not thread:
+ if isinstance(ctx.channel, discord.Thread):
+ thread = ctx.channel
+ else:
+ raise commands.BadArgument("You must provide a thread, or run this command within a thread.")
+
+ if await self.threads_to_bump.contains(thread.id):
+ raise commands.BadArgument("This thread is already in the bump list.")
+
+ await self.threads_to_bump.set(thread.id, "sentinel")
+ await ctx.send(f":ok_hand:{thread.mention} has been added to the bump list.")
+
+ @thread_bump_group.command(name="remove", aliases=("r", "rem", "d", "del", "delete"))
+ async def remove_thread_from_bump_list(self, ctx: commands.Context, thread: t.Optional[discord.Thread]) -> None:
+ """Remove a thread from the bump list."""
+ await self.init_task
+
+ if not thread:
+ if isinstance(ctx.channel, discord.Thread):
+ thread = ctx.channel
+ else:
+ raise commands.BadArgument("You must provide a thread, or run this command within a thread.")
+
+ if not await self.threads_to_bump.contains(thread.id):
+ raise commands.BadArgument("This thread is not in the bump list.")
+
+ await self.threads_to_bump.delete(thread.id)
+ await ctx.send(f":ok_hand: {thread.mention} has been removed from the bump list.")
+
+ @thread_bump_group.command(name="list", aliases=("get",))
+ async def list_all_threads_in_bump_list(self, ctx: commands.Context) -> None:
+ """List all the threads in the bump list."""
+ await self.init_task
+
+ lines = [f"<#{k}>" for k, _ in await self.threads_to_bump.items()]
+ embed = discord.Embed(
+ title="Threads in the bump list",
+ colour=constants.Colours.blue
+ )
+ await LinePaginator.paginate(lines, ctx, embed)
+
+ @commands.Cog.listener()
+ async def on_thread_update(self, _: discord.Thread, after: discord.Thread) -> None:
+ """
+ Listen for thread updates and check if the thread has been archived.
+
+ If the thread has been archived, and is in the bump list, un-archive it.
+ """
+ await self.init_task
+
+ if not after.archived:
+ return
+
+ if await self.threads_to_bump.contains(after.id):
+ await self.unarchive_threads_not_manually_archived([after])
+
+ async def cog_check(self, ctx: commands.Context) -> bool:
+ """Only allow staff & partner roles to invoke the commands in this cog."""
+ return await commands.has_any_role(
+ *constants.STAFF_PARTNERS_COMMUNITY_ROLES
+ ).predicate(ctx)
+
+
+def setup(bot: Bot) -> None:
+ """Load the ThreadBumper cog."""
+ bot.add_cog(ThreadBumper(bot))
diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py
index f76eea516..2a074788e 100644
--- a/bot/exts/utils/utils.py
+++ b/bot/exts/utils/utils.py
@@ -13,8 +13,7 @@ from bot.converters import Snowflake
from bot.decorators import in_whitelist
from bot.log import get_logger
from bot.pagination import LinePaginator
-from bot.utils import messages
-from bot.utils.time import time_since
+from bot.utils import messages, time
log = get_logger(__name__)
@@ -173,7 +172,7 @@ class Utils(Cog):
lines = []
for snowflake in snowflakes:
created_at = snowflake_time(snowflake)
- lines.append(f"**{snowflake}**\nCreated at {created_at} ({time_since(created_at)}).")
+ lines.append(f"**{snowflake}**\nCreated at {created_at} ({time.format_relative(created_at)}).")
await LinePaginator.paginate(
lines,
diff --git a/bot/monkey_patches.py b/bot/monkey_patches.py
index b5c0de8d9..4840fa454 100644
--- a/bot/monkey_patches.py
+++ b/bot/monkey_patches.py
@@ -1,3 +1,4 @@
+import re
from datetime import timedelta
import arrow
@@ -5,9 +6,9 @@ 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__)
+MESSAGE_ID_RE = re.compile(r'(?P<message_id>[0-9]{15,20})$')
class Command(commands.Command):
diff --git a/bot/resources/tags/contribute.md b/bot/resources/tags/contribute.md
index 070975646..50c5cd11f 100644
--- a/bot/resources/tags/contribute.md
+++ b/bot/resources/tags/contribute.md
@@ -7,6 +7,6 @@ Looking to contribute to Open Source Projects for the first time? Want to add a
• [Site](https://github.com/python-discord/site) - resources, guides, and more
**Where to start**
-1. Read our [contributing guidelines](https://pythondiscord.com/pages/guides/pydis-guides/contributing/)
+1. Read our [contribution guide](https://pythondiscord.com/pages/guides/pydis-guides/contributing/)
2. Chat with us in <#635950537262759947> if you're ready to jump in or have any questions
3. Open an issue or ask to be assigned to an issue to work on
diff --git a/bot/resources/tags/traceback.md b/bot/resources/tags/traceback.md
index 321737aac..e21fa6c6e 100644
--- a/bot/resources/tags/traceback.md
+++ b/bot/resources/tags/traceback.md
@@ -1,18 +1,15 @@
Please provide the full traceback for your exception in order to help us identify your issue.
+While the last line of the error message tells us what kind of error you got,
+the full traceback will tell us which line, and other critical information to solve your problem.
+Please avoid screenshots so we can copy and paste parts of the message.
A full traceback could look like:
```py
Traceback (most recent call last):
- File "tiny", line 3, in
- do_something()
- File "tiny", line 2, in do_something
- a = 6 / b
-ZeroDivisionError: division by zero
+ File "my_file.py", line 5, in <module>
+ add_three("6")
+ File "my_file.py", line 2, in add_three
+ a = num + 3
+TypeError: can only concatenate str (not "int") to str
```
-The best way to read your traceback is bottom to top.
-
-• Identify the exception raised (in this case `ZeroDivisionError`)
-• Make note of the line number (in this case `2`), and navigate there in your program.
-• Try to understand why the error occurred (in this case because `b` is `0`).
-
-To read more about exceptions and errors, please refer to the [PyDis Wiki](https://pythondiscord.com/pages/guides/pydis-guides/asking-good-questions/#examining-tracebacks) or the [official Python tutorial](https://docs.python.org/3.7/tutorial/errors.html).
+If the traceback is long, use [our pastebin](https://paste.pythondiscord.com/).
diff --git a/bot/utils/regex.py b/bot/utils/regex.py
deleted file mode 100644
index 9dc1eba9d..000000000
--- a/bot/utils/regex.py
+++ /dev/null
@@ -1,15 +0,0 @@
-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"((?<!\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/scheduling.py b/bot/utils/scheduling.py
index 7b4c8e2de..23acacf74 100644
--- a/bot/utils/scheduling.py
+++ b/bot/utils/scheduling.py
@@ -5,6 +5,8 @@ import typing as t
from datetime import datetime
from functools import partial
+from arrow import Arrow
+
from bot.log import get_logger
@@ -58,7 +60,7 @@ class Scheduler:
self._scheduled_tasks[task_id] = task
self._log.debug(f"Scheduled task #{task_id} {id(task)}.")
- def schedule_at(self, time: datetime, task_id: t.Hashable, coroutine: t.Coroutine) -> None:
+ def schedule_at(self, time: t.Union[datetime, Arrow], task_id: t.Hashable, coroutine: t.Coroutine) -> None:
"""
Schedule `coroutine` to be executed at the given `time`.
diff --git a/bot/utils/time.py b/bot/utils/time.py
index eaa9b72e9..a0379c3ef 100644
--- a/bot/utils/time.py
+++ b/bot/utils/time.py
@@ -1,15 +1,12 @@
import datetime
import re
from enum import Enum
-from typing import Optional, Union
+from time import struct_time
+from typing import Literal, Optional, Union, overload
import arrow
-import dateutil.parser
from dateutil.relativedelta import relativedelta
-RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT"
-DISCORD_TIMESTAMP_REGEX = re.compile(r"<t:(\d+):f>")
-
_DURATION_REGEX = re.compile(
r"((?P<years>\d+?) ?(years|year|Y|y) ?)?"
r"((?P<months>\d+?) ?(months|month|m) ?)?"
@@ -20,8 +17,19 @@ _DURATION_REGEX = re.compile(
r"((?P<seconds>\d+?) ?(seconds|second|S|s))?"
)
-
-ValidTimestamp = Union[int, datetime.datetime, datetime.date, datetime.timedelta, relativedelta]
+# All supported types for the single-argument overload of arrow.get(). tzinfo is excluded because
+# it's too implicit of a way for the caller to specify that they want the current time.
+Timestamp = Union[
+ arrow.Arrow,
+ datetime.datetime,
+ datetime.date,
+ struct_time,
+ int, # POSIX timestamp
+ float, # POSIX timestamp
+ str, # ISO 8601-formatted string
+ tuple[int, int, int], # ISO calendar tuple
+]
+_Precision = Literal["years", "months", "days", "hours", "minutes", "seconds"]
class TimestampFormats(Enum):
@@ -42,7 +50,7 @@ class TimestampFormats(Enum):
def _stringify_time_unit(value: int, unit: str) -> str:
"""
- Returns a string to represent a value and time unit, ensuring that it uses the right plural form of the unit.
+ Return a string to represent a value and time unit, ensuring the unit's correct plural form is used.
>>> _stringify_time_unit(1, "seconds")
"1 second"
@@ -61,33 +69,140 @@ def _stringify_time_unit(value: int, unit: str) -> str:
return f"{value} {unit}"
-def discord_timestamp(timestamp: ValidTimestamp, format: TimestampFormats = TimestampFormats.DATE_TIME) -> str:
- """Create and format a Discord flavored markdown timestamp."""
- if format not in TimestampFormats:
- raise ValueError(f"Format can only be one of {', '.join(TimestampFormats.args)}, not {format}.")
+def discord_timestamp(timestamp: Timestamp, format: TimestampFormats = TimestampFormats.DATE_TIME) -> str:
+ """
+ Format a timestamp as a Discord-flavored Markdown timestamp.
+
+ `timestamp` can be any type supported by the single-arg `arrow.get()`, except for a `tzinfo`.
+ """
+ timestamp = int(arrow.get(timestamp).timestamp())
+ return f"<t:{timestamp}:{format.value}>"
+
+
+# region humanize_delta overloads
+@overload
+def humanize_delta(
+ arg1: Union[relativedelta, Timestamp],
+ /,
+ *,
+ precision: _Precision = "seconds",
+ max_units: int = 6,
+ absolute: bool = True,
+) -> str:
+ ...
+
+
+@overload
+def humanize_delta(
+ end: Timestamp,
+ start: Timestamp,
+ /,
+ *,
+ precision: _Precision = "seconds",
+ max_units: int = 6,
+ absolute: bool = True,
+) -> str:
+ ...
+
+
+@overload
+def humanize_delta(
+ *,
+ years: int = 0,
+ months: int = 0,
+ weeks: float = 0,
+ days: float = 0,
+ hours: float = 0,
+ minutes: float = 0,
+ seconds: float = 0,
+ precision: _Precision = "seconds",
+ max_units: int = 6,
+ absolute: bool = True,
+) -> str:
+ ...
+# endregion
+
+
+def humanize_delta(
+ *args,
+ precision: _Precision = "seconds",
+ max_units: int = 6,
+ absolute: bool = True,
+ **kwargs,
+) -> str:
+ """
+ Return a human-readable version of a time duration.
+
+ `precision` is the smallest unit of time to include (e.g. "seconds", "minutes").
- # Convert each possible timestamp class to an integer.
- if isinstance(timestamp, datetime.datetime):
- timestamp = (timestamp - arrow.get(0)).total_seconds()
- elif isinstance(timestamp, datetime.date):
- timestamp = (timestamp - arrow.get(0)).total_seconds()
- elif isinstance(timestamp, datetime.timedelta):
- timestamp = timestamp.total_seconds()
- elif isinstance(timestamp, relativedelta):
- timestamp = timestamp.seconds
+ `max_units` is the maximum number of units of time to include.
+ Count units from largest to smallest (e.g. count days before months).
- return f"<t:{int(timestamp)}:{format.value}>"
+ Use the absolute value of the duration if `absolute` is True.
+ Usage:
-def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6) -> str:
- """
- Returns a human-readable version of the relativedelta.
+ Keyword arguments specifying values for time units, to construct a `relativedelta` and humanize
+ the duration represented by it:
+
+ >>> humanize_delta(days=2, hours=16, seconds=23)
+ '2 days, 16 hours and 23 seconds'
+
+ **One** `relativedelta` object, to humanize the duration represented by it:
+
+ >>> humanize_delta(relativedelta(years=12, months=6))
+ '12 years and 6 months'
+
+ Note that `leapdays` and absolute info (singular names) will be ignored during humanization.
+
+ **One** timestamp of a type supported by the single-arg `arrow.get()`, except for `tzinfo`,
+ to humanize the duration between it and the current time:
+
+ >>> humanize_delta('2021-08-06T12:43:01Z', absolute=True) # now = 2021-08-06T12:33:33Z
+ '9 minutes and 28 seconds'
+
+ >>> humanize_delta('2021-08-06T12:43:01Z', absolute=False) # now = 2021-08-06T12:33:33Z
+ '-9 minutes and -28 seconds'
+
+ **Two** timestamps, each of a type supported by the single-arg `arrow.get()`, except for
+ `tzinfo`, to humanize the duration between them:
+
+ >>> humanize_delta(datetime.datetime(2020, 1, 1), '2021-01-01T12:00:00Z', absolute=False)
+ '1 year and 12 hours'
+
+ >>> humanize_delta('2021-01-01T12:00:00Z', datetime.datetime(2020, 1, 1), absolute=False)
+ '-1 years and -12 hours'
+
+ Note that order of the arguments can result in a different output even if `absolute` is True:
+
+ >>> x = datetime.datetime(3000, 11, 1)
+ >>> y = datetime.datetime(3000, 9, 2)
+ >>> humanize_delta(y, x, absolute=True), humanize_delta(x, y, absolute=True)
+ ('1 month and 30 days', '1 month and 29 days')
- precision specifies the smallest unit of time to include (e.g. "seconds", "minutes").
- max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours).
+ This is due to the nature of `relativedelta`; it does not represent a fixed period of time.
+ Instead, it's relative to the `datetime` to which it's added to get the other `datetime`.
+ In the example, the difference arises because all months don't have the same number of days.
"""
+ if args and kwargs:
+ raise ValueError("Unsupported combination of positional and keyword arguments.")
+
+ if len(args) == 0:
+ delta = relativedelta(**kwargs)
+ elif len(args) == 1 and isinstance(args[0], relativedelta):
+ delta = args[0]
+ elif len(args) <= 2:
+ end = arrow.get(args[0])
+ start = arrow.get(args[1]) if len(args) == 2 else arrow.utcnow()
+
+ delta = relativedelta(end.datetime, start.datetime)
+ if absolute:
+ delta = abs(delta)
+ else:
+ raise ValueError(f"Received {len(args)} positional arguments, but expected 1 or 2.")
+
if max_units <= 0:
- raise ValueError("max_units must be positive")
+ raise ValueError("max_units must be positive.")
units = (
("years", delta.years),
@@ -98,7 +213,7 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units:
("seconds", delta.seconds),
)
- # Add the time units that are >0, but stop at accuracy or max_units.
+ # Add the time units that are >0, but stop at precision or max_units.
time_strings = []
unit_count = 0
for unit, value in units:
@@ -109,7 +224,7 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units:
if unit == precision or unit_count >= max_units:
break
- # Add the 'and' between the last two units, if necessary
+ # Add the 'and' between the last two units, if necessary.
if len(time_strings) > 1:
time_strings[-1] = f"{time_strings[-2]} and {time_strings[-1]}"
del time_strings[-2]
@@ -123,19 +238,12 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units:
return humanized
-def get_time_delta(time_string: str) -> str:
- """Returns the time in human-readable time delta format."""
- date_time = dateutil.parser.isoparse(time_string)
- time_delta = time_since(date_time)
-
- return time_delta
-
-
def parse_duration_string(duration: str) -> Optional[relativedelta]:
"""
- Converts a `duration` string to a relativedelta object.
+ Convert a `duration` string to a relativedelta object.
+
+ The following symbols are supported for each unit of time:
- The function supports the following symbols for each unit of time:
- years: `Y`, `y`, `year`, `years`
- months: `m`, `month`, `months`
- weeks: `w`, `W`, `week`, `weeks`
@@ -143,8 +251,9 @@ def parse_duration_string(duration: str) -> Optional[relativedelta]:
- hours: `H`, `h`, `hour`, `hours`
- minutes: `M`, `minute`, `minutes`
- seconds: `S`, `s`, `second`, `seconds`
+
The units need to be provided in descending order of magnitude.
- If the string does represent a durationdelta object, it will return None.
+ Return None if the `duration` string cannot be parsed according to the symbols above.
"""
match = _DURATION_REGEX.fullmatch(duration)
if not match:
@@ -157,76 +266,63 @@ def parse_duration_string(duration: str) -> Optional[relativedelta]:
def relativedelta_to_timedelta(delta: relativedelta) -> datetime.timedelta:
- """Converts a relativedelta object to a timedelta object."""
+ """Convert a relativedelta object to a timedelta object."""
utcnow = arrow.utcnow()
return utcnow + delta - utcnow
-def time_since(past_datetime: datetime.datetime) -> str:
- """Takes a datetime and returns a discord timestamp that describes how long ago that datetime was."""
- return discord_timestamp(past_datetime, TimestampFormats.RELATIVE)
-
-
-def parse_rfc1123(stamp: str) -> datetime.datetime:
- """Parse RFC1123 time string into datetime."""
- return datetime.datetime.strptime(stamp, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc)
+def format_relative(timestamp: Timestamp) -> str:
+ """
+ Format `timestamp` as a relative Discord timestamp.
+ A relative timestamp describes how much time has elapsed since `timestamp` or how much time
+ remains until `timestamp` is reached.
-def format_infraction(timestamp: str) -> str:
- """Format an infraction timestamp to a discord timestamp."""
- return discord_timestamp(dateutil.parser.isoparse(timestamp))
+ `timestamp` can be any type supported by the single-arg `arrow.get()`, except for a `tzinfo`.
+ """
+ return discord_timestamp(timestamp, TimestampFormats.RELATIVE)
-def format_infraction_with_duration(
- date_to: Optional[str],
- date_from: Optional[datetime.datetime] = None,
+def format_with_duration(
+ timestamp: Optional[Timestamp],
+ other_timestamp: Optional[Timestamp] = None,
max_units: int = 2,
- absolute: bool = True
) -> Optional[str]:
"""
- Return `date_to` formatted as a discord timestamp with the timestamp duration since `date_from`.
+ Return `timestamp` formatted as a discord timestamp with the timestamp duration since `other_timestamp`.
+
+ `timestamp` and `other_timestamp` can be any type supported by the single-arg `arrow.get()`,
+ except for a `tzinfo`. Use the current time if `other_timestamp` is None or unspecified.
- `max_units` specifies the maximum number of units of time to include in the duration. For
- example, a value of 1 may include days but not hours.
+ `max_units` is forwarded to `time.humanize_delta`. See its documentation for more information.
- If `absolute` is True, the absolute value of the duration delta is used. This prevents negative
- values in the case that `date_to` is in the past relative to `date_from`.
+ Return None if `timestamp` is None.
"""
- if not date_to:
+ if timestamp is None:
return None
- date_to_formatted = format_infraction(date_to)
-
- date_from = date_from or datetime.datetime.now(datetime.timezone.utc)
- date_to = dateutil.parser.isoparse(date_to).replace(microsecond=0)
+ if other_timestamp is None:
+ other_timestamp = arrow.utcnow()
- delta = relativedelta(date_to, date_from)
- if absolute:
- delta = abs(delta)
+ formatted_timestamp = discord_timestamp(timestamp)
+ duration = humanize_delta(timestamp, other_timestamp, max_units=max_units)
- duration = humanize_delta(delta, max_units=max_units)
- duration_formatted = f" ({duration})" if duration else ""
+ return f"{formatted_timestamp} ({duration})"
- return f"{date_to_formatted}{duration_formatted}"
-
-def until_expiration(
- expiry: Optional[str]
-) -> Optional[str]:
+def until_expiration(expiry: Optional[Timestamp]) -> str:
"""
- Get the remaining time until infraction's expiration, in a discord timestamp.
+ Get the remaining time until an infraction's expiration as a Discord timestamp.
- 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
+ `expiry` can be any type supported by the single-arg `arrow.get()`, except for a `tzinfo`.
- now = arrow.utcnow()
- since = dateutil.parser.isoparse(expiry).replace(microsecond=0)
+ Return "Permanent" if `expiry` is None. Return "Expired" if `expiry` is in the past.
+ """
+ if expiry is None:
+ return "Permanent"
- if since < now:
- return None
+ expiry = arrow.get(expiry)
+ if expiry < arrow.utcnow():
+ return "Expired"
- return discord_timestamp(since, TimestampFormats.RELATIVE)
+ return format_relative(expiry)
diff --git a/docker-compose.yml b/docker-compose.yml
index 869d9acb6..ce78f65aa 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -38,6 +38,7 @@ services:
metricity:
<< : *logging
+ << : *restart_policy
restart: on-failure # USE_METRICITY=false will stop the container, so this ensures it only restarts on error
depends_on:
postgres:
diff --git a/poetry.lock b/poetry.lock
index 68eebf8de..6d3bd44bb 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -114,29 +114,17 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "attrs"
-version = "21.2.0"
+version = "21.4.0"
description = "Classes Without Boilerplate"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[package.extras]
-dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"]
+dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"]
docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
-tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"]
-tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"]
-
-[[package]]
-name = "backports.entry-points-selectable"
-version = "1.1.1"
-description = "Compatibility shim providing selectable entry points for older implementations"
-category = "dev"
-optional = false
-python-versions = ">=2.7"
-
-[package.extras]
-docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
-testing = ["pytest", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"]
+tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
+tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"]
[[package]]
name = "beautifulsoup4"
@@ -154,6 +142,21 @@ html5lib = ["html5lib"]
lxml = ["lxml"]
[[package]]
+name = "bot-core"
+version = "1.2.0"
+description = "Bot-Core provides the core functionality and utilities for the bots of the Python Discord community."
+category = "main"
+optional = false
+python-versions = "3.9.*"
+
+[package.dependencies]
+"discord.py" = {url = "https://github.com/Rapptz/discord.py/archive/45d498c1b76deaf3b394d17ccf56112fa691d160.zip"}
+
+[package.source]
+type = "url"
+url = "https://github.com/python-discord/bot-core/archive/511bcba1b0196cd498c707a525ea56921bd971db.zip"
+
+[[package]]
name = "certifi"
version = "2021.10.8"
description = "Python package for providing Mozilla's CA Bundle."
@@ -190,7 +193,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "charset-normalizer"
-version = "2.0.9"
+version = "2.0.10"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
category = "main"
optional = false
@@ -233,22 +236,6 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
toml = ["toml"]
[[package]]
-name = "coveralls"
-version = "2.2.0"
-description = "Show coverage stats online via coveralls.io"
-category = "dev"
-optional = false
-python-versions = ">= 3.5"
-
-[package.dependencies]
-coverage = ">=4.1,<6.0"
-docopt = ">=0.6.1"
-requests = ">=1.0.0"
-
-[package.extras]
-yaml = ["PyYAML (>=3.10)"]
-
-[[package]]
name = "deepdiff"
version = "4.3.2"
description = "Deep Difference and Search of any Python object/data."
@@ -295,6 +282,7 @@ 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.4"
@@ -304,14 +292,6 @@ optional = false
python-versions = "*"
[[package]]
-name = "docopt"
-version = "0.6.2"
-description = "Pythonic argument parser, that will make you smile"
-category = "dev"
-optional = false
-python-versions = "*"
-
-[[package]]
name = "emoji"
version = "0.6.0"
description = "Emoji for Python"
@@ -364,11 +344,11 @@ sgmllib3k = "*"
[[package]]
name = "filelock"
-version = "3.4.0"
+version = "3.4.2"
description = "A platform independent file lock."
category = "main"
optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
[package.extras]
docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"]
@@ -506,7 +486,7 @@ pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_ve
[[package]]
name = "identify"
-version = "2.4.0"
+version = "2.4.2"
description = "File identification library for Python"
category = "dev"
optional = false
@@ -669,11 +649,11 @@ test = ["docutils", "pytest-cov", "pytest-pycodestyle", "pytest-runner"]
[[package]]
name = "platformdirs"
-version = "2.4.0"
+version = "2.4.1"
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
category = "dev"
optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
[package.extras]
docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"]
@@ -709,7 +689,7 @@ virtualenv = ">=20.0.8"
[[package]]
name = "psutil"
-version = "5.8.0"
+version = "5.9.0"
description = "Cross-platform lib for process and system monitoring in Python."
category = "dev"
optional = false
@@ -952,7 +932,7 @@ python-versions = "*"
[[package]]
name = "requests"
-version = "2.26.0"
+version = "2.27.1"
description = "Python HTTP for Humans."
category = "main"
optional = false
@@ -1115,7 +1095,7 @@ python-versions = ">=3.6"
[[package]]
name = "urllib3"
-version = "1.26.7"
+version = "1.26.8"
description = "HTTP library with thread-safe connection pooling, file post, and more."
category = "main"
optional = false
@@ -1128,14 +1108,13 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[[package]]
name = "virtualenv"
-version = "20.10.0"
+version = "20.13.0"
description = "Virtual Python Environment builder"
category = "dev"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
[package.dependencies]
-"backports.entry-points-selectable" = ">=1.0.4"
distlib = ">=0.3.1,<1"
filelock = ">=3.2,<4"
platformdirs = ">=2,<3"
@@ -1168,7 +1147,7 @@ multidict = ">=4.0"
[metadata]
lock-version = "1.1"
python-versions = "3.9.*"
-content-hash = "14ad70153b8c2f4a7e8492bf89f60bf7c468a939da36ce62871b677495f75302"
+content-hash = "0248fc7488c79af0cdb3a6db9528f4c3129db50b3a8d1dd3ba57dbc31b381c31"
[metadata.files]
aio-pika = [
@@ -1243,17 +1222,14 @@ atomicwrites = [
{file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
]
attrs = [
- {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"},
- {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"},
-]
-"backports.entry-points-selectable" = [
- {file = "backports.entry_points_selectable-1.1.1-py2.py3-none-any.whl", hash = "sha256:7fceed9532a7aa2bd888654a7314f864a3c16a4e710b34a58cfc0f08114c663b"},
- {file = "backports.entry_points_selectable-1.1.1.tar.gz", hash = "sha256:914b21a479fde881635f7af5adc7f6e38d6b274be32269070c53b698c60d5386"},
+ {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"},
+ {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"},
]
beautifulsoup4 = [
{file = "beautifulsoup4-4.10.0-py3-none-any.whl", hash = "sha256:9a315ce70049920ea4572a4055bc4bd700c940521d36fc858205ad4fcde149bf"},
{file = "beautifulsoup4-4.10.0.tar.gz", hash = "sha256:c23ad23c521d818955a4151a67d81580319d4bf548d3d49f4223ae041ff98891"},
]
+bot-core = []
certifi = [
{file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"},
{file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"},
@@ -1319,8 +1295,8 @@ chardet = [
{file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"},
]
charset-normalizer = [
- {file = "charset-normalizer-2.0.9.tar.gz", hash = "sha256:b0b883e8e874edfdece9c28f314e3dd5badf067342e42fb162203335ae61aa2c"},
- {file = "charset_normalizer-2.0.9-py3-none-any.whl", hash = "sha256:1eecaa09422db5be9e29d7fc65664e6c33bd06f9ced7838578ba40d58bdf3721"},
+ {file = "charset-normalizer-2.0.10.tar.gz", hash = "sha256:876d180e9d7432c5d1dfd4c5d26b72f099d503e8fcc0feb7532c9289be60fcbd"},
+ {file = "charset_normalizer-2.0.10-py3-none-any.whl", hash = "sha256:cb957888737fc0bbcd78e3df769addb41fd1ff8cf950dc9e7ad7793f1bf44455"},
]
colorama = [
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
@@ -1384,10 +1360,6 @@ coverage = [
{file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"},
{file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"},
]
-coveralls = [
- {file = "coveralls-2.2.0-py2.py3-none-any.whl", hash = "sha256:2301a19500b06649d2ec4f2858f9c69638d7699a4c63027c5d53daba666147cc"},
- {file = "coveralls-2.2.0.tar.gz", hash = "sha256:b990ba1f7bc4288e63340be0433698c1efe8217f78c689d254c2540af3d38617"},
-]
deepdiff = [
{file = "deepdiff-4.3.2-py3-none-any.whl", hash = "sha256:59fc1e3e7a28dd0147b0f2b00e3e27181f0f0ef4286b251d5f214a5bcd9a9bc4"},
{file = "deepdiff-4.3.2.tar.gz", hash = "sha256:91360be1d9d93b1d9c13ae9c5048fa83d9cff17a88eb30afaa0d7ff2d0fee17d"},
@@ -1401,9 +1373,6 @@ distlib = [
{file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"},
{file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"},
]
-docopt = [
- {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"},
-]
emoji = [
{file = "emoji-0.6.0.tar.gz", hash = "sha256:e42da4f8d648f8ef10691bc246f682a1ec6b18373abfd9be10ec0b398823bd11"},
]
@@ -1420,8 +1389,8 @@ feedparser = [
{file = "feedparser-6.0.8.tar.gz", hash = "sha256:5ce0410a05ab248c8c7cfca3a0ea2203968ee9ff4486067379af4827a59f9661"},
]
filelock = [
- {file = "filelock-3.4.0-py3-none-any.whl", hash = "sha256:2e139a228bcf56dd8b2274a65174d005c4a6b68540ee0bdbb92c76f43f29f7e8"},
- {file = "filelock-3.4.0.tar.gz", hash = "sha256:93d512b32a23baf4cac44ffd72ccf70732aeff7b8050fcaf6d3ec406d954baf4"},
+ {file = "filelock-3.4.2-py3-none-any.whl", hash = "sha256:cf0fc6a2f8d26bd900f19bf33915ca70ba4dd8c56903eeb14e1e7a2fd7590146"},
+ {file = "filelock-3.4.2.tar.gz", hash = "sha256:38b4f4c989f9d06d44524df1b24bd19e167d851f19b50bf3e3559952dddc5b80"},
]
flake8 = [
{file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"},
@@ -1506,8 +1475,8 @@ humanfriendly = [
{file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"},
]
identify = [
- {file = "identify-2.4.0-py2.py3-none-any.whl", hash = "sha256:eba31ca80258de6bb51453084bff4a923187cd2193b9c13710f2516ab30732cc"},
- {file = "identify-2.4.0.tar.gz", hash = "sha256:a33ae873287e81651c7800ca309dc1f84679b763c9c8b30680e16fbfa82f0107"},
+ {file = "identify-2.4.2-py2.py3-none-any.whl", hash = "sha256:67c1e66225870dce721228176637a8ef965e8dd58450bcc7592249d0dfc4da6c"},
+ {file = "identify-2.4.2.tar.gz", hash = "sha256:93e8ec965e888f2212aa5c24b2b662f4832c39acb1d7196a70ea45acb626a05e"},
]
idna = [
{file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
@@ -1697,8 +1666,8 @@ pip-licenses = [
{file = "pip_licenses-3.5.3-py3-none-any.whl", hash = "sha256:59c148d6a03784bf945d232c0dc0e9de4272a3675acaa0361ad7712398ca86ba"},
]
platformdirs = [
- {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"},
- {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"},
+ {file = "platformdirs-2.4.1-py3-none-any.whl", hash = "sha256:1d7385c7db91728b83efd0ca99a5afb296cab9d0ed8313a45ed8ba17967ecfca"},
+ {file = "platformdirs-2.4.1.tar.gz", hash = "sha256:440633ddfebcc36264232365d7840a970e75e1018d15b4327d11f91909045fda"},
]
pluggy = [
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
@@ -1709,34 +1678,38 @@ pre-commit = [
{file = "pre_commit-2.16.0.tar.gz", hash = "sha256:fe9897cac830aa7164dbd02a4e7b90cae49630451ce88464bca73db486ba9f65"},
]
psutil = [
- {file = "psutil-5.8.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:0066a82f7b1b37d334e68697faba68e5ad5e858279fd6351c8ca6024e8d6ba64"},
- {file = "psutil-5.8.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:0ae6f386d8d297177fd288be6e8d1afc05966878704dad9847719650e44fc49c"},
- {file = "psutil-5.8.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:12d844996d6c2b1d3881cfa6fa201fd635971869a9da945cf6756105af73d2df"},
- {file = "psutil-5.8.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:02b8292609b1f7fcb34173b25e48d0da8667bc85f81d7476584d889c6e0f2131"},
- {file = "psutil-5.8.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:6ffe81843131ee0ffa02c317186ed1e759a145267d54fdef1bc4ea5f5931ab60"},
- {file = "psutil-5.8.0-cp27-none-win32.whl", hash = "sha256:ea313bb02e5e25224e518e4352af4bf5e062755160f77e4b1767dd5ccb65f876"},
- {file = "psutil-5.8.0-cp27-none-win_amd64.whl", hash = "sha256:5da29e394bdedd9144c7331192e20c1f79283fb03b06e6abd3a8ae45ffecee65"},
- {file = "psutil-5.8.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:74fb2557d1430fff18ff0d72613c5ca30c45cdbfcddd6a5773e9fc1fe9364be8"},
- {file = "psutil-5.8.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:74f2d0be88db96ada78756cb3a3e1b107ce8ab79f65aa885f76d7664e56928f6"},
- {file = "psutil-5.8.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:99de3e8739258b3c3e8669cb9757c9a861b2a25ad0955f8e53ac662d66de61ac"},
- {file = "psutil-5.8.0-cp36-cp36m-win32.whl", hash = "sha256:36b3b6c9e2a34b7d7fbae330a85bf72c30b1c827a4366a07443fc4b6270449e2"},
- {file = "psutil-5.8.0-cp36-cp36m-win_amd64.whl", hash = "sha256:52de075468cd394ac98c66f9ca33b2f54ae1d9bff1ef6b67a212ee8f639ec06d"},
- {file = "psutil-5.8.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c6a5fd10ce6b6344e616cf01cc5b849fa8103fbb5ba507b6b2dee4c11e84c935"},
- {file = "psutil-5.8.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:61f05864b42fedc0771d6d8e49c35f07efd209ade09a5afe6a5059e7bb7bf83d"},
- {file = "psutil-5.8.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:0dd4465a039d343925cdc29023bb6960ccf4e74a65ad53e768403746a9207023"},
- {file = "psutil-5.8.0-cp37-cp37m-win32.whl", hash = "sha256:1bff0d07e76114ec24ee32e7f7f8d0c4b0514b3fae93e3d2aaafd65d22502394"},
- {file = "psutil-5.8.0-cp37-cp37m-win_amd64.whl", hash = "sha256:fcc01e900c1d7bee2a37e5d6e4f9194760a93597c97fee89c4ae51701de03563"},
- {file = "psutil-5.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6223d07a1ae93f86451d0198a0c361032c4c93ebd4bf6d25e2fb3edfad9571ef"},
- {file = "psutil-5.8.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d225cd8319aa1d3c85bf195c4e07d17d3cd68636b8fc97e6cf198f782f99af28"},
- {file = "psutil-5.8.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:28ff7c95293ae74bf1ca1a79e8805fcde005c18a122ca983abf676ea3466362b"},
- {file = "psutil-5.8.0-cp38-cp38-win32.whl", hash = "sha256:ce8b867423291cb65cfc6d9c4955ee9bfc1e21fe03bb50e177f2b957f1c2469d"},
- {file = "psutil-5.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:90f31c34d25b1b3ed6c40cdd34ff122b1887a825297c017e4cbd6796dd8b672d"},
- {file = "psutil-5.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6323d5d845c2785efb20aded4726636546b26d3b577aded22492908f7c1bdda7"},
- {file = "psutil-5.8.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:245b5509968ac0bd179287d91210cd3f37add77dad385ef238b275bad35fa1c4"},
- {file = "psutil-5.8.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:90d4091c2d30ddd0a03e0b97e6a33a48628469b99585e2ad6bf21f17423b112b"},
- {file = "psutil-5.8.0-cp39-cp39-win32.whl", hash = "sha256:ea372bcc129394485824ae3e3ddabe67dc0b118d262c568b4d2602a7070afdb0"},
- {file = "psutil-5.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:f4634b033faf0d968bb9220dd1c793b897ab7f1189956e1aa9eae752527127d3"},
- {file = "psutil-5.8.0.tar.gz", hash = "sha256:0c9ccb99ab76025f2f0bbecf341d4656e9c1351db8cc8a03ccd62e318ab4b5c6"},
+ {file = "psutil-5.9.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:55ce319452e3d139e25d6c3f85a1acf12d1607ddedea5e35fb47a552c051161b"},
+ {file = "psutil-5.9.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:7336292a13a80eb93c21f36bde4328aa748a04b68c13d01dfddd67fc13fd0618"},
+ {file = "psutil-5.9.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:cb8d10461c1ceee0c25a64f2dd54872b70b89c26419e147a05a10b753ad36ec2"},
+ {file = "psutil-5.9.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:7641300de73e4909e5d148e90cc3142fb890079e1525a840cf0dfd39195239fd"},
+ {file = "psutil-5.9.0-cp27-none-win32.whl", hash = "sha256:ea42d747c5f71b5ccaa6897b216a7dadb9f52c72a0fe2b872ef7d3e1eacf3ba3"},
+ {file = "psutil-5.9.0-cp27-none-win_amd64.whl", hash = "sha256:ef216cc9feb60634bda2f341a9559ac594e2eeaadd0ba187a4c2eb5b5d40b91c"},
+ {file = "psutil-5.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:90a58b9fcae2dbfe4ba852b57bd4a1dded6b990a33d6428c7614b7d48eccb492"},
+ {file = "psutil-5.9.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff0d41f8b3e9ebb6b6110057e40019a432e96aae2008951121ba4e56040b84f3"},
+ {file = "psutil-5.9.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:742c34fff804f34f62659279ed5c5b723bb0195e9d7bd9907591de9f8f6558e2"},
+ {file = "psutil-5.9.0-cp310-cp310-win32.whl", hash = "sha256:8293942e4ce0c5689821f65ce6522ce4786d02af57f13c0195b40e1edb1db61d"},
+ {file = "psutil-5.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:9b51917c1af3fa35a3f2dabd7ba96a2a4f19df3dec911da73875e1edaf22a40b"},
+ {file = "psutil-5.9.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e9805fed4f2a81de98ae5fe38b75a74c6e6ad2df8a5c479594c7629a1fe35f56"},
+ {file = "psutil-5.9.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c51f1af02334e4b516ec221ee26b8fdf105032418ca5a5ab9737e8c87dafe203"},
+ {file = "psutil-5.9.0-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32acf55cb9a8cbfb29167cd005951df81b567099295291bcfd1027365b36591d"},
+ {file = "psutil-5.9.0-cp36-cp36m-win32.whl", hash = "sha256:e5c783d0b1ad6ca8a5d3e7b680468c9c926b804be83a3a8e95141b05c39c9f64"},
+ {file = "psutil-5.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:d62a2796e08dd024b8179bd441cb714e0f81226c352c802fca0fd3f89eeacd94"},
+ {file = "psutil-5.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3d00a664e31921009a84367266b35ba0aac04a2a6cad09c550a89041034d19a0"},
+ {file = "psutil-5.9.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7779be4025c540d1d65a2de3f30caeacc49ae7a2152108adeaf42c7534a115ce"},
+ {file = "psutil-5.9.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:072664401ae6e7c1bfb878c65d7282d4b4391f1bc9a56d5e03b5a490403271b5"},
+ {file = "psutil-5.9.0-cp37-cp37m-win32.whl", hash = "sha256:df2c8bd48fb83a8408c8390b143c6a6fa10cb1a674ca664954de193fdcab36a9"},
+ {file = "psutil-5.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1d7b433519b9a38192dfda962dd8f44446668c009833e1429a52424624f408b4"},
+ {file = "psutil-5.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c3400cae15bdb449d518545cbd5b649117de54e3596ded84aacabfbb3297ead2"},
+ {file = "psutil-5.9.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2237f35c4bbae932ee98902a08050a27821f8f6dfa880a47195e5993af4702d"},
+ {file = "psutil-5.9.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1070a9b287846a21a5d572d6dddd369517510b68710fca56b0e9e02fd24bed9a"},
+ {file = "psutil-5.9.0-cp38-cp38-win32.whl", hash = "sha256:76cebf84aac1d6da5b63df11fe0d377b46b7b500d892284068bacccf12f20666"},
+ {file = "psutil-5.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:3151a58f0fbd8942ba94f7c31c7e6b310d2989f4da74fcbf28b934374e9bf841"},
+ {file = "psutil-5.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:539e429da49c5d27d5a58e3563886057f8fc3868a5547b4f1876d9c0f007bccf"},
+ {file = "psutil-5.9.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58c7d923dc209225600aec73aa2c4ae8ea33b1ab31bc11ef8a5933b027476f07"},
+ {file = "psutil-5.9.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3611e87eea393f779a35b192b46a164b1d01167c9d323dda9b1e527ea69d697d"},
+ {file = "psutil-5.9.0-cp39-cp39-win32.whl", hash = "sha256:4e2fb92e3aeae3ec3b7b66c528981fd327fb93fd906a77215200404444ec1845"},
+ {file = "psutil-5.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:7d190ee2eaef7831163f254dc58f6d2e2a22e27382b936aab51c835fc080c3d3"},
+ {file = "psutil-5.9.0.tar.gz", hash = "sha256:869842dbd66bb80c3217158e629d6fceaecc3a3166d3d1faee515b05dd26ca25"},
]
ptable = [
{file = "PTable-0.9.2.tar.gz", hash = "sha256:aa7fc151cb40f2dabcd2275ba6f7fd0ff8577a86be3365cd3fb297cbe09cc292"},
@@ -1963,8 +1936,8 @@ regex = [
{file = "regex-2021.4.4.tar.gz", hash = "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb"},
]
requests = [
- {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"},
- {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"},
+ {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"},
+ {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"},
]
requests-file = [
{file = "requests-file-1.5.1.tar.gz", hash = "sha256:07d74208d3389d01c38ab89ef403af0cfec63957d53a0081d8eca738d0247d8e"},
@@ -2018,12 +1991,12 @@ typing-extensions = [
{file = "typing_extensions-4.0.1.tar.gz", hash = "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e"},
]
urllib3 = [
- {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"},
- {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"},
+ {file = "urllib3-1.26.8-py2.py3-none-any.whl", hash = "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed"},
+ {file = "urllib3-1.26.8.tar.gz", hash = "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c"},
]
virtualenv = [
- {file = "virtualenv-20.10.0-py2.py3-none-any.whl", hash = "sha256:4b02e52a624336eece99c96e3ab7111f469c24ba226a53ec474e8e787b365814"},
- {file = "virtualenv-20.10.0.tar.gz", hash = "sha256:576d05b46eace16a9c348085f7d0dc8ef28713a2cabaa1cf0aea41e8f12c9218"},
+ {file = "virtualenv-20.13.0-py2.py3-none-any.whl", hash = "sha256:339f16c4a86b44240ba7223d0f93a7887c3ca04b5f9c8129da7958447d079b09"},
+ {file = "virtualenv-20.13.0.tar.gz", hash = "sha256:d8458cf8d59d0ea495ad9b34c2599487f8a7772d796f9910858376d1600dd2dd"},
]
wrapt = [
{file = "wrapt-1.13.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:e05e60ff3b2b0342153be4d1b597bbcfd8330890056b9619f4ad6b8d5c96a81a"},
diff --git a/pyproject.toml b/pyproject.toml
index 928435975..c764910c2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,6 +8,8 @@ license = "MIT"
[tool.poetry.dependencies]
python = "3.9.*"
"discord.py" = {url = "https://github.com/Rapptz/discord.py/archive/45d498c1b76deaf3b394d17ccf56112fa691d160.zip"}
+# See https://bot-core.pythondiscord.com/ for docs.
+bot-core = {url = "https://github.com/python-discord/bot-core/archive/511bcba1b0196cd498c707a525ea56921bd971db.zip"}
aio-pika = "~=6.1"
aiodns = "~=2.0"
aiohttp = "~=3.7"
@@ -34,7 +36,6 @@ tldextract = "^3.1.2"
[tool.poetry.dev-dependencies]
coverage = "~=5.0"
-coveralls = "~=2.1"
flake8 = "~=3.8"
flake8-annotations = "~=2.0"
flake8-bugbear = "~=20.1"
diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py
index 30e5258fb..d896b7652 100644
--- a/tests/bot/exts/info/test_information.py
+++ b/tests/bot/exts/info/test_information.py
@@ -1,6 +1,7 @@
import textwrap
import unittest
import unittest.mock
+from datetime import datetime
import discord
@@ -288,6 +289,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
user.nick = None
user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock")
user.colour = 0
+ user.created_at = user.joined_at = datetime.utcnow()
embed = await self.cog.create_user_embed(ctx, user, False)
@@ -309,6 +311,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
user.nick = "Cat lover"
user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock")
user.colour = 0
+ user.created_at = user.joined_at = datetime.utcnow()
embed = await self.cog.create_user_embed(ctx, user, False)
@@ -329,6 +332,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
# A `MockMember` has the @Everyone role by default; we add the Admins to that.
user = helpers.MockMember(roles=[admins_role], colour=100)
+ user.created_at = user.joined_at = datetime.utcnow()
embed = await self.cog.create_user_embed(ctx, user, False)
@@ -355,6 +359,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
nomination_counts.return_value = ("Nominations", "nomination info")
user = helpers.MockMember(id=314, roles=[moderators_role], colour=100)
+ user.created_at = user.joined_at = datetime.utcfromtimestamp(1)
embed = await self.cog.create_user_embed(ctx, user, False)
infraction_counts.assert_called_once_with(user)
@@ -394,6 +399,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
user_messages.return_value = ("Messages", "user message counts")
user = helpers.MockMember(id=314, roles=[moderators_role], colour=100)
+ user.created_at = user.joined_at = datetime.utcfromtimestamp(1)
embed = await self.cog.create_user_embed(ctx, user, False)
infraction_counts.assert_called_once_with(user)
@@ -440,6 +446,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
moderators_role = helpers.MockRole(name='Moderators')
user = helpers.MockMember(id=314, roles=[moderators_role], colour=100)
+ user.created_at = user.joined_at = datetime.utcnow()
embed = await self.cog.create_user_embed(ctx, user, False)
self.assertEqual(embed.colour, discord.Colour(100))
@@ -457,6 +464,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
ctx = helpers.MockContext()
user = helpers.MockMember(id=217, colour=discord.Colour.default())
+ user.created_at = user.joined_at = datetime.utcnow()
embed = await self.cog.create_user_embed(ctx, user, False)
self.assertEqual(embed.colour, discord.Colour.og_blurple())
@@ -474,6 +482,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
ctx = helpers.MockContext()
user = helpers.MockMember(id=217, colour=0)
+ user.created_at = user.joined_at = datetime.utcnow()
user.display_avatar.url = "avatar url"
embed = await self.cog.create_user_embed(ctx, user, False)
diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py
index 4d01e18a5..052048053 100644
--- a/tests/bot/exts/moderation/infraction/test_infractions.py
+++ b/tests/bot/exts/moderation/infraction/test_infractions.py
@@ -1,13 +1,15 @@
import inspect
import textwrap
import unittest
-from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch
+from unittest.mock import ANY, AsyncMock, DEFAULT, MagicMock, Mock, patch
from discord.errors import NotFound
from bot.constants import Event
+from bot.exts.moderation.clean import Clean
from bot.exts.moderation.infraction import _utils
from bot.exts.moderation.infraction.infractions import Infractions
+from bot.exts.moderation.infraction.management import ModManagement
from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockUser, autospec
@@ -62,8 +64,8 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase):
@patch("bot.exts.moderation.infraction.infractions.constants.Roles.voice_verified", new=123456)
-class VoiceBanTests(unittest.IsolatedAsyncioTestCase):
- """Tests for voice ban related functions and commands."""
+class VoiceMuteTests(unittest.IsolatedAsyncioTestCase):
+ """Tests for voice mute related functions and commands."""
def setUp(self):
self.bot = MockBot()
@@ -73,59 +75,59 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):
self.ctx = MockContext(bot=self.bot, author=self.mod)
self.cog = Infractions(self.bot)
- async def test_permanent_voice_ban(self):
- """Should call voice ban applying function without expiry."""
- self.cog.apply_voice_ban = AsyncMock()
- self.assertIsNone(await self.cog.voiceban(self.cog, self.ctx, self.user, reason="foobar"))
- self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at=None)
+ async def test_permanent_voice_mute(self):
+ """Should call voice mute applying function without expiry."""
+ self.cog.apply_voice_mute = AsyncMock()
+ self.assertIsNone(await self.cog.voicemute(self.cog, self.ctx, self.user, reason="foobar"))
+ self.cog.apply_voice_mute.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at=None)
- async def test_temporary_voice_ban(self):
- """Should call voice ban applying function with expiry."""
- self.cog.apply_voice_ban = AsyncMock()
- self.assertIsNone(await self.cog.tempvoiceban(self.cog, self.ctx, self.user, "baz", reason="foobar"))
- self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at="baz")
+ async def test_temporary_voice_mute(self):
+ """Should call voice mute applying function with expiry."""
+ self.cog.apply_voice_mute = AsyncMock()
+ self.assertIsNone(await self.cog.tempvoicemute(self.cog, self.ctx, self.user, "baz", reason="foobar"))
+ self.cog.apply_voice_mute.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at="baz")
- async def test_voice_unban(self):
+ async def test_voice_unmute(self):
"""Should call infraction pardoning function."""
self.cog.pardon_infraction = AsyncMock()
- self.assertIsNone(await self.cog.unvoiceban(self.cog, self.ctx, self.user))
- self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_ban", self.user)
+ self.assertIsNone(await self.cog.unvoicemute(self.cog, self.ctx, self.user))
+ self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_mute", self.user)
@patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")
@patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction")
- async def test_voice_ban_user_have_active_infraction(self, get_active_infraction, post_infraction_mock):
- """Should return early when user already have Voice Ban infraction."""
+ async def test_voice_mute_user_have_active_infraction(self, get_active_infraction, post_infraction_mock):
+ """Should return early when user already have Voice Mute infraction."""
get_active_infraction.return_value = {"foo": "bar"}
- self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar"))
- get_active_infraction.assert_awaited_once_with(self.ctx, self.user, "voice_ban")
+ self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, "foobar"))
+ get_active_infraction.assert_awaited_once_with(self.ctx, self.user, "voice_mute")
post_infraction_mock.assert_not_awaited()
@patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")
@patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction")
- async def test_voice_ban_infraction_post_failed(self, get_active_infraction, post_infraction_mock):
+ async def test_voice_mute_infraction_post_failed(self, get_active_infraction, post_infraction_mock):
"""Should return early when posting infraction fails."""
self.cog.mod_log.ignore = MagicMock()
get_active_infraction.return_value = None
post_infraction_mock.return_value = None
- self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar"))
+ self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, "foobar"))
post_infraction_mock.assert_awaited_once()
self.cog.mod_log.ignore.assert_not_called()
@patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")
@patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction")
- async def test_voice_ban_infraction_post_add_kwargs(self, get_active_infraction, post_infraction_mock):
- """Should pass all kwargs passed to apply_voice_ban to post_infraction."""
+ async def test_voice_mute_infraction_post_add_kwargs(self, get_active_infraction, post_infraction_mock):
+ """Should pass all kwargs passed to apply_voice_mute to post_infraction."""
get_active_infraction.return_value = None
# We don't want that this continue yet
post_infraction_mock.return_value = None
- self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar", my_kwarg=23))
+ self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, "foobar", my_kwarg=23))
post_infraction_mock.assert_awaited_once_with(
- self.ctx, self.user, "voice_ban", "foobar", active=True, my_kwarg=23
+ self.ctx, self.user, "voice_mute", "foobar", active=True, my_kwarg=23
)
@patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")
@patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction")
- async def test_voice_ban_mod_log_ignore(self, get_active_infraction, post_infraction_mock):
+ async def test_voice_mute_mod_log_ignore(self, get_active_infraction, post_infraction_mock):
"""Should ignore Voice Verified role removing."""
self.cog.mod_log.ignore = MagicMock()
self.cog.apply_infraction = AsyncMock()
@@ -134,11 +136,11 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):
get_active_infraction.return_value = None
post_infraction_mock.return_value = {"foo": "bar"}
- self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar"))
+ self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, "foobar"))
self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id)
async def action_tester(self, action, reason: str) -> None:
- """Helper method to test voice ban action."""
+ """Helper method to test voice mute action."""
self.assertTrue(inspect.iscoroutine(action))
await action
@@ -147,7 +149,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):
@patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")
@patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction")
- async def test_voice_ban_apply_infraction(self, get_active_infraction, post_infraction_mock):
+ async def test_voice_mute_apply_infraction(self, get_active_infraction, post_infraction_mock):
"""Should ignore Voice Verified role removing."""
self.cog.mod_log.ignore = MagicMock()
self.cog.apply_infraction = AsyncMock()
@@ -156,22 +158,22 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):
post_infraction_mock.return_value = {"foo": "bar"}
reason = "foobar"
- self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, reason))
+ self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, reason))
self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, ANY)
await self.action_tester(self.cog.apply_infraction.call_args[0][-1], reason)
@patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")
@patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction")
- async def test_voice_ban_truncate_reason(self, get_active_infraction, post_infraction_mock):
- """Should truncate reason for voice ban."""
+ async def test_voice_mute_truncate_reason(self, get_active_infraction, post_infraction_mock):
+ """Should truncate reason for voice mute."""
self.cog.mod_log.ignore = MagicMock()
self.cog.apply_infraction = AsyncMock()
get_active_infraction.return_value = None
post_infraction_mock.return_value = {"foo": "bar"}
- self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar" * 3000))
+ self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, "foobar" * 3000))
self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, ANY)
# Test action
@@ -180,14 +182,14 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):
@autospec(_utils, "post_infraction", "get_active_infraction", return_value=None)
@autospec(Infractions, "apply_infraction")
- async def test_voice_ban_user_left_guild(self, apply_infraction_mock, post_infraction_mock, _):
- """Should voice ban user that left the guild without throwing an error."""
+ async def test_voice_mute_user_left_guild(self, apply_infraction_mock, post_infraction_mock, _):
+ """Should voice mute user that left the guild without throwing an error."""
infraction = {"foo": "bar"}
post_infraction_mock.return_value = {"foo": "bar"}
user = MockUser()
- await self.cog.voiceban(self.cog, self.ctx, user, reason=None)
- post_infraction_mock.assert_called_once_with(self.ctx, user, "voice_ban", None, active=True, expires_at=None)
+ await self.cog.voicemute(self.cog, self.ctx, user, reason=None)
+ post_infraction_mock.assert_called_once_with(self.ctx, user, "voice_mute", None, active=True, expires_at=None)
apply_infraction_mock.assert_called_once_with(self.cog, self.ctx, infraction, user, ANY)
# Test action
@@ -195,22 +197,22 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):
self.assertTrue(inspect.iscoroutine(action))
await action
- async def test_voice_unban_user_not_found(self):
+ async def test_voice_unmute_user_not_found(self):
"""Should include info to return dict when user was not found from guild."""
self.guild.get_member.return_value = None
self.guild.fetch_member.side_effect = NotFound(Mock(status=404), "Not found")
- result = await self.cog.pardon_voice_ban(self.user.id, self.guild)
+ result = await self.cog.pardon_voice_mute(self.user.id, self.guild)
self.assertEqual(result, {"Info": "User was not found in the guild."})
@patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon")
@patch("bot.exts.moderation.infraction.infractions.format_user")
- async def test_voice_unban_user_found(self, format_user_mock, notify_pardon_mock):
+ async def test_voice_unmute_user_found(self, format_user_mock, notify_pardon_mock):
"""Should add role back with ignoring, notify user and return log dictionary.."""
self.guild.get_member.return_value = self.user
notify_pardon_mock.return_value = True
format_user_mock.return_value = "my-user"
- result = await self.cog.pardon_voice_ban(self.user.id, self.guild)
+ result = await self.cog.pardon_voice_mute(self.user.id, self.guild)
self.assertEqual(result, {
"Member": "my-user",
"DM": "Sent"
@@ -219,15 +221,100 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):
@patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon")
@patch("bot.exts.moderation.infraction.infractions.format_user")
- async def test_voice_unban_dm_fail(self, format_user_mock, notify_pardon_mock):
+ async def test_voice_unmute_dm_fail(self, format_user_mock, notify_pardon_mock):
"""Should add role back with ignoring, notify user and return log dictionary.."""
self.guild.get_member.return_value = self.user
notify_pardon_mock.return_value = False
format_user_mock.return_value = "my-user"
- result = await self.cog.pardon_voice_ban(self.user.id, self.guild)
+ result = await self.cog.pardon_voice_mute(self.user.id, self.guild)
self.assertEqual(result, {
"Member": "my-user",
"DM": "**Failed**"
})
notify_pardon_mock.assert_awaited_once()
+
+
+class CleanBanTests(unittest.IsolatedAsyncioTestCase):
+ """Tests for cleanban functionality."""
+
+ def setUp(self):
+ self.bot = MockBot()
+ self.mod = MockMember(roles=[MockRole(id=7890123, position=10)])
+ self.user = MockMember(roles=[MockRole(id=123456, position=1)])
+ self.guild = MockGuild()
+ self.ctx = MockContext(bot=self.bot, author=self.mod)
+ self.cog = Infractions(self.bot)
+ self.clean_cog = Clean(self.bot)
+ self.management_cog = ModManagement(self.bot)
+
+ self.cog.apply_ban = AsyncMock(return_value={"id": 42})
+ self.log_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
+ self.clean_cog._clean_messages = AsyncMock(return_value=self.log_url)
+
+ def mock_get_cog(self, enable_clean, enable_manage):
+ """Mock get cog factory that allows the user to specify whether clean and manage cogs are enabled."""
+ def inner(name):
+ if name == "ModManagement":
+ return self.management_cog if enable_manage else None
+ elif name == "Clean":
+ return self.clean_cog if enable_clean else None
+ else:
+ return DEFAULT
+ return inner
+
+ async def test_cleanban_falls_back_to_native_purge_without_clean_cog(self):
+ """Should fallback to native purge if the Clean cog is not available."""
+ self.bot.get_cog.side_effect = self.mock_get_cog(False, False)
+
+ self.assertIsNone(await self.cog.cleanban(self.cog, self.ctx, self.user, None, reason="FooBar"))
+ self.cog.apply_ban.assert_awaited_once_with(
+ self.ctx,
+ self.user,
+ "FooBar",
+ purge_days=1,
+ expires_at=None,
+ )
+
+ async def test_cleanban_doesnt_purge_messages_if_clean_cog_available(self):
+ """Cleanban command should use the native purge messages if the clean cog is available."""
+ self.bot.get_cog.side_effect = self.mock_get_cog(True, False)
+
+ self.assertIsNone(await self.cog.cleanban(self.cog, self.ctx, self.user, None, reason="FooBar"))
+ self.cog.apply_ban.assert_awaited_once_with(
+ self.ctx,
+ self.user,
+ "FooBar",
+ expires_at=None,
+ )
+
+ @patch("bot.exts.moderation.infraction.infractions.Age")
+ async def test_cleanban_uses_clean_cog_when_available(self, mocked_age_converter):
+ """Test cleanban uses the clean cog to clean messages if it's available."""
+ self.bot.api_client.patch = AsyncMock()
+ self.bot.get_cog.side_effect = self.mock_get_cog(True, False)
+
+ mocked_age_converter.return_value.convert = AsyncMock(return_value="81M")
+ self.assertIsNone(await self.cog.cleanban(self.cog, self.ctx, self.user, None, reason="FooBar"))
+
+ self.clean_cog._clean_messages.assert_awaited_once_with(
+ self.ctx,
+ users=[self.user],
+ channels="*",
+ first_limit="81M",
+ attempt_delete_invocation=False,
+ )
+
+ async def test_cleanban_edits_infraction_reason(self):
+ """Ensure cleanban edits the ban reason with a link to the clean log."""
+ self.bot.get_cog.side_effect = self.mock_get_cog(True, True)
+
+ self.management_cog.infraction_append = AsyncMock()
+ self.assertIsNone(await self.cog.cleanban(self.cog, self.ctx, self.user, None, reason="FooBar"))
+
+ self.management_cog.infraction_append.assert_awaited_once_with(
+ self.ctx,
+ {"id": 42},
+ None,
+ reason=f"[Clean log]({self.log_url})"
+ )
diff --git a/tests/bot/exts/moderation/test_clean.py b/tests/bot/exts/moderation/test_clean.py
new file mode 100644
index 000000000..d7647fa48
--- /dev/null
+++ b/tests/bot/exts/moderation/test_clean.py
@@ -0,0 +1,104 @@
+import unittest
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from bot.exts.moderation.clean import Clean
+from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockMessage, MockRole, MockTextChannel
+
+
+class CleanTests(unittest.IsolatedAsyncioTestCase):
+ """Tests for clean cog functionality."""
+
+ def setUp(self):
+ self.bot = MockBot()
+ self.mod = MockMember(roles=[MockRole(id=7890123, position=10)])
+ self.user = MockMember(roles=[MockRole(id=123456, position=1)])
+ self.guild = MockGuild()
+ self.ctx = MockContext(bot=self.bot, author=self.mod)
+ self.cog = Clean(self.bot)
+
+ self.log_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
+ self.cog._modlog_cleaned_messages = AsyncMock(return_value=self.log_url)
+
+ self.cog._use_cache = MagicMock(return_value=True)
+ self.cog._delete_found = AsyncMock(return_value=[42, 84])
+
+ @patch("bot.exts.moderation.clean.is_mod_channel")
+ async def test_clean_deletes_invocation_in_non_mod_channel(self, mod_channel_check):
+ """Clean command should delete the invocation message if ran in a non mod channel."""
+ mod_channel_check.return_value = False
+ self.ctx.message.delete = AsyncMock()
+
+ self.assertIsNone(await self.cog._delete_invocation(self.ctx))
+
+ self.ctx.message.delete.assert_awaited_once()
+
+ @patch("bot.exts.moderation.clean.is_mod_channel")
+ async def test_clean_doesnt_delete_invocation_in_mod_channel(self, mod_channel_check):
+ """Clean command should not delete the invocation message if ran in a mod channel."""
+ mod_channel_check.return_value = True
+ self.ctx.message.delete = AsyncMock()
+
+ self.assertIsNone(await self.cog._delete_invocation(self.ctx))
+
+ self.ctx.message.delete.assert_not_awaited()
+
+ async def test_clean_doesnt_attempt_deletion_when_attempt_delete_invocation_is_false(self):
+ """Clean command should not attempt to delete the invocation message if attempt_delete_invocation is false."""
+ self.cog._delete_invocation = AsyncMock()
+ self.bot.get_channel = MagicMock(return_value=False)
+
+ self.assertEqual(
+ await self.cog._clean_messages(
+ self.ctx,
+ None,
+ first_limit=MockMessage(),
+ attempt_delete_invocation=False,
+ ),
+ self.log_url,
+ )
+
+ self.cog._delete_invocation.assert_not_awaited()
+
+ @patch("bot.exts.moderation.clean.is_mod_channel")
+ async def test_clean_replies_with_success_message_when_ran_in_mod_channel(self, mod_channel_check):
+ """Clean command should reply to the message with a confirmation message if invoked in a mod channel."""
+ mod_channel_check.return_value = True
+ self.ctx.reply = AsyncMock()
+
+ self.assertEqual(
+ await self.cog._clean_messages(
+ self.ctx,
+ None,
+ first_limit=MockMessage(),
+ attempt_delete_invocation=False,
+ ),
+ self.log_url,
+ )
+
+ self.ctx.reply.assert_awaited_once()
+ sent_message = self.ctx.reply.await_args[0][0]
+ self.assertIn(self.log_url, sent_message)
+ self.assertIn("2 messages", sent_message)
+
+ @patch("bot.exts.moderation.clean.is_mod_channel")
+ async def test_clean_send_success_message_to_mods_when_ran_in_non_mod_channel(self, mod_channel_check):
+ """Clean command should send a confirmation message to #mods if invoked in a non-mod channel."""
+ mod_channel_check.return_value = False
+ mocked_mods = MockTextChannel(id=1234567)
+ mocked_mods.send = AsyncMock()
+ self.bot.get_channel = MagicMock(return_value=mocked_mods)
+
+ self.assertEqual(
+ await self.cog._clean_messages(
+ self.ctx,
+ None,
+ first_limit=MockMessage(),
+ attempt_delete_invocation=False,
+ ),
+ self.log_url,
+ )
+
+ mocked_mods.send.assert_awaited_once()
+ sent_message = mocked_mods.send.await_args[0][0]
+ self.assertIn(self.log_url, sent_message)
+ self.assertIn("2 messages", sent_message)
diff --git a/tests/bot/exts/utils/test_snekbox.py b/tests/bot/exts/utils/test_snekbox.py
index 321a92445..8bdeedd27 100644
--- a/tests/bot/exts/utils/test_snekbox.py
+++ b/tests/bot/exts/utils/test_snekbox.py
@@ -2,6 +2,7 @@ import asyncio
import unittest
from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, patch
+from discord import AllowedMentions
from discord.ext import commands
from bot import constants
@@ -201,7 +202,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
ctx = MockContext()
ctx.message = MockMessage()
ctx.send = AsyncMock()
- ctx.author.mention = '@LemonLemonishBeard#0042'
+ ctx.author = MockUser(mention='@LemonLemonishBeard#0042')
self.cog.post_eval = AsyncMock(return_value={'stdout': '', 'returncode': 0})
self.cog.get_results_message = MagicMock(return_value=('Return code 0', ''))
@@ -213,9 +214,16 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
self.bot.get_cog.return_value = mocked_filter_cog
await self.cog.send_eval(ctx, 'MyAwesomeCode')
- ctx.send.assert_called_once_with(
+
+ ctx.send.assert_called_once()
+ self.assertEqual(
+ ctx.send.call_args.args[0],
'@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```\n[No output]\n```'
)
+ allowed_mentions = ctx.send.call_args.kwargs['allowed_mentions']
+ expected_allowed_mentions = AllowedMentions(everyone=False, roles=False, users=[ctx.author])
+ self.assertEqual(allowed_mentions.to_dict(), expected_allowed_mentions.to_dict())
+
self.cog.post_eval.assert_called_once_with('MyAwesomeCode')
self.cog.get_status_emoji.assert_called_once_with({'stdout': '', 'returncode': 0})
self.cog.get_results_message.assert_called_once_with({'stdout': '', 'returncode': 0})
@@ -238,10 +246,14 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
self.bot.get_cog.return_value = mocked_filter_cog
await self.cog.send_eval(ctx, 'MyAwesomeCode')
- ctx.send.assert_called_once_with(
+
+ ctx.send.assert_called_once()
+ self.assertEqual(
+ ctx.send.call_args.args[0],
'@LemonLemonishBeard#0042 :yay!: Return code 0.'
'\n\n```\nWay too long beard\n```\nFull output: lookatmybeard.com'
)
+
self.cog.post_eval.assert_called_once_with('MyAwesomeCode')
self.cog.get_status_emoji.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0})
self.cog.get_results_message.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0})
@@ -263,9 +275,13 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
self.bot.get_cog.return_value = mocked_filter_cog
await self.cog.send_eval(ctx, 'MyAwesomeCode')
- ctx.send.assert_called_once_with(
+
+ ctx.send.assert_called_once()
+ self.assertEqual(
+ ctx.send.call_args.args[0],
'@LemonLemonishBeard#0042 :nope!: Return code 127.\n\n```\nBeard got stuck in the eval\n```'
)
+
self.cog.post_eval.assert_called_once_with('MyAwesomeCode')
self.cog.get_status_emoji.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127})
self.cog.get_results_message.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127})
diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py
index a3dcbfc0a..120d65176 100644
--- a/tests/bot/utils/test_time.py
+++ b/tests/bot/utils/test_time.py
@@ -13,13 +13,15 @@ class TimeTests(unittest.TestCase):
"""humanize_delta should be able to handle unknown units, and will not abort."""
# Does not abort for unknown units, as the unit name is checked
# against the attribute of the relativedelta instance.
- self.assertEqual(time.humanize_delta(relativedelta(days=2, hours=2), 'elephants', 2), '2 days and 2 hours')
+ actual = time.humanize_delta(relativedelta(days=2, hours=2), precision='elephants', max_units=2)
+ self.assertEqual(actual, '2 days and 2 hours')
def test_humanize_delta_handle_high_units(self):
"""humanize_delta should be able to handle very high units."""
# Very high maximum units, but it only ever iterates over
# each value the relativedelta might have.
- self.assertEqual(time.humanize_delta(relativedelta(days=2, hours=2), 'hours', 20), '2 days and 2 hours')
+ actual = time.humanize_delta(relativedelta(days=2, hours=2), precision='hours', max_units=20)
+ self.assertEqual(actual, '2 days and 2 hours')
def test_humanize_delta_should_normal_usage(self):
"""Testing humanize delta."""
@@ -32,7 +34,8 @@ class TimeTests(unittest.TestCase):
for delta, precision, max_units, expected in test_cases:
with self.subTest(delta=delta, precision=precision, max_units=max_units, expected=expected):
- self.assertEqual(time.humanize_delta(delta, precision, max_units), expected)
+ actual = time.humanize_delta(delta, precision=precision, max_units=max_units)
+ self.assertEqual(actual, expected)
def test_humanize_delta_raises_for_invalid_max_units(self):
"""humanize_delta should raises ValueError('max_units must be positive') for invalid max_units."""
@@ -40,22 +43,11 @@ class TimeTests(unittest.TestCase):
for max_units in test_cases:
with self.subTest(max_units=max_units), self.assertRaises(ValueError) as error:
- time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units)
- self.assertEqual(str(error.exception), 'max_units must be positive')
-
- def test_parse_rfc1123(self):
- """Testing parse_rfc1123."""
- self.assertEqual(
- time.parse_rfc1123('Sun, 15 Sep 2019 12:00:00 GMT'),
- datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
- )
-
- def test_format_infraction(self):
- """Testing format_infraction."""
- self.assertEqual(time.format_infraction('2019-12-12T00:01:00Z'), '<t:1576108860:f>')
+ time.humanize_delta(relativedelta(days=2, hours=2), precision='hours', max_units=max_units)
+ self.assertEqual(str(error.exception), 'max_units must be positive.')
- def test_format_infraction_with_duration_none_expiry(self):
- """format_infraction_with_duration should work for None expiry."""
+ def test_format_with_duration_none_expiry(self):
+ """format_with_duration should work for None expiry."""
test_cases = (
(None, None, None, None),
@@ -67,10 +59,10 @@ class TimeTests(unittest.TestCase):
for expiry, date_from, max_units, expected in test_cases:
with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected):
- self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected)
+ self.assertEqual(time.format_with_duration(expiry, date_from, max_units), expected)
- def test_format_infraction_with_duration_custom_units(self):
- """format_infraction_with_duration should work for custom max_units."""
+ def test_format_with_duration_custom_units(self):
+ """format_with_duration should work for custom max_units."""
test_cases = (
('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)'),
@@ -80,10 +72,10 @@ class TimeTests(unittest.TestCase):
for expiry, date_from, max_units, expected in test_cases:
with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected):
- self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected)
+ self.assertEqual(time.format_with_duration(expiry, date_from, max_units), expected)
- def test_format_infraction_with_duration_normal_usage(self):
- """format_infraction_with_duration should work for normal usage, across various durations."""
+ def test_format_with_duration_normal_usage(self):
+ """format_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, tzinfo=utc), 2,
@@ -105,11 +97,11 @@ class TimeTests(unittest.TestCase):
for expiry, date_from, max_units, expected in test_cases:
with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected):
- self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected)
+ self.assertEqual(time.format_with_duration(expiry, date_from, max_units), expected)
def test_until_expiration_with_duration_none_expiry(self):
- """until_expiration should work for None expiry."""
- self.assertEqual(time.until_expiration(None), None)
+ """until_expiration should return "Permanent" is expiry is None."""
+ self.assertEqual(time.until_expiration(None), "Permanent")
def test_until_expiration_with_duration_custom_units(self):
"""until_expiration should work for custom max_units."""
@@ -130,7 +122,6 @@ class TimeTests(unittest.TestCase):
('3000-12-12T00:00:00Z', '<t:32533488000:R>'),
('3000-11-23T20:09:00Z', '<t:32531918940:R>'),
('3000-11-23T20:09:00Z', '<t:32531918940:R>'),
- (None, None),
)
for expiry, expected in test_cases: