aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar ToxicKidz <[email protected]>2022-02-23 23:10:15 -0500
committerGravatar GitHub <[email protected]>2022-02-23 23:10:15 -0500
commitbab827a6126685ab26215a8cf32802933216b43e (patch)
treeef0fc2a0225a08be8a184e2c89def2cd417ea139
parentFixup: remove extra blank line (diff)
parentMerge pull request #2101 from python-discord/fix/help-channels-attribute-error (diff)
Merge branch 'main' into feature/nonpinging-helper-notify
-rw-r--r--README.md1
-rw-r--r--bot/constants.py1
-rw-r--r--bot/decorators.py7
-rw-r--r--bot/exts/filters/antispam.py1
-rw-r--r--bot/exts/filters/filter_lists.py13
-rw-r--r--bot/exts/filters/filtering.py7
-rw-r--r--bot/exts/help_channels/_channel.py35
-rw-r--r--bot/exts/help_channels/_cog.py35
-rw-r--r--bot/exts/info/information.py2
-rw-r--r--bot/exts/info/subscribe.py2
-rw-r--r--bot/exts/moderation/clean.py42
-rw-r--r--bot/exts/moderation/incidents.py1
-rw-r--r--bot/exts/moderation/infraction/infractions.py78
-rw-r--r--bot/exts/moderation/modlog.py5
-rw-r--r--bot/exts/moderation/stream.py8
-rw-r--r--bot/exts/utils/bot.py17
-rw-r--r--bot/exts/utils/reminders.py15
-rw-r--r--bot/exts/utils/thread_bumper.py147
-rw-r--r--bot/resources/tags/traceback.md21
-rw-r--r--poetry.lock34
-rw-r--r--pyproject.toml1
-rw-r--r--tests/bot/exts/moderation/infraction/test_infractions.py89
-rw-r--r--tests/bot/exts/moderation/test_clean.py104
23 files changed, 545 insertions, 121 deletions
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 ecb1ed81b..b775848fb 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/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/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 1f83acf9b..f44b28125 100644
--- a/bot/exts/filters/filtering.py
+++ b/bot/exts/filters/filtering.py
@@ -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!",
diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py
index 940868245..d9cebf215 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,36 @@ 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
+ # Only set the claimant if the first embed matches the claimed channel embed regex
+ if match := CLAIMED_BY_RE.match(message.embeds[0].description):
+ await _caches.claimants.set(channel.id, int(match.group("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 0fd631a6e..a93acffb6 100644
--- a/bot/exts/help_channels/_cog.py
+++ b/bot/exts/help_channels/_cog.py
@@ -114,14 +114,31 @@ class HelpChannels(commands.Cog):
"""
log.info(f"Channel #{message.channel} was claimed by `{message.author.id}`.")
+ 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)
- await self.move_to_in_use(message.channel)
-
# Handle odd edge case of `message.author` not being a `discord.Member` (see bot#1839)
if not isinstance(message.author, discord.Member):
log.debug(f"{message.author} ({message.author.id}) isn't a member. Not giving cooldown role or sending DM.")
@@ -320,6 +337,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.
@@ -445,18 +463,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 5b25fd0c3..e616b9208 100644
--- a/bot/exts/info/information.py
+++ b/bot/exts/info/information.py
@@ -470,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/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/infractions.py b/bot/exts/moderation/infraction/infractions.py
index 7c0259b8e..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."""
@@ -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,11 +106,48 @@ 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(self, ctx: Context) -> None:
@@ -368,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`.
@@ -376,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
@@ -385,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 ""
@@ -409,19 +451,17 @@ 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_mute(self, ctx: Context, user: MemberOrUser, reason: t.Optional[str], **kwargs) -> None:
diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py
index 2c01a4a21..32ea0dc6a 100644
--- a/bot/exts/moderation/modlog.py
+++ b/bot/exts/moderation/modlog.py
@@ -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}>"
@@ -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/stream.py b/bot/exts/moderation/stream.py
index 4dccc8a7e..985cc6eb1 100644
--- a/bot/exts/moderation/stream.py
+++ b/bot/exts/moderation/stream.py
@@ -133,8 +133,12 @@ class Stream(commands.Cog):
await ctx.send(f"{Emojis.check_mark} {member.mention} can now stream until {time.discord_timestamp(duration)}.")
# Convert here for nicer logging
- revoke_time = time.format_with_duration(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/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 289d00356..ad82d49c9 100644
--- a/bot/exts/utils/reminders.py
+++ b/bot/exts/utils/reminders.py
@@ -66,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(
@@ -170,7 +169,7 @@ class Reminders(Cog):
@lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True)
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
@@ -206,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']}")
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/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/poetry.lock b/poetry.lock
index 9c9cc97ad..6d3bd44bb 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -155,6 +155,7 @@ python-versions = "3.9.*"
[package.source]
type = "url"
url = "https://github.com/python-discord/bot-core/archive/511bcba1b0196cd498c707a525ea56921bd971db.zip"
+
[[package]]
name = "certifi"
version = "2021.10.8"
@@ -235,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."
@@ -307,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"
@@ -1170,7 +1147,7 @@ multidict = ">=4.0"
[metadata]
lock-version = "1.1"
python-versions = "3.9.*"
-content-hash = "d625aaae916c07c21080bf504831f5bf4d2bf4f0e3696e404448b43719eff201"
+content-hash = "0248fc7488c79af0cdb3a6db9528f4c3129db50b3a8d1dd3ba57dbc31b381c31"
[metadata.files]
aio-pika = [
@@ -1383,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"},
@@ -1400,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"},
]
diff --git a/pyproject.toml b/pyproject.toml
index 19e5f78a7..c764910c2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,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/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py
index f89465f84..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
@@ -231,3 +233,88 @@ class VoiceMuteTests(unittest.IsolatedAsyncioTestCase):
"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)