diff options
| -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 | 32 | ||||
| -rw-r--r-- | bot/cogs/filtering.py | 66 | ||||
| -rw-r--r-- | bot/cogs/free.py | 127 | ||||
| -rw-r--r-- | bot/cogs/help.py | 12 | ||||
| -rw-r--r-- | bot/cogs/information.py | 35 | ||||
| -rw-r--r-- | bot/cogs/modlog.py | 55 | ||||
| -rw-r--r-- | bot/cogs/tags.py | 2 | ||||
| -rw-r--r-- | bot/cogs/token_remover.py | 5 | ||||
| -rw-r--r-- | bot/constants.py | 17 | ||||
| -rw-r--r-- | bot/decorators.py | 36 | ||||
| -rw-r--r-- | bot/utils/checks.py | 56 | ||||
| -rw-r--r-- | config-default.yml | 34 | 
16 files changed, 482 insertions, 95 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..f0baecd4b 100644 --- a/bot/cogs/events.py +++ b/bot/cogs/events.py @@ -25,6 +25,7 @@ class Events:      def __init__(self, bot: Bot):          self.bot = bot +        self.headers = {"X-API-KEY": Keys.site_api}      @property      def mod_log(self) -> ModLog: @@ -103,6 +104,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 @@ -236,6 +260,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: diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 247ee26b8..570d6549f 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -45,6 +45,7 @@ class Filtering:                  "enabled": Filter.filter_zalgo,                  "function": self._has_zalgo,                  "type": "filter", +                "content_only": True,                  "user_notification": Filter.notify_user_zalgo,                  "notification_msg": (                      "Your post has been removed for abusing Unicode character rendering (aka Zalgo text). " @@ -55,6 +56,7 @@ class Filtering:                  "enabled": Filter.filter_invites,                  "function": self._has_invites,                  "type": "filter", +                "content_only": True,                  "user_notification": Filter.notify_user_invites,                  "notification_msg": (                      f"Per Rule 10, your invite link has been removed. {_staff_mistake_str}\n\n" @@ -65,20 +67,36 @@ class Filtering:                  "enabled": Filter.filter_domains,                  "function": self._has_urls,                  "type": "filter", +                "content_only": True,                  "user_notification": Filter.notify_user_domains,                  "notification_msg": (                      f"Your URL has been removed because it matched a blacklisted domain. {_staff_mistake_str}"                  )              }, +            "filter_rich_embeds": { +                "enabled": Filter.filter_rich_embeds, +                "function": self._has_rich_embed, +                "type": "filter", +                "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,                  "function": self._has_watchlist_words,                  "type": "watchlist", +                "content_only": True,              },              "watch_tokens": {                  "enabled": Filter.watch_tokens,                  "function": self._has_watchlist_tokens,                  "type": "watchlist", +                "content_only": True,              },          } @@ -121,12 +139,35 @@ class Filtering:          # If none of the above, we can start filtering.          if filter_message:              for filter_name, _filter in self.filters.items(): -                  # Is this specific filter enabled in the config?                  if _filter["enabled"]: -                    triggered = await _filter["function"](msg.content) +                    # Does the filter only need the message content or the full message? +                    if _filter["content_only"]: +                        triggered = await _filter["function"](msg.content) +                    else: +                        triggered = await _filter["function"](msg)                      if triggered: +                        # If this is a filter (not a watchlist), we should delete the message. +                        if _filter["type"] == "filter": +                            try: +                                # Embeds (can?) trigger both the `on_message` and `on_message_edit` +                                # event handlers, triggering filtering twice for the same message. +                                # +                                # If `on_message`-triggered filtering already deleted the message +                                # then `on_message_edit`-triggered filtering will raise exception +                                # since the message no longer exists. +                                # +                                # In addition, to avoid sending two notifications to the user, the +                                # logs, and mod_alert, we return if the message no longer exists. +                                await msg.delete() +                            except discord.errors.NotFound: +                                return + +                            # Notify the user if the filter specifies +                            if _filter["user_notification"]: +                                await self.notify_member(msg.author, _filter["notification_msg"], msg.channel) +                          if isinstance(msg.channel, DMChannel):                              channel_str = "via DM"                          else: @@ -142,6 +183,8 @@ class Filtering:                          log.debug(message) +                        additional_embeds = msg.embeds if filter_name == "filter_rich_embeds" else None +                          # Send pretty mod log embed to mod-alerts                          await self.mod_log.send_log_message(                              icon_url=Icons.filtering, @@ -151,16 +194,9 @@ class Filtering:                              thumbnail=msg.author.avatar_url_as(static_format="png"),                              channel_id=Channels.mod_alerts,                              ping_everyone=Filter.ping_everyone, +                            additional_embeds=additional_embeds,                          ) -                        # If this is a filter (not a watchlist), we should delete the message. -                        if _filter["type"] == "filter": -                            await msg.delete() - -                            # Notify the user if the filter specifies -                            if _filter["user_notification"]: -                                await self.notify_member(msg.author, _filter["notification_msg"], msg.channel) -                          break  # We don't want multiple filters to trigger      @staticmethod @@ -272,6 +308,16 @@ class Filtering:                  return True          return False +    @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 +        """ +        if msg.embeds: +            return any(embed.type == "rich" for embed in msg.embeds) +        return False +      async def notify_member(self, filtered_member: Member, reason: str, channel: TextChannel):          """          Notify filtered_member about a moderation action with the reason str 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 d30ff0dfb..ded068123 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -6,6 +6,7 @@ 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 @@ -14,6 +15,7 @@ from bot.pagination import (      LEFT_EMOJI, LinePaginator, RIGHT_EMOJI,  ) +  REACTIONS = {      FIRST_EMOJI: 'first',      LEFT_EMOJI: 'back', @@ -427,7 +429,15 @@ class HelpSession:                      # see if the user can run the command                      strikeout = '' -                    can_run = await command.can_run(self._ctx) + +                    # Patch to make the !help command work outside of #bot-commands again +                    # This probably needs a proper rewrite, but this will make it work in +                    # the mean time. +                    try: +                        can_run = await command.can_run(self._ctx) +                    except CheckFailure: +                        can_run = False +                      if not can_run:                          # skip if we don't show commands they can't run                          if self._only_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/modlog.py b/bot/cogs/modlog.py index 0561b5afb..55611c5e4 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 +            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_override: 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_override: +            embed.set_footer(text=footer_override) + +        if thumbnail:              embed.set_thumbnail(url=thumbnail)          if ping_everyone: @@ -125,7 +139,15 @@ class ModLog:              else:                  content = "@everyone" -        await self.bot.get_channel(channel_id).send(content=content, embed=embed, files=files) +        channel = self.bot.get_channel(channel_id) +        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: @@ -668,14 +690,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_override=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 bbe6c1604..be713cef2 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -201,6 +201,7 @@ class Filter(metaclass=YAMLGetter):      filter_zalgo: bool      filter_invites: bool      filter_domains: bool +    filter_rich_embeds: bool      watch_words: bool      watch_tokens: bool @@ -208,6 +209,7 @@ 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] @@ -315,6 +317,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" @@ -464,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 ad87e44ac..b6427b489 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 @@ -134,17 +137,19 @@ guild:  filter:      # What do we filter? -    filter_zalgo:   false -    filter_invites: true -    filter_domains: true -    watch_words:    true -    watch_tokens:   true +    filter_zalgo:       false +    filter_invites:     true +    filter_domains:     true +    filter_rich_embeds: false +    watch_words:        true +    watch_tokens:       true      # Notify user on filter?      # Notifications are not expected for "watchlist" type filters -    notify_user_zalgo:   false -    notify_user_invites: true -    notify_user_domains: false +    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? @@ -154,6 +159,8 @@ filter:          - 267624335836053506  # Python Discord          - 440186186024222721  # Python Discord: ModLog Emojis          - 273944235143593984  # STEM +        - 348658686962696195  # RLBot +        - 531221516914917387  # Pallets      domain_blacklist:          - pornhub.com @@ -236,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"] @@ -332,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'] | 
