diff options
| author | 2019-01-17 22:44:35 +1000 | |
|---|---|---|
| committer | 2019-01-17 22:44:35 +1000 | |
| commit | 8b55f36ccf7d2cc1706e404bb806e8790130b887 (patch) | |
| tree | 2b4e5176782cb466811e21ee7d8edb791446da5d | |
| parent | Merge branch 'master' into master (diff) | |
| parent | Merge pull request #277 from python-discord/reorder-mod-actions (diff) | |
Merge branch 'master' into master
| -rw-r--r-- | bot/__main__.py | 1 | ||||
| -rw-r--r-- | bot/cogs/alias.py | 2 | ||||
| -rw-r--r-- | bot/cogs/antispam.py | 35 | ||||
| -rw-r--r-- | bot/cogs/bigbrother.py | 62 | ||||
| -rw-r--r-- | bot/cogs/events.py | 43 | ||||
| -rw-r--r-- | bot/cogs/filtering.py | 42 | ||||
| -rw-r--r-- | bot/cogs/free.py | 127 | ||||
| -rw-r--r-- | bot/cogs/help.py | 4 | ||||
| -rw-r--r-- | bot/cogs/information.py | 35 | ||||
| -rw-r--r-- | bot/cogs/moderation.py | 535 | ||||
| -rw-r--r-- | bot/cogs/modlog.py | 65 | ||||
| -rw-r--r-- | bot/cogs/tags.py | 2 | ||||
| -rw-r--r-- | bot/cogs/token_remover.py | 5 | ||||
| -rw-r--r-- | bot/constants.py | 19 | ||||
| -rw-r--r-- | bot/decorators.py | 36 | ||||
| -rw-r--r-- | bot/utils/checks.py | 56 | ||||
| -rw-r--r-- | config-default.yml | 18 |
17 files changed, 773 insertions, 314 deletions
diff --git a/bot/__main__.py b/bot/__main__.py index 3c40a3243..581fa5c8e 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -75,6 +75,7 @@ bot.load_extension("bot.cogs.tags") bot.load_extension("bot.cogs.token_remover") bot.load_extension("bot.cogs.utils") bot.load_extension("bot.cogs.wolfram") +bot.load_extension("bot.cogs.free") if has_rmq: bot.load_extension("bot.cogs.rmq") diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 2ce4a51e3..0b848c773 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -71,7 +71,7 @@ class Alias: @command(name="watch", hidden=True) async def bigbrother_watch_alias( - self, ctx, user: User, *, reason: str = None + self, ctx, user: User, *, reason: str ): """ Alias for invoking <prefix>bigbrother watch user [text_channel]. diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index d5b72718c..800700a50 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -1,21 +1,18 @@ -import asyncio import logging -import textwrap from datetime import datetime, timedelta from typing import List -from dateutil.relativedelta import relativedelta from discord import Colour, Member, Message, Object, TextChannel from discord.ext.commands import Bot from bot import rules +from bot.cogs.moderation import Moderation from bot.cogs.modlog import ModLog from bot.constants import ( AntiSpam as AntiSpamConfig, Channels, Colours, DEBUG_MODE, Event, Guild as GuildConfig, Icons, Roles, ) -from bot.utils.time import humanize_delta log = logging.getLogger(__name__) @@ -44,7 +41,7 @@ WHITELISTED_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers) class AntiSpam: def __init__(self, bot: Bot): self.bot = bot - self.muted_role = None + self._muted_role = Object(Roles.muted) @property def mod_log(self) -> ModLog: @@ -110,8 +107,6 @@ class AntiSpam: # Sanity check to ensure we're not lagging behind if self.muted_role not in member.roles: remove_role_after = AntiSpamConfig.punishment['remove_after'] - duration_delta = relativedelta(seconds=remove_role_after) - human_duration = humanize_delta(duration_delta) mod_alert_message = ( f"**Triggered by:** {member.display_name}#{member.discriminator} (`{member.id}`)\n" @@ -133,7 +128,8 @@ class AntiSpam: mod_alert_message += f"{content}" - await self.mod_log.send_log_message( + # Return the mod log message Context that we can use to post the infraction + mod_log_ctx = await self.mod_log.send_log_message( icon_url=Icons.filtering, colour=Colour(Colours.soft_red), title=f"Spam detected!", @@ -143,27 +139,8 @@ class AntiSpam: ping_everyone=AntiSpamConfig.ping_everyone ) - await member.add_roles(self.muted_role, reason=reason) - description = textwrap.dedent(f""" - **Channel**: {msg.channel.mention} - **User**: {msg.author.mention} (`{msg.author.id}`) - **Reason**: {reason} - Role will be removed after {human_duration}. - """) - - await self.mod_log.send_log_message( - icon_url=Icons.user_mute, colour=Colour(Colours.soft_red), - title="User muted", text=description - ) - - await asyncio.sleep(remove_role_after) - await member.remove_roles(self.muted_role, reason="AntiSpam mute expired") - - await self.mod_log.send_log_message( - icon_url=Icons.user_mute, colour=Colour(Colours.soft_green), - title="User unmuted", - text=f"Was muted by `AntiSpam` cog for {human_duration}." - ) + # Run a tempmute + await mod_log_ctx.invoke(Moderation.tempmute, member, f"{remove_role_after}S", reason=reason) async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]): # Is deletion of offending messages actually enabled? diff --git a/bot/cogs/bigbrother.py b/bot/cogs/bigbrother.py index 29b13f038..70916cd7b 100644 --- a/bot/cogs/bigbrother.py +++ b/bot/cogs/bigbrother.py @@ -2,8 +2,10 @@ import asyncio import logging import re from collections import defaultdict, deque +from time import strptime, struct_time from typing import List, Union +from aiohttp import ClientError from discord import Color, Embed, Guild, Member, Message, TextChannel, User from discord.ext.commands import Bot, Context, group @@ -26,9 +28,11 @@ class BigBrother: def __init__(self, bot: Bot): self.bot = bot self.watched_users = {} # { user_id: log_channel_id } + self.watch_reasons = {} # { user_id: watch_reason } self.channel_queues = defaultdict(lambda: defaultdict(deque)) # { user_id: { channel_id: queue(messages) } self.last_log = [None, None, 0] # [user_id, channel_id, message_count] self.consuming = False + self.infraction_watch_prefix = "bb watch: " # Please do not change or we won't be able to find old reasons self.bot.loop.create_task(self.get_watched_users()) @@ -62,6 +66,42 @@ class BigBrother: data = await response.json() self.update_cache(data) + async def get_watch_reason(self, user_id: int) -> str: + """ Fetches and returns the latest watch reason for a user using the infraction API """ + + re_bb_watch = rf"^{self.infraction_watch_prefix}" + user_id = str(user_id) + + try: + response = await self.bot.http_session.get( + URLs.site_infractions_user_type.format( + user_id=user_id, + infraction_type="note", + ), + params={"search": re_bb_watch, "hidden": "True", "active": "False"}, + headers=self.HEADERS + ) + infraction_list = await response.json() + except ClientError: + log.exception(f"Failed to retrieve bb watch reason for {user_id}.") + return "(error retrieving bb reason)" + + if infraction_list: + latest_reason_infraction = max(infraction_list, key=self._parse_infraction_time) + latest_reason = latest_reason_infraction['reason'][len(self.infraction_watch_prefix):] + log.trace(f"The latest bb watch reason for {user_id}: {latest_reason}") + return latest_reason + + log.trace(f"No bb watch reason found for {user_id}; returning default string") + return "(no reason specified)" + + @staticmethod + def _parse_infraction_time(infraction: str) -> struct_time: + """Takes RFC1123 date_time string and returns time object for sorting purposes""" + + date_string = infraction["inserted_at"] + return strptime(date_string, "%a, %d %b %Y %H:%M:%S %Z") + async def on_member_ban(self, guild: Guild, user: Union[User, Member]): if guild.id == GuildConfig.id and user.id in self.watched_users: url = f"{URLs.site_bigbrother_api}?user_id={user.id}" @@ -70,6 +110,7 @@ class BigBrother: async with self.bot.http_session.delete(url, headers=self.HEADERS) as response: del self.watched_users[user.id] del self.channel_queues[user.id] + del self.watch_reasons[user.id] if response.status == 204: await channel.send( f"{Emojis.bb_message}:hammer: {user} got banned, so " @@ -139,10 +180,17 @@ class BigBrother: # Send header if user/channel are different or if message limit exceeded. if message.author.id != last_user or message.channel.id != last_channel or msg_count > limit: + # Retrieve watch reason from API if it's not already in the cache + if message.author.id not in self.watch_reasons: + log.trace(f"No watch reason for {message.author.id} found in cache; retrieving from API") + user_watch_reason = await self.get_watch_reason(message.author.id) + self.watch_reasons[message.author.id] = user_watch_reason + self.last_log = [message.author.id, message.channel.id, 0] embed = Embed(description=f"{message.author.mention} in [#{message.channel.name}]({message.jump_url})") embed.set_author(name=message.author.nick or message.author.name, icon_url=message.author.avatar_url) + embed.set_footer(text=f"Watch reason: {self.watch_reasons[message.author.id]}") await destination.send(embed=embed) @staticmethod @@ -246,15 +294,15 @@ class BigBrother: ) else: self.watched_users[user.id] = channel + self.watch_reasons[user.id] = reason + # Add a note (shadow warning) with the reason for watching + reason = f"{self.infraction_watch_prefix}{reason}" + await post_infraction(ctx, user, type="warning", reason=reason, hidden=True) else: data = await response.json() - reason = data.get('error_message', "no message provided") - await ctx.send(f":x: the API returned an error: {reason}") - - # Add a note (shadow warning) with the reason for watching - reason = "bb watch: " + reason # Prepend for situational awareness - await post_infraction(ctx, user, type="warning", reason=reason, hidden=True) + error_reason = data.get('error_message', "no message provided") + await ctx.send(f":x: the API returned an error: {error_reason}") @bigbrother_group.command(name='unwatch', aliases=('uw',)) @with_role(Roles.owner, Roles.admin, Roles.moderator) @@ -270,6 +318,8 @@ class BigBrother: del self.watched_users[user.id] if user.id in self.channel_queues: del self.channel_queues[user.id] + if user.id in self.watch_reasons: + del self.watch_reasons[user.id] else: log.warning(f"user {user.id} was unwatched but was not found in the cache") diff --git a/bot/cogs/events.py b/bot/cogs/events.py index edfc6e579..8dac83d9b 100644 --- a/bot/cogs/events.py +++ b/bot/cogs/events.py @@ -1,4 +1,5 @@ import logging +from functools import partial from discord import Colour, Embed, Member, Object from discord.ext.commands import ( @@ -7,7 +8,6 @@ from discord.ext.commands import ( Context, NoPrivateMessage, UserInputError ) -from bot.cogs.modlog import ModLog from bot.constants import ( Channels, Colours, DEBUG_MODE, Guild, Icons, Keys, @@ -25,10 +25,12 @@ class Events: def __init__(self, bot: Bot): self.bot = bot + self.headers = {"X-API-KEY": Keys.site_api} @property - def mod_log(self) -> ModLog: - return self.bot.get_cog("ModLog") + def send_log(self) -> partial: + cog = self.bot.get_cog("ModLog") + return partial(cog.send_log_message, channel_id=Channels.userlog) async def send_updated_users(self, *users, replace_all=False): users = list(filter(lambda user: str(Roles.verified) in user["roles"], users)) @@ -103,6 +105,29 @@ class Events: resp = await response.json() return resp["data"] + async def has_active_mute(self, user_id: str) -> bool: + """ + Check whether a user has any active mute infractions + """ + + response = await self.bot.http_session.get( + URLs.site_infractions_user.format( + user_id=user_id + ), + params={"hidden": "True"}, + headers=self.headers + ) + infraction_list = await response.json() + + # Check for active mute infractions + if not infraction_list: + # Short circuit + return False + + return any( + infraction["active"] for infraction in infraction_list if infraction["type"].lower() == "mute" + ) + async def on_command_error(self, ctx: Context, e: CommandError): command = ctx.command parent = None @@ -225,7 +250,7 @@ class Events: except Exception as e: log.exception("Failed to persist roles") - await self.mod_log.send_log_message( + await self.send_log( Icons.crown_red, Colour(Colours.soft_red), "Failed to persist roles", f"```py\n{e}\n```", member.avatar_url_as(static_format="png") @@ -236,6 +261,14 @@ class Events: for role in RESTORE_ROLES: if role in old_roles: + # Check for mute roles that were not able to be removed and skip if present + if role == str(Roles.muted) and not await self.has_active_mute(str(member.id)): + log.debug( + f"User {member.id} has no active mute infraction, " + "their leftover muted role will not be persisted" + ) + continue + new_roles.append(Object(int(role))) for role in new_roles: @@ -258,7 +291,7 @@ class Events: reason="Roles restored" ) - await self.mod_log.send_log_message( + await self.send_log( Icons.crown_blurple, Colour.blurple(), "Roles restored", f"Restored {len(new_roles)} roles", member.avatar_url_as(static_format="png") diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 570d6549f..6b4469ceb 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -1,7 +1,9 @@ import logging import re +from typing import Optional import discord.errors +from dateutil.relativedelta import relativedelta from discord import Colour, DMChannel, Member, Message, TextChannel from discord.ext.commands import Bot @@ -73,18 +75,11 @@ class Filtering: f"Your URL has been removed because it matched a blacklisted domain. {_staff_mistake_str}" ) }, - "filter_rich_embeds": { - "enabled": Filter.filter_rich_embeds, + "watch_rich_embeds": { + "enabled": Filter.watch_rich_embeds, "function": self._has_rich_embed, - "type": "filter", + "type": "watchlist", "content_only": False, - "user_notification": Filter.notify_user_rich_embeds, - "notification_msg": ( - "Your post has been removed because it contained a rich embed. " - "This indicates that you're either using an unofficial discord client or are using a self-bot, " - f"both of which violate Discord's Terms of Service. {_staff_mistake_str}\n\n" - "Please don't use a self-bot or an unofficial Discord client on our server." - ) }, "watch_words": { "enabled": Filter.watch_words, @@ -107,10 +102,14 @@ class Filtering: async def on_message(self, msg: Message): await self._filter_message(msg) - async def on_message_edit(self, _: Message, after: Message): - await self._filter_message(after) + async def on_message_edit(self, before: Message, after: Message): + if not before.edited_at: + delta = relativedelta(after.edited_at, before.created_at).microseconds + else: + delta = None + await self._filter_message(after, delta) - async def _filter_message(self, msg: Message): + async def _filter_message(self, msg: Message, delta: Optional[int] = None): """ Whenever a message is sent or edited, run it through our filters to see if it @@ -141,6 +140,13 @@ class Filtering: for filter_name, _filter in self.filters.items(): # Is this specific filter enabled in the config? if _filter["enabled"]: + # Double trigger check for the embeds filter + if filter_name == "watch_rich_embeds": + # If the edit delta is less than 0.001 seconds, then we're probably dealing + # with a double filter trigger. + if delta is not None and delta < 100: + return + # Does the filter only need the message content or the full message? if _filter["content_only"]: triggered = await _filter["function"](msg.content) @@ -183,7 +189,7 @@ class Filtering: log.debug(message) - additional_embeds = msg.embeds if filter_name == "filter_rich_embeds" else None + additional_embeds = msg.embeds if filter_name == "watch_rich_embeds" else None # Send pretty mod log embed to mod-alerts await self.mod_log.send_log_message( @@ -311,11 +317,13 @@ class Filtering: @staticmethod async def _has_rich_embed(msg: Message): """ - Returns True if any of the embeds in the message - are of type 'rich', returns False otherwise + Returns True if any of the embeds in the message are of type 'rich', but are not twitter + embeds. Returns False otherwise. """ if msg.embeds: - return any(embed.type == "rich" for embed in msg.embeds) + for embed in msg.embeds: + if embed.type == "rich" and (not embed.url or "twitter.com" not in embed.url): + return True return False async def notify_member(self, filtered_member: Member, reason: str, channel: TextChannel): diff --git a/bot/cogs/free.py b/bot/cogs/free.py new file mode 100644 index 000000000..620449f7e --- /dev/null +++ b/bot/cogs/free.py @@ -0,0 +1,127 @@ +import logging +from datetime import datetime + +from discord import Colour, Embed, Member, utils +from discord.ext import commands +from discord.ext.commands import BucketType, Context, command, cooldown + +from bot.constants import Categories, Free, Roles + + +log = logging.getLogger(__name__) + +TIMEOUT = Free.activity_timeout +RATE = Free.cooldown_rate +PER = Free.cooldown_per + + +class Free: + """Tries to figure out which help channels are free.""" + + PYTHON_HELP_ID = Categories.python_help + + @command(name="free", aliases=('f',)) + @cooldown(RATE, PER, BucketType.channel) + async def free(self, ctx: Context, user: Member = None, seek: int = 2): + """ + Lists free help channels by likeliness of availability. + :param user: accepts user mention, ID, etc. + :param seek: How far back to check the last active message. + + seek is used only when this command is invoked in a help channel. + You cannot override seek without mentioning a user first. + + When seek is 2, we are avoiding considering the last active message + in a channel to be the one that invoked this command. + + When seek is 3 or more, a user has been mentioned on the assumption + that they asked if the channel is free or they asked their question + in an active channel, and we want the message before that happened. + """ + free_channels = [] + python_help = utils.get(ctx.guild.categories, id=self.PYTHON_HELP_ID) + + if user is not None and seek == 2: + seek = 3 + elif not 0 < seek < 10: + seek = 3 + + # Iterate through all the help channels + # to check latest activity + for channel in python_help.channels: + # Seek further back in the help channel + # the command was invoked in + if channel.id == ctx.channel.id: + messages = await channel.history(limit=seek).flatten() + msg = messages[seek-1] + # Otherwise get last message + else: + msg = await channel.history(limit=1).next() # noqa (False positive) + + inactive = (datetime.utcnow() - msg.created_at).seconds + if inactive > TIMEOUT: + free_channels.append((inactive, channel)) + + embed = Embed() + embed.colour = Colour.blurple() + embed.title = "**Looking for a free help channel?**" + + if user is not None: + embed.description = f"**Hey {user.mention}!**\n\n" + else: + embed.description = "" + + # Display all potentially inactive channels + # in descending order of inactivity + if free_channels: + embed.description += "**The following channel{0} look{1} free:**\n\n**".format( + 's' if len(free_channels) > 1 else '', + '' if len(free_channels) > 1 else 's' + ) + + # Sort channels in descending order by seconds + # Get position in list, inactivity, and channel object + # For each channel, add to embed.description + for i, (inactive, channel) in enumerate(sorted(free_channels, reverse=True), 1): + minutes, seconds = divmod(inactive, 60) + if minutes > 59: + hours, minutes = divmod(minutes, 60) + embed.description += f"{i}. {channel.mention} inactive for {hours}h{minutes}m{seconds}s\n\n" + else: + embed.description += f"{i}. {channel.mention} inactive for {minutes}m{seconds}s\n\n" + + embed.description += ("**\nThese channels aren't guaranteed to be free, " + "so use your best judgement and check for yourself.") + else: + embed.description = ("**Doesn't look like any channels are available right now. " + "You're welcome to check for yourself to be sure. " + "If all channels are truly busy, please be patient " + "as one will likely be available soon.**") + + await ctx.send(embed=embed) + + @free.error + async def free_error(self, ctx: Context, error): + """ + If error raised is CommandOnCooldown, and the + user who invoked has the helper role, reset + the cooldown and reinvoke the command. + + Otherwise log the error. + """ + helpers = ctx.guild.get_role(Roles.helpers) + + if isinstance(error, commands.CommandOnCooldown): + if helpers in ctx.author.roles: + # reset cooldown so second invocation + # doesn't bring us back here. + ctx.command.reset_cooldown(ctx) + # return to avoid needlessly logging the error + return await ctx.reinvoke() + + log.exception(error) # Don't ignore other errors + + +def setup(bot): + bot.add_cog(Free()) + log.info("Cog loaded: Free") diff --git a/bot/cogs/help.py b/bot/cogs/help.py index c82a25417..ded068123 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -6,10 +6,10 @@ from contextlib import suppress from discord import Colour, Embed, HTTPException from discord.ext import commands +from discord.ext.commands import CheckFailure from fuzzywuzzy import fuzz, process from bot import constants -from bot.decorators import InChannelCheckFailure from bot.pagination import ( DELETE_EMOJI, FIRST_EMOJI, LAST_EMOJI, LEFT_EMOJI, LinePaginator, RIGHT_EMOJI, @@ -435,7 +435,7 @@ class HelpSession: # the mean time. try: can_run = await command.can_run(self._ctx) - except InChannelCheckFailure: + except CheckFailure: can_run = False if not can_run: diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 7a244cdbe..129166d2f 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -1,11 +1,13 @@ import logging +import random import textwrap from discord import CategoryChannel, Colour, Embed, Member, TextChannel, VoiceChannel -from discord.ext.commands import Bot, Context, command +from discord.ext.commands import BadArgument, Bot, CommandError, Context, MissingPermissions, command -from bot.constants import Emojis, Keys, Roles, URLs +from bot.constants import Channels, Emojis, Keys, NEGATIVE_REPLIES, Roles, URLs from bot.decorators import with_role +from bot.utils.checks import with_role_check from bot.utils.time import time_since log = logging.getLogger(__name__) @@ -121,13 +123,23 @@ class Information: await ctx.send(embed=embed) - @with_role(*MODERATION_ROLES) @command(name="user", aliases=["user_info", "member", "member_info"]) async def user_info(self, ctx: Context, user: Member = None, hidden: bool = False): """ Returns info about a user. """ + # Do a role check if this is being executed on + # someone other than the caller + if user and user != ctx.author: + if not with_role_check(ctx, *MODERATION_ROLES): + raise BadArgument("You do not have permission to use this command on users other than yourself.") + + # Non-moderators may only do this in #bot-commands + if not with_role_check(ctx, *MODERATION_ROLES): + if not ctx.channel.id == Channels.bot: + raise MissingPermissions("You can't do that here!") + # Validates hidden input hidden = str(hidden) @@ -192,6 +204,23 @@ class Information: await ctx.send(embed=embed) + @user_info.error + async def user_info_command_error(self, ctx: Context, error: CommandError): + embed = Embed(colour=Colour.red()) + + if isinstance(error, BadArgument): + embed.title = random.choice(NEGATIVE_REPLIES) + embed.description = str(error) + await ctx.send(embed=embed) + + elif isinstance(error, MissingPermissions): + embed.title = random.choice(NEGATIVE_REPLIES) + embed.description = f"Sorry, but you may only use this command within <#{Channels.bot}>." + await ctx.send(embed=embed) + + else: + log.exception(f"Unhandled error: {error}") + def setup(bot): bot.add_cog(Information(bot)) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index ac08d3dd4..e9acc27b9 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -45,7 +45,7 @@ def proxy_user(user_id: str) -> Object: class Moderation(Scheduler): """ - Rowboat replacement moderation tools. + Server moderation tools. """ def __init__(self, bot: Bot): @@ -66,32 +66,32 @@ class Moderation(Scheduler): headers=self.headers ) infraction_list = await response.json() - loop = asyncio.get_event_loop() for infraction_object in infraction_list: if infraction_object["expires_at"] is not None: - self.schedule_task(loop, infraction_object["id"], infraction_object) + self.schedule_task(self.bot.loop, infraction_object["id"], infraction_object) # region: Permanent infractions @with_role(*MODERATION_ROLES) - @command(name="warn") + @command() async def warn(self, ctx: Context, user: Union[User, proxy_user], *, reason: str = None): """ Create a warning infraction in the database for a user. - :param user: accepts user mention, ID, etc. - :param reason: The reason for the warning. + + **`user`:** Accepts user mention, ID, etc. + **`reason`:** The reason for the warning. """ + response_object = await post_infraction(ctx, user, type="warning", reason=reason) + if response_object is None: + return + notified = await self.notify_infraction( user=user, infr_type="Warning", reason=reason ) - response_object = await post_infraction(ctx, user, type="warning", reason=reason) - if response_object is None: - return - dm_result = ":incoming_envelope: " if notified else "" action = f"{dm_result}:ok_hand: warned {user.mention}" @@ -100,10 +100,13 @@ class Moderation(Scheduler): else: await ctx.send(f"{action} ({reason}).") - if not notified: - await self.log_notify_failure(user, ctx.author, "warning") + if notified: + dm_status = "Sent" + log_content = None + else: + dm_status = "**Failed**" + log_content = ctx.author.mention - # Send a message to the mod log await self.mod_log.send_log_message( icon_url=Icons.user_warn, colour=Colour(Colours.soft_red), @@ -111,32 +114,41 @@ class Moderation(Scheduler): thumbnail=user.avatar_url_as(static_format="png"), text=textwrap.dedent(f""" Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author} + Actor: {ctx.author} + DM: {dm_status} Reason: {reason} - """) + """), + content=log_content, + footer=f"ID {response_object['infraction']['id']}" ) @with_role(*MODERATION_ROLES) - @command(name="kick") + @command() async def kick(self, ctx: Context, user: Member, *, reason: str = None): """ Kicks a user. - :param user: accepts user mention, ID, etc. - :param reason: The reason for the kick. + + **`user`:** Accepts user mention, ID, etc. + **`reason`:** The reason for the kick. """ + response_object = await post_infraction(ctx, user, type="kick", reason=reason) + if response_object is None: + return + notified = await self.notify_infraction( user=user, infr_type="Kick", reason=reason ) - response_object = await post_infraction(ctx, user, type="kick", reason=reason) - if response_object is None: - return - self.mod_log.ignore(Event.member_remove, user.id) - await user.kick(reason=reason) + + try: + await user.kick(reason=reason) + action_result = True + except Forbidden: + action_result = False dm_result = ":incoming_envelope: " if notified else "" action = f"{dm_result}:ok_hand: kicked {user.mention}" @@ -146,31 +158,39 @@ class Moderation(Scheduler): else: await ctx.send(f"{action} ({reason}).") - if not notified: - await self.log_notify_failure(user, ctx.author, "kick") + dm_status = "Sent" if notified else "**Failed**" + title = "Member kicked" if action_result else "Member kicked (Failed)" + log_content = None if all((notified, action_result)) else ctx.author.mention - # Send a log message to the mod log await self.mod_log.send_log_message( icon_url=Icons.sign_out, colour=Colour(Colours.soft_red), - title="Member kicked", + title=title, thumbnail=user.avatar_url_as(static_format="png"), text=textwrap.dedent(f""" Member: {user.mention} (`{user.id}`) Actor: {ctx.message.author} + DM: {dm_status} Reason: {reason} - """) + """), + content=log_content, + footer=f"ID {response_object['infraction']['id']}" ) @with_role(*MODERATION_ROLES) - @command(name="ban") + @command() async def ban(self, ctx: Context, user: Union[User, proxy_user], *, reason: str = None): """ Create a permanent ban infraction in the database for a user. - :param user: Accepts user mention, ID, etc. - :param reason: The reason for the ban. + + **`user`:** Accepts user mention, ID, etc. + **`reason`:** The reason for the ban. """ + response_object = await post_infraction(ctx, user, type="ban", reason=reason) + if response_object is None: + return + notified = await self.notify_infraction( user=user, infr_type="Ban", @@ -178,13 +198,14 @@ class Moderation(Scheduler): reason=reason ) - response_object = await post_infraction(ctx, user, type="ban", reason=reason) - if response_object is None: - return - self.mod_log.ignore(Event.member_ban, user.id) self.mod_log.ignore(Event.member_remove, user.id) - await ctx.guild.ban(user, reason=reason, delete_message_days=0) + + try: + await ctx.guild.ban(user, reason=reason, delete_message_days=0) + action_result = True + except Forbidden: + action_result = False dm_result = ":incoming_envelope: " if notified else "" action = f"{dm_result}:ok_hand: permanently banned {user.mention}" @@ -194,46 +215,51 @@ class Moderation(Scheduler): else: await ctx.send(f"{action} ({reason}).") - if not notified: - await self.log_notify_failure(user, ctx.author, "ban") + dm_status = "Sent" if notified else "**Failed**" + log_content = None if all((notified, action_result)) else ctx.author.mention + title = "Member permanently banned" + if not action_result: + title += " (Failed)" - # Send a log message to the mod log await self.mod_log.send_log_message( icon_url=Icons.user_ban, colour=Colour(Colours.soft_red), - title="Member permanently banned", + title=title, thumbnail=user.avatar_url_as(static_format="png"), text=textwrap.dedent(f""" Member: {user.mention} (`{user.id}`) Actor: {ctx.message.author} + DM: {dm_status} Reason: {reason} - """) + """), + content=log_content, + footer=f"ID {response_object['infraction']['id']}" ) @with_role(*MODERATION_ROLES) - @command(name="mute") + @command() async def mute(self, ctx: Context, user: Member, *, reason: str = None): """ Create a permanent mute infraction in the database for a user. - :param user: Accepts user mention, ID, etc. - :param reason: The reason for the mute. - """ - notified = await self.notify_infraction( - user=user, - infr_type="Mute", - duration="Permanent", - reason=reason - ) + **`user`:** Accepts user mention, ID, etc. + **`reason`:** The reason for the mute. + """ response_object = await post_infraction(ctx, user, type="mute", reason=reason) if response_object is None: return - # add the mute role self.mod_log.ignore(Event.member_update, user.id) await user.add_roles(self._muted_role, reason=reason) + notified = await self.notify_infraction( + user=user, + infr_type="Mute", + duration="Permanent", + reason=reason + ) + dm_result = ":incoming_envelope: " if notified else "" action = f"{dm_result}:ok_hand: permanently muted {user.mention}" @@ -242,10 +268,13 @@ class Moderation(Scheduler): else: await ctx.send(f"{action} ({reason}).") - if not notified: - await self.log_notify_failure(user, ctx.author, "mute") + if notified: + dm_status = "Sent" + log_content = None + else: + dm_status = "**Failed**" + log_content = ctx.author.mention - # Send a log message to the mod log await self.mod_log.send_log_message( icon_url=Icons.user_mute, colour=Colour(Colours.soft_red), @@ -254,42 +283,47 @@ class Moderation(Scheduler): text=textwrap.dedent(f""" Member: {user.mention} (`{user.id}`) Actor: {ctx.message.author} + DM: {dm_status} Reason: {reason} - """) + """), + content=log_content, + footer=f"ID {response_object['infraction']['id']}" ) # endregion # region: Temporary infractions @with_role(*MODERATION_ROLES) - @command(name="tempmute") + @command() async def tempmute(self, ctx: Context, user: Member, duration: str, *, reason: str = None): """ Create a temporary mute infraction in the database for a user. - :param user: Accepts user mention, ID, etc. - :param duration: The duration for the temporary mute infraction - :param reason: The reason for the temporary mute. + + **`user`:** Accepts user mention, ID, etc. + **`duration`:** The duration for the temporary mute infraction + **`reason`:** The reason for the temporary mute. """ - notified = await self.notify_infraction( - user=user, - infr_type="Mute", - duration=duration, - reason=reason + response_object = await post_infraction( + ctx, user, type="mute", reason=reason, duration=duration ) - - response_object = await post_infraction(ctx, user, type="mute", reason=reason, duration=duration) if response_object is None: return self.mod_log.ignore(Event.member_update, user.id) await user.add_roles(self._muted_role, reason=reason) + notified = await self.notify_infraction( + user=user, + infr_type="Mute", + duration=duration, + reason=reason + ) + infraction_object = response_object["infraction"] infraction_expiration = infraction_object["expires_at"] - loop = asyncio.get_event_loop() - self.schedule_task(loop, infraction_object["id"], infraction_object) + self.schedule_task(ctx.bot.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}" @@ -299,10 +333,13 @@ class Moderation(Scheduler): else: await ctx.send(f"{action} ({reason}).") - if not notified: - await self.log_notify_failure(user, ctx.author, "mute") + if notified: + dm_status = "Sent" + log_content = None + else: + dm_status = "**Failed**" + log_content = ctx.author.mention - # Send a log message to the mod log await self.mod_log.send_log_message( icon_url=Icons.user_mute, colour=Colour(Colours.soft_red), @@ -311,22 +348,34 @@ class Moderation(Scheduler): text=textwrap.dedent(f""" Member: {user.mention} (`{user.id}`) Actor: {ctx.message.author} + DM: {dm_status} Reason: {reason} Duration: {duration} Expires: {infraction_expiration} - """) + """), + content=log_content, + footer=f"ID {response_object['infraction']['id']}" ) @with_role(*MODERATION_ROLES) - @command(name="tempban") - async def tempban(self, ctx: Context, user: Union[User, proxy_user], duration: str, *, reason: str = None): + @command() + async def tempban( + self, ctx: Context, user: Union[User, proxy_user], duration: str, *, reason: str = None + ): """ Create a temporary ban infraction in the database for a user. - :param user: Accepts user mention, ID, etc. - :param duration: The duration for the temporary ban infraction - :param reason: The reason for the temporary ban. + + **`user`:** Accepts user mention, ID, etc. + **`duration`:** The duration for the temporary ban infraction + **`reason`:** The reason for the temporary ban. """ + response_object = await post_infraction( + ctx, user, type="ban", reason=reason, duration=duration + ) + if response_object is None: + return + notified = await self.notify_infraction( user=user, infr_type="Ban", @@ -334,20 +383,19 @@ class Moderation(Scheduler): reason=reason ) - response_object = await post_infraction(ctx, user, type="ban", reason=reason, duration=duration) - if response_object is None: - return - self.mod_log.ignore(Event.member_ban, user.id) self.mod_log.ignore(Event.member_remove, user.id) - guild: Guild = ctx.guild - await guild.ban(user, reason=reason, delete_message_days=0) + + try: + await ctx.guild.ban(user, reason=reason, delete_message_days=0) + action_result = True + except Forbidden: + action_result = False infraction_object = response_object["infraction"] infraction_expiration = infraction_object["expires_at"] - loop = asyncio.get_event_loop() - self.schedule_task(loop, infraction_object["id"], infraction_object) + self.schedule_task(ctx.bot.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}" @@ -357,67 +405,74 @@ class Moderation(Scheduler): else: await ctx.send(f"{action} ({reason}).") - if not notified: - await self.log_notify_failure(user, ctx.author, "ban") + dm_status = "Sent" if notified else "**Failed**" + log_content = None if all((notified, action_result)) else ctx.author.mention + title = "Member temporarily banned" + if not action_result: + title += " (Failed)" - # Send a log message to the mod log await self.mod_log.send_log_message( icon_url=Icons.user_ban, colour=Colour(Colours.soft_red), thumbnail=user.avatar_url_as(static_format="png"), - title="Member temporarily banned", + title=title, text=textwrap.dedent(f""" Member: {user.mention} (`{user.id}`) Actor: {ctx.message.author} + DM: {dm_status} Reason: {reason} Duration: {duration} Expires: {infraction_expiration} - """) + """), + content=log_content, + footer=f"ID {response_object['infraction']['id']}" ) # endregion # region: Permanent shadow infractions @with_role(*MODERATION_ROLES) - @command(name="shadow_warn", hidden=True, aliases=['shadowwarn', 'swarn', 'note']) - async def shadow_warn(self, ctx: Context, user: Union[User, proxy_user], *, reason: str = None): + @command(hidden=True, aliases=['shadowwarn', 'swarn', 'shadow_warn']) + async def note(self, ctx: Context, user: Union[User, proxy_user], *, reason: str = None): """ - Create a warning infraction in the database for a user. - :param user: accepts user mention, ID, etc. - :param reason: The reason for the warning. + Create a private infraction note in the database for a user. + + **`user`:** accepts user mention, ID, etc. + **`reason`:** The reason for the warning. """ - response_object = await post_infraction(ctx, user, type="warning", reason=reason, hidden=True) + response_object = await post_infraction( + ctx, user, type="warning", reason=reason, hidden=True + ) if response_object is None: return if reason is None: - result_message = f":ok_hand: note added for {user.mention}." + await ctx.send(f":ok_hand: note added for {user.mention}.") else: - result_message = f":ok_hand: note added for {user.mention} ({reason})." - - await ctx.send(result_message) + await ctx.send(f":ok_hand: note added for {user.mention} ({reason}).") - # 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", + title="Member note added", thumbnail=user.avatar_url_as(static_format="png"), text=textwrap.dedent(f""" Member: {user.mention} (`{user.id}`) Actor: {ctx.message.author} Reason: {reason} - """) + """), + footer=f"ID {response_object['infraction']['id']}" ) @with_role(*MODERATION_ROLES) - @command(name="shadow_kick", hidden=True, aliases=['shadowkick', 'skick']) + @command(hidden=True, aliases=['shadowkick', 'skick']) async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None): """ Kicks a user. - :param user: accepts user mention, ID, etc. - :param reason: The reason for the kick. + + **`user`:** accepts user mention, ID, etc. + **`reason`:** The reason for the kick. """ response_object = await post_infraction(ctx, user, type="kick", reason=reason, hidden=True) @@ -425,35 +480,47 @@ class Moderation(Scheduler): return self.mod_log.ignore(Event.member_remove, user.id) - await user.kick(reason=reason) + + try: + await user.kick(reason=reason) + action_result = True + except Forbidden: + action_result = False if reason is None: - result_message = f":ok_hand: kicked {user.mention}." + await ctx.send(f":ok_hand: kicked {user.mention}.") else: - result_message = f":ok_hand: kicked {user.mention} ({reason})." + await ctx.send(f":ok_hand: kicked {user.mention} ({reason}).") - await ctx.send(result_message) + title = "Member shadow kicked" + if action_result: + log_content = None + else: + log_content = ctx.author.mention + title += " (Failed)" - # Send a log message to the mod log await self.mod_log.send_log_message( icon_url=Icons.sign_out, colour=Colour(Colours.soft_red), - title="Member shadow kicked", + title=title, thumbnail=user.avatar_url_as(static_format="png"), text=textwrap.dedent(f""" Member: {user.mention} (`{user.id}`) Actor: {ctx.message.author} Reason: {reason} - """) + """), + content=log_content, + footer=f"ID {response_object['infraction']['id']}" ) @with_role(*MODERATION_ROLES) - @command(name="shadow_ban", hidden=True, aliases=['shadowban', 'sban']) + @command(hidden=True, aliases=['shadowban', 'sban']) async def shadow_ban(self, ctx: Context, user: Union[User, proxy_user], *, reason: str = None): """ Create a permanent ban infraction in the database for a user. - :param user: Accepts user mention, ID, etc. - :param reason: The reason for the ban. + + **`user`:** Accepts user mention, ID, etc. + **`reason`:** The reason for the ban. """ response_object = await post_infraction(ctx, user, type="ban", reason=reason, hidden=True) @@ -462,53 +529,61 @@ class Moderation(Scheduler): self.mod_log.ignore(Event.member_ban, user.id) self.mod_log.ignore(Event.member_remove, user.id) - await ctx.guild.ban(user, reason=reason, delete_message_days=0) + + try: + await ctx.guild.ban(user, reason=reason, delete_message_days=0) + action_result = True + except Forbidden: + action_result = False if reason is None: - result_message = f":ok_hand: permanently banned {user.mention}." + await ctx.send(f":ok_hand: permanently banned {user.mention}.") else: - result_message = f":ok_hand: permanently banned {user.mention} ({reason})." + await ctx.send(f":ok_hand: permanently banned {user.mention} ({reason}).") - await ctx.send(result_message) + title = "Member permanently banned" + if action_result: + log_content = None + else: + log_content = ctx.author.mention + title += " (Failed)" - # Send a log message to the mod log await self.mod_log.send_log_message( icon_url=Icons.user_ban, colour=Colour(Colours.soft_red), - title="Member permanently banned", + title=title, thumbnail=user.avatar_url_as(static_format="png"), text=textwrap.dedent(f""" Member: {user.mention} (`{user.id}`) Actor: {ctx.message.author} Reason: {reason} - """) + """), + content=log_content, + footer=f"ID {response_object['infraction']['id']}" ) @with_role(*MODERATION_ROLES) - @command(name="shadow_mute", hidden=True, aliases=['shadowmute', 'smute']) + @command(hidden=True, aliases=['shadowmute', 'smute']) async def shadow_mute(self, ctx: Context, user: Member, *, reason: str = None): """ Create a permanent mute infraction in the database for a user. - :param user: Accepts user mention, ID, etc. - :param reason: The reason for the mute. + + **`user`:** Accepts user mention, ID, etc. + **`reason`:** The reason for the mute. """ response_object = await post_infraction(ctx, user, type="mute", reason=reason, hidden=True) if response_object is None: return - # add the mute role self.mod_log.ignore(Event.member_update, user.id) await user.add_roles(self._muted_role, reason=reason) if reason is None: - result_message = f":ok_hand: permanently muted {user.mention}." + await ctx.send(f":ok_hand: permanently muted {user.mention}.") else: - result_message = f":ok_hand: permanently muted {user.mention} ({reason})." - - await ctx.send(result_message) + await ctx.send(f":ok_hand: permanently muted {user.mention} ({reason}).") - # Send a log message to the mod log await self.mod_log.send_log_message( icon_url=Icons.user_mute, colour=Colour(Colours.soft_red), @@ -518,23 +593,29 @@ class Moderation(Scheduler): Member: {user.mention} (`{user.id}`) Actor: {ctx.message.author} Reason: {reason} - """) + """), + footer=f"ID {response_object['infraction']['id']}" ) # endregion # region: Temporary shadow infractions @with_role(*MODERATION_ROLES) - @command(name="shadow_tempmute", hidden=True, aliases=["shadowtempmute, stempmute"]) - async def shadow_tempmute(self, ctx: Context, user: Member, duration: str, *, reason: str = None): + @command(hidden=True, aliases=["shadowtempmute, stempmute"]) + async def shadow_tempmute( + self, ctx: Context, user: Member, duration: str, *, reason: str = None + ): """ Create a temporary mute infraction in the database for a user. - :param user: Accepts user mention, ID, etc. - :param duration: The duration for the temporary mute infraction - :param reason: The reason for the temporary mute. + + **`user`:** Accepts user mention, ID, etc. + **`duration`:** The duration for the temporary mute infraction + **`reason`:** The reason for the temporary mute. """ - response_object = await post_infraction(ctx, user, type="mute", reason=reason, duration=duration, hidden=True) + response_object = await post_infraction( + ctx, user, type="mute", reason=reason, duration=duration, hidden=True + ) if response_object is None: return @@ -544,17 +625,15 @@ class Moderation(Scheduler): infraction_object = response_object["infraction"] infraction_expiration = infraction_object["expires_at"] - loop = asyncio.get_event_loop() - self.schedule_expiration(loop, infraction_object) + self.schedule_expiration(ctx.bot.loop, infraction_object) if reason is None: - result_message = f":ok_hand: muted {user.mention} until {infraction_expiration}." + await ctx.send(f":ok_hand: muted {user.mention} until {infraction_expiration}.") else: - result_message = f":ok_hand: muted {user.mention} until {infraction_expiration} ({reason})." - - await ctx.send(result_message) + await ctx.send( + f":ok_hand: muted {user.mention} until {infraction_expiration} ({reason})." + ) - # Send a log message to the mod log await self.mod_log.send_log_message( icon_url=Icons.user_mute, colour=Colour(Colours.soft_red), @@ -566,67 +645,84 @@ class Moderation(Scheduler): Reason: {reason} Duration: {duration} Expires: {infraction_expiration} - """) + """), + footer=f"ID {response_object['infraction']['id']}" ) @with_role(*MODERATION_ROLES) - @command(name="shadow_tempban", hidden=True, aliases=["shadowtempban, stempban"]) + @command(hidden=True, aliases=["shadowtempban, stempban"]) async def shadow_tempban( self, ctx: Context, user: Union[User, proxy_user], duration: str, *, reason: str = None ): """ Create a temporary ban infraction in the database for a user. - :param user: Accepts user mention, ID, etc. - :param duration: The duration for the temporary ban infraction - :param reason: The reason for the temporary ban. + + **`user`:** Accepts user mention, ID, etc. + **`duration`:** The duration for the temporary ban infraction + **`reason`:** The reason for the temporary ban. """ - response_object = await post_infraction(ctx, user, type="ban", reason=reason, duration=duration, hidden=True) + response_object = await post_infraction( + ctx, user, type="ban", reason=reason, duration=duration, hidden=True + ) if response_object is None: return self.mod_log.ignore(Event.member_ban, user.id) self.mod_log.ignore(Event.member_remove, user.id) - guild: Guild = ctx.guild - await guild.ban(user, reason=reason, delete_message_days=0) + + try: + await ctx.guild.ban(user, reason=reason, delete_message_days=0) + action_result = True + except Forbidden: + action_result = False infraction_object = response_object["infraction"] infraction_expiration = infraction_object["expires_at"] - loop = asyncio.get_event_loop() - self.schedule_expiration(loop, infraction_object) + self.schedule_expiration(ctx.bot.loop, infraction_object) if reason is None: - result_message = f":ok_hand: banned {user.mention} until {infraction_expiration}." + await ctx.send(f":ok_hand: banned {user.mention} until {infraction_expiration}.") else: - result_message = f":ok_hand: banned {user.mention} until {infraction_expiration} ({reason})." + await ctx.send( + f":ok_hand: banned {user.mention} until {infraction_expiration} ({reason})." + ) - await ctx.send(result_message) + title = "Member temporarily banned" + if action_result: + log_content = None + else: + log_content = ctx.author.mention + title += " (Failed)" # Send a log message to the mod log await self.mod_log.send_log_message( icon_url=Icons.user_ban, colour=Colour(Colours.soft_red), thumbnail=user.avatar_url_as(static_format="png"), - title="Member temporarily banned", + title=title, text=textwrap.dedent(f""" Member: {user.mention} (`{user.id}`) Actor: {ctx.message.author} Reason: {reason} Duration: {duration} Expires: {infraction_expiration} - """) + """), + content=log_content, + footer=f"ID {response_object['infraction']['id']}" ) # endregion # region: Remove infractions (un- commands) @with_role(*MODERATION_ROLES) - @command(name="unmute") + @command() async def unmute(self, ctx: Context, user: Member): """ Deactivates the active mute infraction for a user. - :param user: Accepts user mention, ID, etc. + + **`user`:** Accepts user mention, ID, etc. """ try: @@ -638,16 +734,20 @@ class Moderation(Scheduler): ), headers=self.headers ) + response_object = await response.json() if "error_code" in response_object: - await ctx.send(f":x: There was an error removing the infraction: {response_object['error_message']}") - return + return await ctx.send( + ":x: There was an error removing the infraction: " + f"{response_object['error_message']}" + ) infraction_object = response_object["infraction"] if infraction_object is None: # no active infraction - await ctx.send(f":x: There is no active mute infraction for user {user.mention}.") - return + return await ctx.send( + f":x: There is no active mute infraction for user {user.mention}." + ) await self._deactivate_infraction(infraction_object) if infraction_object["expires_at"] is not None: @@ -660,11 +760,16 @@ class Moderation(Scheduler): 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 notified: + dm_status = "Sent" + dm_emoji = ":incoming_envelope: " + log_content = None + else: + dm_status = "**Failed**" + dm_emoji = "" + log_content = ctx.author.mention - if not notified: - await self.log_notify_failure(user, ctx.author, "unmute") + await ctx.send(f"{dm_emoji}:ok_hand: Un-muted {user.mention}.") # Send a log message to the mod log await self.mod_log.send_log_message( @@ -676,19 +781,23 @@ class Moderation(Scheduler): Member: {user.mention} (`{user.id}`) Actor: {ctx.message.author} Intended expiry: {infraction_object['expires_at']} - """) + DM: {dm_status} + """), + footer=infraction_object["id"], + content=log_content ) - except Exception: - log.exception("There was an error removing an infraction.") + + except Exception as e: + log.exception("There was an error removing an infraction.", exc_info=e) await ctx.send(":x: There was an error removing the infraction.") - return @with_role(*MODERATION_ROLES) - @command(name="unban") + @command() async def unban(self, ctx: Context, user: Union[User, proxy_user]): """ Deactivates the active ban infraction for a user. - :param user: Accepts user mention, ID, etc. + + **`user`:** Accepts user mention, ID, etc. """ try: @@ -702,14 +811,17 @@ class Moderation(Scheduler): ) response_object = await response.json() if "error_code" in response_object: - await ctx.send(f":x: There was an error removing the infraction: {response_object['error_message']}") - return + return await ctx.send( + ":x: There was an error removing the infraction: " + f"{response_object['error_message']}" + ) infraction_object = response_object["infraction"] if infraction_object is None: # no active infraction - await ctx.send(f":x: There is no active ban infraction for user {user.mention}.") - return + return await ctx.send( + f":x: There is no active ban infraction for user {user.mention}." + ) await self._deactivate_infraction(infraction_object) if infraction_object["expires_at"] is not None: @@ -732,7 +844,6 @@ class Moderation(Scheduler): except Exception: log.exception("There was an error removing an infraction.") await ctx.send(":x: There was an error removing the infraction.") - return # endregion # region: Edit infraction commands @@ -755,10 +866,12 @@ class Moderation(Scheduler): @infraction_edit_group.command(name="duration") async def edit_duration(self, ctx: Context, infraction_id: str, duration: str): """ - Sets the duration of the given infraction, relative to the time of updating. - :param infraction_id: the id (UUID) of the infraction - :param duration: the new duration of the infraction, relative to the time of updating. Use "permanent" to mark - the infraction as permanent. + Sets the duration of the given infraction, relative to the time of + updating. + + **`infraction_id`:** The ID (UUID) of the infraction. + **`duration`:** The new duration of the infraction, relative to the + time of updating. Use "permanent" to the infraction as permanent. """ try: @@ -784,8 +897,10 @@ class Moderation(Scheduler): ) response_object = await response.json() if "error_code" in response_object or response_object.get("success") is False: - await ctx.send(f":x: There was an error updating the infraction: {response_object['error_message']}") - return + return await ctx.send( + ":x: There was an error updating the infraction: " + f"{response_object['error_message']}" + ) infraction_object = response_object["infraction"] # Re-schedule @@ -796,7 +911,10 @@ class Moderation(Scheduler): if duration is None: await ctx.send(f":ok_hand: Updated infraction: marked as permanent.") else: - await ctx.send(f":ok_hand: Updated infraction: set to expire on {infraction_object['expires_at']}.") + await ctx.send( + ":ok_hand: Updated infraction: set to expire on " + f"{infraction_object['expires_at']}." + ) except Exception: log.exception("There was an error updating an infraction.") @@ -839,8 +957,8 @@ class Moderation(Scheduler): async def edit_reason(self, ctx: Context, infraction_id: str, *, reason: str): """ Sets the reason of the given infraction. - :param infraction_id: the id (UUID) of the infraction - :param reason: The new reason of the infraction + **`infraction_id`:** The ID (UUID) of the infraction. + **`reason`:** The new reason of the infraction. """ try: @@ -863,14 +981,15 @@ class Moderation(Scheduler): ) response_object = await response.json() if "error_code" in response_object or response_object.get("success") is False: - await ctx.send(f":x: There was an error updating the infraction: {response_object['error_message']}") - return + return await ctx.send( + ":x: There was an error updating the infraction: " + f"{response_object['error_message']}" + ) await ctx.send(f":ok_hand: Updated infraction: set reason to \"{reason}\".") except Exception: log.exception("There was an error updating an infraction.") - await ctx.send(":x: There was an error updating the infraction.") - return + return await ctx.send(":x: There was an error updating the infraction.") new_infraction = response_object["infraction"] prev_infraction = previous_object["infraction"] @@ -1004,6 +1123,7 @@ class Moderation(Scheduler): def schedule_expiration(self, loop: asyncio.AbstractEventLoop, infraction_object: dict): """ Schedules a task to expire a temporary infraction. + :param loop: the asyncio event loop :param infraction_object: the infraction object to expire at the end of the task """ @@ -1032,9 +1152,10 @@ class Moderation(Scheduler): async def _scheduled_task(self, infraction_object: dict): """ - A co-routine which marks an infraction as expired after the delay from the time of scheduling - to the time of expiration. At the time of expiration, the infraction is marked as inactive on the website, - and the expiration task is cancelled. + A co-routine which marks an infraction as expired after the delay from the time of + scheduling to the time of expiration. At the time of expiration, the infraction is + marked as inactive on the website, and the expiration task is cancelled. + :param infraction_object: the infraction in question """ @@ -1061,8 +1182,9 @@ class Moderation(Scheduler): async def _deactivate_infraction(self, infraction_object): """ - A co-routine which marks an infraction as inactive on the website. This co-routine does not cancel or - un-schedule an expiration task. + A co-routine which marks an infraction as inactive on the website. This co-routine does + not cancel or un-schedule an expiration task. + :param infraction_object: the infraction in question """ @@ -1116,7 +1238,8 @@ class Moderation(Scheduler): return lines.strip() async def notify_infraction( - self, user: Union[User, Member], infr_type: str, duration: str = None, reason: str = None + self, user: Union[User, Member], infr_type: str, duration: str = None, + reason: str = None ): """ Notify a user of their fresh infraction :) @@ -1150,7 +1273,8 @@ class Moderation(Scheduler): 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 + self, user: Union[User, Member], title: str, content: str, + icon_url: str = Icons.user_verified ): """ Notify a user that an infraction has been lifted. @@ -1197,7 +1321,10 @@ class Moderation(Scheduler): 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}" + text=( + f"Direct message was unable to be sent.\nUser: {target.mention}\n" + f"Type: {infraction_type}" + ) ) # endregion diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py index c96838a54..495795b6d 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -104,9 +104,19 @@ 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, content: str = None, additional_embeds: List[Embed] = None, + self, + icon_url: Optional[str], + colour: Colour, + title: Optional[str], + text: str, + thumbnail: Optional[str] = None, + channel_id: int = Channels.modlog, + ping_everyone: bool = False, + files: Optional[List[File]] = None, + content: Optional[str] = None, + additional_embeds: Optional[List[Embed]] = None, + timestamp_override: Optional[datetime.datetime] = None, + footer: Optional[str] = None, ): embed = Embed(description=text) @@ -114,9 +124,13 @@ class ModLog: embed.set_author(name=title, icon_url=icon_url) embed.colour = colour - embed.timestamp = datetime.datetime.utcnow() - if thumbnail is not None: + embed.timestamp = timestamp_override or datetime.datetime.utcnow() + + if footer: + embed.set_footer(text=footer) + + if thumbnail: embed.set_thumbnail(url=thumbnail) if ping_everyone: @@ -126,14 +140,15 @@ class ModLog: content = "@everyone" channel = self.bot.get_channel(channel_id) - - await channel.send(content=content, embed=embed, files=files) + log_message = await channel.send(content=content, embed=embed, files=files) if additional_embeds: await channel.send("With the following embed(s):") for additional_embed in additional_embeds: await channel.send(embed=additional_embed) + return await self.bot.get_context(log_message) # Optionally return for use with antispam + async def on_guild_channel_create(self, channel: GUILD_CHANNEL): if channel.guild.id != GuildConstant.id: return @@ -366,7 +381,8 @@ class ModLog: await self.send_log_message( Icons.user_ban, Colour(Colours.soft_red), "User banned", f"{member.name}#{member.discriminator} (`{member.id}`)", - thumbnail=member.avatar_url_as(static_format="png") + thumbnail=member.avatar_url_as(static_format="png"), + channel_id=Channels.modlog ) async def on_member_join(self, member: Member): @@ -385,7 +401,8 @@ class ModLog: await self.send_log_message( Icons.sign_in, Colour(Colours.soft_green), "User joined", message, - thumbnail=member.avatar_url_as(static_format="png") + thumbnail=member.avatar_url_as(static_format="png"), + channel_id=Channels.userlog ) async def on_member_remove(self, member: Member): @@ -399,7 +416,8 @@ class ModLog: await self.send_log_message( Icons.sign_out, Colour(Colours.soft_red), "User left", f"{member.name}#{member.discriminator} (`{member.id}`)", - thumbnail=member.avatar_url_as(static_format="png") + thumbnail=member.avatar_url_as(static_format="png"), + channel_id=Channels.userlog ) async def on_member_unban(self, guild: Guild, member: User): @@ -413,7 +431,8 @@ class ModLog: await self.send_log_message( Icons.user_unban, Colour.blurple(), "User unbanned", f"{member.name}#{member.discriminator} (`{member.id}`)", - thumbnail=member.avatar_url_as(static_format="png") + thumbnail=member.avatar_url_as(static_format="png"), + channel_id=Channels.modlog ) async def on_member_update(self, before: Member, after: Member): @@ -501,7 +520,8 @@ class ModLog: await self.send_log_message( Icons.user_update, Colour.blurple(), "Member updated", message, - thumbnail=after.avatar_url_as(static_format="png") + thumbnail=after.avatar_url_as(static_format="png"), + channel_id=Channels.userlog ) async def on_raw_bulk_message_delete(self, event: RawBulkMessageDeleteEvent): @@ -675,14 +695,27 @@ class ModLog: f"{after.clean_content}" ) + if before.edited_at: + # Message was previously edited, to assist with self-bot detection, use the edited_at + # datetime as the baseline and create a human-readable delta between this edit event + # and the last time the message was edited + timestamp = before.edited_at + delta = humanize_delta(relativedelta(after.edited_at, before.edited_at)) + footer = f"Last edited {delta} ago" + else: + # Message was not previously edited, use the created_at datetime as the baseline, no + # delta calculation needed + timestamp = before.created_at + footer = None + await self.send_log_message( - Icons.message_edit, Colour.blurple(), "Message edited (Before)", - before_response, channel_id=Channels.message_log + Icons.message_edit, Colour.blurple(), "Message edited (Before)", before_response, + channel_id=Channels.message_log, timestamp_override=timestamp, footer=footer ) await self.send_log_message( - Icons.message_edit, Colour.blurple(), "Message edited (After)", - after_response, channel_id=Channels.message_log + Icons.message_edit, Colour.blurple(), "Message edited (After)", after_response, + channel_id=Channels.message_log, timestamp_override=after.edited_at ) async def on_raw_message_edit(self, event: RawMessageUpdateEvent): diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index b128b6de1..8ecd80127 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -149,7 +149,7 @@ class Tags: tags = [] - embed = Embed() + embed: Embed = Embed() embed.colour = Colour.red() tag_data = await self.get_tag_data(tag_name) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 8277513a7..c1a0e18ba 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -16,8 +16,9 @@ log = logging.getLogger(__name__) DELETION_MESSAGE_TEMPLATE = ( "Hey {mention}! I noticed you posted a seemingly valid Discord API " - "token in your message and have removed your message to prevent abuse. " - "We recommend regenerating your token regardless, which you can do here: " + "token in your message and have removed your message. " + "We **strongly recommend** regenerating your token as it's probably " + "been compromised. You can do that here: " "<https://discordapp.com/developers/applications/me>\n" "Feel free to re-post it with the token removed. " "If you believe this was a mistake, please let us know!" diff --git a/bot/constants.py b/bot/constants.py index c1375bb13..61f62b09c 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -201,7 +201,7 @@ class Filter(metaclass=YAMLGetter): filter_zalgo: bool filter_invites: bool filter_domains: bool - filter_rich_embeds: bool + watch_rich_embeds: bool watch_words: bool watch_tokens: bool @@ -209,7 +209,6 @@ class Filter(metaclass=YAMLGetter): notify_user_zalgo: bool notify_user_invites: bool notify_user_domains: bool - notify_user_rich_embeds: bool ping_everyone: bool guild_invite_whitelist: List[int] @@ -317,6 +316,13 @@ class CleanMessages(metaclass=YAMLGetter): message_limit: int +class Categories(metaclass=YAMLGetter): + section = "guild" + subsection = "categories" + + python_help: int + + class Channels(metaclass=YAMLGetter): section = "guild" subsection = "channels" @@ -345,6 +351,7 @@ class Channels(metaclass=YAMLGetter): off_topic_3: int python: int reddit: int + userlog: int verification: int @@ -466,6 +473,14 @@ class BigBrother(metaclass=YAMLGetter): header_message_limit: int +class Free(metaclass=YAMLGetter): + section = 'free' + + activity_timeout: int + cooldown_rate: int + cooldown_per: float + + # Debug mode DEBUG_MODE = True if 'local' in os.environ.get("SITE_URL", "local") else False diff --git a/bot/decorators.py b/bot/decorators.py index 87877ecbf..710045c10 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -10,6 +10,7 @@ from discord.ext import commands from discord.ext.commands import CheckFailure, Context from bot.constants import ERROR_REPLIES +from bot.utils.checks import with_role_check, without_role_check log = logging.getLogger(__name__) @@ -47,35 +48,24 @@ def in_channel(*channels: int, bypass_roles: typing.Container[int] = None): def with_role(*role_ids: int): - async def predicate(ctx: Context): - if not ctx.guild: # Return False in a DM - log.debug(f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. " - "This command is restricted by the with_role decorator. Rejecting request.") - return False - - for role in ctx.author.roles: - if role.id in role_ids: - log.debug(f"{ctx.author} has the '{role.name}' role, and passes the check.") - return True + """ + Returns True if the user has any one + of the roles in role_ids. + """ - log.debug(f"{ctx.author} does not have the required role to use " - f"the '{ctx.command.name}' command, so the request is rejected.") - return False + async def predicate(ctx: Context): + return with_role_check(ctx, *role_ids) return commands.check(predicate) def without_role(*role_ids: int): - async def predicate(ctx: Context): - if not ctx.guild: # Return False in a DM - log.debug(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. " - "This command is restricted by the without_role decorator. Rejecting request.") - return False + """ + Returns True if the user does not have any + of the roles in role_ids. + """ - author_roles = [role.id for role in ctx.author.roles] - check = all(role not in author_roles for role in role_ids) - log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " - f"The result of the without_role check was {check}.") - return check + async def predicate(ctx: Context): + return without_role_check(ctx, *role_ids) return commands.check(predicate) diff --git a/bot/utils/checks.py b/bot/utils/checks.py new file mode 100644 index 000000000..37dc657f7 --- /dev/null +++ b/bot/utils/checks.py @@ -0,0 +1,56 @@ +import logging + +from discord.ext.commands import Context + +log = logging.getLogger(__name__) + + +def with_role_check(ctx: Context, *role_ids: int) -> bool: + """ + Returns True if the user has any one + of the roles in role_ids. + """ + + if not ctx.guild: # Return False in a DM + log.trace(f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. " + "This command is restricted by the with_role decorator. Rejecting request.") + return False + + for role in ctx.author.roles: + if role.id in role_ids: + log.trace(f"{ctx.author} has the '{role.name}' role, and passes the check.") + return True + + log.trace(f"{ctx.author} does not have the required role to use " + f"the '{ctx.command.name}' command, so the request is rejected.") + return False + + +def without_role_check(ctx: Context, *role_ids: int) -> bool: + """ + Returns True if the user does not have any + of the roles in role_ids. + """ + + if not ctx.guild: # Return False in a DM + log.trace(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. " + "This command is restricted by the without_role decorator. Rejecting request.") + return False + + author_roles = (role.id for role in ctx.author.roles) + check = all(role not in author_roles for role in role_ids) + log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The result of the without_role check was {check}.") + return check + + +def in_channel_check(ctx: Context, channel_id: int) -> bool: + """ + Checks if the command was executed + inside of the specified channel. + """ + + check = ctx.channel.id == channel_id + log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The result of the in_channel check was {check}.") + return check diff --git a/config-default.yml b/config-default.yml index 21d7f20b9..5938ae533 100644 --- a/config-default.yml +++ b/config-default.yml @@ -25,7 +25,7 @@ style: green_chevron: "<:greenchevron:418104310329769993>" red_chevron: "<:redchevron:418112778184818698>" white_chevron: "<:whitechevron:418110396973711363>" - bb_message: "<:bbmessage:472476937504423936>" + bb_message: "<:bbmessage:476273120999636992>" status_online: "<:status_online:470326272351010816>" status_idle: "<:status_idle:470326266625785866>" @@ -85,6 +85,9 @@ style: guild: id: 267624335836053506 + categories: + python_help: 356013061213126657 + channels: admins: &ADMINS 365960823622991872 announcements: 354619224620138496 @@ -111,6 +114,7 @@ guild: python: 267624335836053506 reddit: 458224812528238616 staff_lounge: &STAFF_LOUNGE 464905259261755392 + userlog: 528976905546760203 verification: 352442727016693763 ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG] @@ -137,7 +141,7 @@ filter: filter_zalgo: false filter_invites: true filter_domains: true - filter_rich_embeds: true + watch_rich_embeds: true watch_words: true watch_tokens: true @@ -146,7 +150,6 @@ filter: notify_user_zalgo: false notify_user_invites: true notify_user_domains: false - notify_user_rich_embeds: true # Filter configuration ping_everyone: true # Ping @everyone when we send a mod-alert? @@ -240,6 +243,7 @@ urls: site_infractions_type: !JOIN [*SCHEMA, *API, "/bot/infractions/type/{infraction_type}"] site_infractions_by_id: !JOIN [*SCHEMA, *API, "/bot/infractions/id/{infraction_id}"] site_infractions_user_type_current: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}/current"] + site_infractions_user_type: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}"] site_logs_api: !JOIN [*SCHEMA, *API, "/bot/logs"] site_logs_view: !JOIN [*SCHEMA, *DOMAIN, "/bot/logs"] site_names_api: !JOIN [*SCHEMA, *API, "/bot/snake_names"] @@ -336,5 +340,13 @@ big_brother: header_message_limit: 15 +free: + # Seconds to elapse for a channel + # to be considered inactive. + activity_timeout: 600 + cooldown_rate: 1 + cooldown_per: 60.0 + + config: required_keys: ['bot.token'] |