diff options
| -rw-r--r-- | bot/cogs/filtering.py | 14 | ||||
| -rw-r--r-- | bot/cogs/moderation.py | 133 | ||||
| -rw-r--r-- | bot/cogs/modlog.py | 12 | ||||
| -rw-r--r-- | bot/cogs/snekbox.py | 26 | ||||
| -rw-r--r-- | bot/cogs/utils.py | 63 | ||||
| -rw-r--r-- | bot/constants.py | 2 | ||||
| -rw-r--r-- | bot/decorators.py | 44 | ||||
| -rw-r--r-- | config-default.yml | 2 |
8 files changed, 215 insertions, 81 deletions
diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index b6ce501fc..0ba1e49c5 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -238,15 +238,13 @@ class Filtering: f"{URLs.discord_invite_api}/{invite}" ) response = await response.json() - if response.get("guild") is None: - # If we have a valid invite which is not a guild invite - # it might be a DM channel invite - if response.get("channel") is not None: - # We don't have whitelisted Group DMs so we can - # go ahead and return a positive for any group DM - return True + guild = response.get("guild") + if guild is None: + # We don't have whitelisted Group DMs so we can + # go ahead and return a positive for any group DM + return True - guild_id = int(response.get("guild").get("id")) + guild_id = int(guild.get("id")) if guild_id not in Filter.guild_invite_whitelist: return True diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 6e958b912..ac08d3dd4 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -82,7 +82,7 @@ class Moderation(Scheduler): :param reason: The reason for the warning. """ - await self.notify_infraction( + notified = await self.notify_infraction( user=user, infr_type="Warning", reason=reason @@ -92,12 +92,29 @@ class Moderation(Scheduler): if response_object is None: return + dm_result = ":incoming_envelope: " if notified else "" + action = f"{dm_result}:ok_hand: warned {user.mention}" + if reason is None: - result_message = f":ok_hand: warned {user.mention}." + await ctx.send(f"{action}.") else: - result_message = f":ok_hand: warned {user.mention} ({reason})." + await ctx.send(f"{action} ({reason}).") - await ctx.send(result_message) + if not notified: + await self.log_notify_failure(user, ctx.author, "warning") + + # Send a message to the mod log + await self.mod_log.send_log_message( + icon_url=Icons.user_warn, + colour=Colour(Colours.soft_red), + title="Member warned", + thumbnail=user.avatar_url_as(static_format="png"), + text=textwrap.dedent(f""" + Member: {user.mention} (`{user.id}`) + Actor: {ctx.message.author} + Reason: {reason} + """) + ) @with_role(*MODERATION_ROLES) @command(name="kick") @@ -108,7 +125,7 @@ class Moderation(Scheduler): :param reason: The reason for the kick. """ - await self.notify_infraction( + notified = await self.notify_infraction( user=user, infr_type="Kick", reason=reason @@ -121,12 +138,16 @@ class Moderation(Scheduler): self.mod_log.ignore(Event.member_remove, user.id) await user.kick(reason=reason) + dm_result = ":incoming_envelope: " if notified else "" + action = f"{dm_result}:ok_hand: kicked {user.mention}" + if reason is None: - result_message = f":ok_hand: kicked {user.mention}." + await ctx.send(f"{action}.") else: - result_message = f":ok_hand: kicked {user.mention} ({reason})." + await ctx.send(f"{action} ({reason}).") - await ctx.send(result_message) + if not notified: + await self.log_notify_failure(user, ctx.author, "kick") # Send a log message to the mod log await self.mod_log.send_log_message( @@ -150,7 +171,7 @@ class Moderation(Scheduler): :param reason: The reason for the ban. """ - await self.notify_infraction( + notified = await self.notify_infraction( user=user, infr_type="Ban", duration="Permanent", @@ -165,12 +186,16 @@ class Moderation(Scheduler): self.mod_log.ignore(Event.member_remove, user.id) await ctx.guild.ban(user, reason=reason, delete_message_days=0) + dm_result = ":incoming_envelope: " if notified else "" + action = f"{dm_result}:ok_hand: permanently banned {user.mention}" + if reason is None: - result_message = f":ok_hand: permanently banned {user.mention}." + await ctx.send(f"{action}.") else: - result_message = f":ok_hand: permanently banned {user.mention} ({reason})." + await ctx.send(f"{action} ({reason}).") - await ctx.send(result_message) + if not notified: + await self.log_notify_failure(user, ctx.author, "ban") # Send a log message to the mod log await self.mod_log.send_log_message( @@ -194,7 +219,7 @@ class Moderation(Scheduler): :param reason: The reason for the mute. """ - await self.notify_infraction( + notified = await self.notify_infraction( user=user, infr_type="Mute", duration="Permanent", @@ -209,12 +234,16 @@ class Moderation(Scheduler): self.mod_log.ignore(Event.member_update, user.id) await user.add_roles(self._muted_role, reason=reason) + dm_result = ":incoming_envelope: " if notified else "" + action = f"{dm_result}:ok_hand: permanently muted {user.mention}" + if reason is None: - result_message = f":ok_hand: permanently muted {user.mention}." + await ctx.send(f"{action}.") else: - result_message = f":ok_hand: permanently muted {user.mention} ({reason})." + await ctx.send(f"{action} ({reason}).") - await ctx.send(result_message) + if not notified: + await self.log_notify_failure(user, ctx.author, "mute") # Send a log message to the mod log await self.mod_log.send_log_message( @@ -242,7 +271,7 @@ class Moderation(Scheduler): :param reason: The reason for the temporary mute. """ - await self.notify_infraction( + notified = await self.notify_infraction( user=user, infr_type="Mute", duration=duration, @@ -262,12 +291,16 @@ class Moderation(Scheduler): loop = asyncio.get_event_loop() self.schedule_task(loop, infraction_object["id"], infraction_object) + dm_result = ":incoming_envelope: " if notified else "" + action = f"{dm_result}:ok_hand: muted {user.mention} until {infraction_expiration}" + if reason is None: - result_message = f":ok_hand: muted {user.mention} until {infraction_expiration}." + await ctx.send(f"{action}.") else: - result_message = f":ok_hand: muted {user.mention} until {infraction_expiration} ({reason})." + await ctx.send(f"{action} ({reason}).") - await ctx.send(result_message) + if not notified: + await self.log_notify_failure(user, ctx.author, "mute") # Send a log message to the mod log await self.mod_log.send_log_message( @@ -294,7 +327,7 @@ class Moderation(Scheduler): :param reason: The reason for the temporary ban. """ - await self.notify_infraction( + notified = await self.notify_infraction( user=user, infr_type="Ban", duration=duration, @@ -316,12 +349,16 @@ class Moderation(Scheduler): loop = asyncio.get_event_loop() self.schedule_task(loop, infraction_object["id"], infraction_object) + dm_result = ":incoming_envelope: " if notified else "" + action = f"{dm_result}:ok_hand: banned {user.mention} until {infraction_expiration}" + if reason is None: - result_message = f":ok_hand: banned {user.mention} until {infraction_expiration}." + await ctx.send(f"{action}.") else: - result_message = f":ok_hand: banned {user.mention} until {infraction_expiration} ({reason})." + await ctx.send(f"{action} ({reason}).") - await ctx.send(result_message) + if not notified: + await self.log_notify_failure(user, ctx.author, "ban") # Send a log message to the mod log await self.mod_log.send_log_message( @@ -361,6 +398,19 @@ class Moderation(Scheduler): await ctx.send(result_message) + # Send a message to the mod log + await self.mod_log.send_log_message( + icon_url=Icons.user_warn, + colour=Colour(Colours.soft_red), + title="Member shadow warned", + thumbnail=user.avatar_url_as(static_format="png"), + text=textwrap.dedent(f""" + Member: {user.mention} (`{user.id}`) + Actor: {ctx.message.author} + Reason: {reason} + """) + ) + @with_role(*MODERATION_ROLES) @command(name="shadow_kick", hidden=True, aliases=['shadowkick', 'skick']) async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None): @@ -603,7 +653,18 @@ class Moderation(Scheduler): if infraction_object["expires_at"] is not None: self.cancel_expiration(infraction_object["id"]) - await ctx.send(f":ok_hand: Un-muted {user.mention}.") + notified = await self.notify_pardon( + user=user, + title="You have been unmuted.", + content="You may now send messages in the server.", + icon_url=Icons.user_unmute + ) + + dm_result = ":incoming_envelope: " if notified else "" + await ctx.send(f"{dm_result}:ok_hand: Un-muted {user.mention}.") + + if not notified: + await self.log_notify_failure(user, ctx.author, "unmute") # Send a log message to the mod log await self.mod_log.send_log_message( @@ -617,13 +678,6 @@ class Moderation(Scheduler): Intended expiry: {infraction_object['expires_at']} """) ) - - await self.notify_pardon( - user=user, - title="You have been unmuted.", - content="You may now send messages in the server.", - icon_url=Icons.user_unmute - ) except Exception: log.exception("There was an error removing an infraction.") await ctx.send(":x: There was an error removing the infraction.") @@ -1093,7 +1147,7 @@ class Moderation(Scheduler): embed.title = f"Please review our rules over at {RULES_URL}" embed.url = RULES_URL - await self.send_private_embed(user, embed) + return await self.send_private_embed(user, embed) async def notify_pardon( self, user: Union[User, Member], title: str, content: str, icon_url: str = Icons.user_verified @@ -1114,7 +1168,7 @@ class Moderation(Scheduler): embed.set_author(name=title, icon_url=icon_url) - await self.send_private_embed(user, embed) + return await self.send_private_embed(user, embed) async def send_private_embed(self, user: Union[User, Member], embed: Embed): """ @@ -1129,11 +1183,22 @@ class Moderation(Scheduler): try: await user.send(embed=embed) + return True except (HTTPException, Forbidden): log.debug( f"Infraction-related information could not be sent to user {user} ({user.id}). " "They've probably just disabled private messages." ) + return False + + async def log_notify_failure(self, target: str, actor: Member, infraction_type: str): + await self.mod_log.send_log_message( + icon_url=Icons.token_removed, + content=actor.mention, + colour=Colour(Colours.soft_red), + title="Notification Failed", + text=f"Direct message was unable to be sent.\nUser: {target.mention}\nType: {infraction_type}" + ) # endregion diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py index 1d1546d5b..905f114c1 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -104,8 +104,9 @@ class ModLog: self._ignored[event].append(item) async def send_log_message( - self, icon_url: Optional[str], colour: Colour, title: Optional[str], text: str, thumbnail: str = None, - channel_id: int = Channels.modlog, ping_everyone: bool = False, files: List[File] = None + self, icon_url: Optional[str], colour: Colour, title: Optional[str], text: str, + thumbnail: str = None, channel_id: int = Channels.modlog, ping_everyone: bool = False, + files: List[File] = None, content: str = None ): embed = Embed(description=text) @@ -118,10 +119,11 @@ class ModLog: if thumbnail is not None: embed.set_thumbnail(url=thumbnail) - content = None - if ping_everyone: - content = "@everyone" + if content: + content = f"@everyone\n{content}" + else: + content = "@everyone" await self.bot.get_channel(channel_id).send(content=content, embed=embed, files=files) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 1b51da843..cb0454249 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -6,12 +6,12 @@ import textwrap from discord import Colour, Embed from discord.ext.commands import ( - Bot, CommandError, Context, MissingPermissions, - NoPrivateMessage, check, command, guild_only + Bot, CommandError, Context, NoPrivateMessage, command, guild_only ) from bot.cogs.rmq import RMQ from bot.constants import Channels, ERROR_REPLIES, NEGATIVE_REPLIES, Roles, URLs +from bot.decorators import InChannelCheckFailure, in_channel from bot.utils.messages import wait_for_deletion @@ -51,22 +51,8 @@ RAW_CODE_REGEX = re.compile( r"\s*$", # any trailing whitespace until the end of the string re.DOTALL # "." also matches newlines ) -BYPASS_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers) -WHITELISTED_CHANNELS = (Channels.bot,) -WHITELISTED_CHANNELS_STRING = ', '.join(f"<#{channel_id}>" for channel_id in WHITELISTED_CHANNELS) - - -async def channel_is_whitelisted_or_author_can_bypass(ctx: Context): - """ - Checks that the author is either helper or above - or the channel is a whitelisted channel. - """ - if ctx.channel.id in WHITELISTED_CHANNELS: - return True - if any(r.id in BYPASS_ROLES for r in ctx.author.roles): - return True - raise MissingPermissions("You are not allowed to do that here.") +BYPASS_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers) class Snekbox: @@ -84,7 +70,7 @@ class Snekbox: @command(name='eval', aliases=('e',)) @guild_only() - @check(channel_is_whitelisted_or_author_can_bypass) + @in_channel(Channels.bot, bypass_roles=BYPASS_ROLES) async def eval_command(self, ctx: Context, *, code: str = None): """ Run some code. get the result back. We've done our best to make this safe, but do let us know if you @@ -205,9 +191,9 @@ class Snekbox: embed.description = "You're not allowed to use this command in private messages." await ctx.send(embed=embed) - elif isinstance(error, MissingPermissions): + elif isinstance(error, InChannelCheckFailure): embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = f"Sorry, but you may only use this command within {WHITELISTED_CHANNELS_STRING}." + embed.description = str(error) await ctx.send(embed=embed) else: diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index b101b8816..65c729414 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -1,16 +1,20 @@ import logging +import random +import re +import unicodedata from email.parser import HeaderParser from io import StringIO - from discord import Colour, Embed from discord.ext.commands import AutoShardedBot, Context, command -from bot.constants import Roles -from bot.decorators import with_role +from bot.constants import Channels, NEGATIVE_REPLIES, Roles +from bot.decorators import InChannelCheckFailure, in_channel log = logging.getLogger(__name__) +BYPASS_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers) + class Utils: """ @@ -24,7 +28,6 @@ class Utils: self.base_github_pep_url = "https://raw.githubusercontent.com/python/peps/master/pep-" @command(name='pep', aliases=('get_pep', 'p')) - @with_role(Roles.verified) async def pep_command(self, ctx: Context, pep_number: str): """ Fetches information about a PEP and sends it to the channel. @@ -87,6 +90,58 @@ class Utils: await ctx.message.channel.send(embed=pep_embed) + @command() + @in_channel(Channels.bot, bypass_roles=BYPASS_ROLES) + async def charinfo(self, ctx, *, characters: str): + """ + Shows you information on up to 25 unicode characters. + """ + + match = re.match(r"<(a?):(\w+):(\d+)>", characters) + if match: + embed = Embed( + title="Non-Character Detected", + description=( + "Only unicode characters can be processed, but a custom Discord emoji " + "was found. Please remove it and try again." + ) + ) + embed.colour = Colour.red() + return await ctx.send(embed=embed) + + if len(characters) > 25: + embed = Embed(title=f"Too many characters ({len(characters)}/25)") + embed.colour = Colour.red() + return await ctx.send(embed=embed) + + def get_info(char): + digit = f"{ord(char):x}" + if len(digit) <= 4: + u_code = f"\\u{digit:>04}" + else: + u_code = f"\\U{digit:>08}" + url = f"https://www.compart.com/en/unicode/U+{digit:>04}" + name = f"[{unicodedata.name(char, '')}]({url})" + info = f"`{u_code.ljust(10)}`: {name} - {char}" + return info, u_code + + charlist, rawlist = zip(*(get_info(c) for c in characters)) + + embed = Embed(description="\n".join(charlist)) + embed.set_author(name="Character Info") + + if len(characters) > 1: + embed.add_field(name='Raw', value=f"`{''.join(rawlist)}`", inline=False) + + await ctx.send(embed=embed) + + async def __error(self, ctx, error): + embed = Embed(colour=Colour.red()) + if isinstance(error, InChannelCheckFailure): + embed.title = random.choice(NEGATIVE_REPLIES) + embed.description = str(error) + await ctx.send(embed=embed) + def setup(bot): bot.add_cog(Utils(bot)) diff --git a/bot/constants.py b/bot/constants.py index b4eca7e1d..05d2abf81 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -292,6 +292,8 @@ class Icons(metaclass=YAMLGetter): user_unmute: str user_verified: str + user_warn: str + pencil: str remind_blurple: str diff --git a/bot/decorators.py b/bot/decorators.py index fe974cbd3..87877ecbf 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,18 +1,51 @@ import logging import random +import typing from asyncio import Lock from functools import wraps from weakref import WeakValueDictionary from discord import Colour, Embed from discord.ext import commands -from discord.ext.commands import Context +from discord.ext.commands import CheckFailure, Context from bot.constants import ERROR_REPLIES log = logging.getLogger(__name__) +class InChannelCheckFailure(CheckFailure): + pass + + +def in_channel(*channels: int, bypass_roles: typing.Container[int] = None): + """ + Checks that the message is in a whitelisted channel or optionally has a bypass role. + """ + def predicate(ctx: Context): + if ctx.channel.id in channels: + log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The command was used in a whitelisted channel.") + return True + + if bypass_roles: + if any(r.id in bypass_roles for r in ctx.author.roles): + log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The command was not used in a whitelisted channel, " + f"but the author had a role to bypass the in_channel check.") + return True + + log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The in_channel check failed.") + + channels_str = ', '.join(f"<#{c_id}>" for c_id in channels) + raise InChannelCheckFailure( + f"Sorry, but you may only use this command within {channels_str}." + ) + + return commands.check(predicate) + + def with_role(*role_ids: int): async def predicate(ctx: Context): if not ctx.guild: # Return False in a DM @@ -46,15 +79,6 @@ def without_role(*role_ids: int): return commands.check(predicate) -def in_channel(channel_id): - async def predicate(ctx: Context): - check = ctx.channel.id == channel_id - log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " - f"The result of the in_channel check was {check}.") - return check - return commands.check(predicate) - - def locked(): """ Allows the user to only run one instance of the decorated command at a time. diff --git a/config-default.yml b/config-default.yml index 6d301048f..7a5960987 100644 --- a/config-default.yml +++ b/config-default.yml @@ -72,6 +72,8 @@ style: user_unmute: "https://cdn.discordapp.com/emojis/472472639206719508.png" user_verified: "https://cdn.discordapp.com/emojis/470326274519334936.png" + user_warn: "https://cdn.discordapp.com/emojis/470326274238447633.png" + pencil: "https://cdn.discordapp.com/emojis/470326272401211415.png" remind_blurple: "https://cdn.discordapp.com/emojis/477907609215827968.png" |