Beispiel #1
0
    async def on_thread_update(self, before: Thread, after: Thread) -> None:
        """Log thread archiving, un-archiving and name edits."""
        if self.is_channel_ignored(after.id):
            log.trace("Ignoring update of thread %s (%d)", after.mention,
                      after.id)
            return

        if before.name != after.name:
            await self.send_log_message(Icons.hash_blurple, Colour.og_blurple(
            ), "Thread name edited", (
                f"Thread {after.mention} (`{after.id}`) from {after.parent.mention} (`{after.parent.id}`): "
                f"`{before.name}` -> `{after.name}`"))
            return

        if not before.archived and after.archived:
            colour = Colours.soft_red
            action = "archived"
            icon = Icons.hash_red
        elif before.archived and not after.archived:
            colour = Colours.soft_green
            action = "un-archived"
            icon = Icons.hash_green
        else:
            return

        await self.send_log_message(icon, colour, f"Thread {action}", (
            f"Thread {after.mention} ({after.name}, `{after.id}`) from {after.parent.mention} "
            f"(`{after.parent.id}`) was {action}"))
Beispiel #2
0
    async def get_top_posts(
            self,
            subreddit: Subreddit,
            time: str = "all",
            amount: int = 5,
            paginate: bool = False) -> Union[Embed, list[tuple]]:
        """
        Get the top amount of posts for a given subreddit within a specified timeframe.

        A time of "all" will get posts from all time, "day" will get top daily posts and "week" will get the top
        weekly posts.

        The amount should be between 0 and 25 as Reddit's JSON requests only provide 25 posts at most.
        """
        embed = Embed()

        posts = await self.fetch_posts(route=f"{subreddit}/top",
                                       amount=amount,
                                       params={"t": time})
        if not posts:
            embed.title = random.choice(ERROR_REPLIES)
            embed.colour = Colour.red()
            embed.description = (
                "Sorry! We couldn't find any SFW posts from that subreddit. "
                "If this problem persists, please let us know.")

            return embed

        if paginate:
            return self.build_pagination_pages(posts, paginate=True)

        # Use only starting summary page for #reddit channel posts.
        embed.description = self.build_pagination_pages(posts, paginate=False)
        embed.colour = Colour.og_blurple()
        return embed
Beispiel #3
0
    async def list_command(self, ctx: Context) -> None:
        """
        Get a list of all extensions, including their loaded status.

        Grey indicates that the extension is unloaded.
        Green indicates that the extension is currently loaded.
        """
        embed = Embed(colour=Colour.og_blurple())
        embed.set_author(
            name="Extensions List",
            url=URLs.github_bot_repo,
            icon_url=URLs.bot_avatar
        )

        lines = []
        categories = self.group_extension_statuses()
        for category, extensions in sorted(categories.items()):
            # Treat each category as a single line by concatenating everything.
            # This ensures the paginator will not cut off a page in the middle of a category.
            category = category.replace("_", " ").title()
            extensions = "\n".join(sorted(extensions))
            lines.append(f"**{category}**\n{extensions}\n")

        log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.")
        await LinePaginator.paginate(lines, ctx, embed, scale_to_size=700, empty=False)
Beispiel #4
0
    async def on_raw_message_edit(
            self, event: discord.RawMessageUpdateEvent) -> None:
        """Log raw message edit event to message change log."""
        try:
            channel = self.bot.get_channel(int(event.data["channel_id"]))
            message = await channel.fetch_message(event.message_id)
        except discord.NotFound:  # Was deleted before we got the event
            return

        if self.is_message_blacklisted(message):
            return

        await asyncio.sleep(1)  # Wait here in case the normal event was fired

        if event.message_id in self._cached_edits:
            # It was in the cache and the normal event was fired, so we can just ignore it
            self._cached_edits.remove(event.message_id)
            return

        channel = message.channel
        channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}"

        before_response = (
            f"**Author:** {format_user(message.author)}\n"
            f"**Channel:** {channel_name} (`{channel.id}`)\n"
            f"**Message ID:** `{message.id}`\n"
            "\n"
            "This message was not cached, so the message content cannot be displayed."
        )

        after_response = (f"**Author:** {format_user(message.author)}\n"
                          f"**Channel:** {channel_name} (`{channel.id}`)\n"
                          f"**Message ID:** `{message.id}`\n"
                          "\n"
                          f"{message.clean_content}")

        await self.send_log_message(Icons.message_edit,
                                    Colour.og_blurple(),
                                    "Message edited (Before)",
                                    before_response,
                                    channel_id=Channels.message_log)

        await self.send_log_message(Icons.message_edit,
                                    Colour.og_blurple(),
                                    "Message edited (After)",
                                    after_response,
                                    channel_id=Channels.message_log)
Beispiel #5
0
    async def status(self, ctx: Context) -> None:
        """Check the current status of DEFCON mode."""
        embed = Embed(colour=Colour.og_blurple(),
                      title="DEFCON Status",
                      description=f"""
                **Threshold:** {humanize_delta(self.threshold) if self.threshold else "-"}
                **Expires:** {discord_timestamp(self.expiry, TimestampFormats.RELATIVE) if self.expiry else "-"}
                **Verification level:** {ctx.guild.verification_level.name}
                """)

        await ctx.send(embed=embed)
Beispiel #6
0
    async def server_info(self, ctx: Context) -> None:
        """Returns an embed full of server information."""
        embed = Embed(colour=Colour.og_blurple(), title="Server Information")

        created = discord_timestamp(ctx.guild.created_at,
                                    TimestampFormats.RELATIVE)
        num_roles = len(ctx.guild.roles) - 1  # Exclude @everyone

        # Server Features are only useful in certain channels
        if ctx.channel.id in (*constants.MODERATION_CHANNELS,
                              constants.Channels.dev_core,
                              constants.Channels.dev_contrib,
                              constants.Channels.bot_commands):
            features = f"\nFeatures: {', '.join(ctx.guild.features)}"
        else:
            features = ""

        # Member status
        py_invite = await self.bot.fetch_invite(constants.Guild.invite)
        online_presences = py_invite.approximate_presence_count
        offline_presences = py_invite.approximate_member_count - online_presences
        member_status = (
            f"{constants.Emojis.status_online} {online_presences:,} "
            f"{constants.Emojis.status_offline} {offline_presences:,}")

        embed.description = (f"Created: {created}"
                             f"{features}"
                             f"\nRoles: {num_roles}"
                             f"\nMember status: {member_status}")
        embed.set_thumbnail(url=ctx.guild.icon.url)

        # Members
        total_members = f"{ctx.guild.member_count:,}"
        member_counts = self.get_member_counts(ctx.guild)
        member_info = "\n".join(f"{role}: {count}"
                                for role, count in member_counts.items())
        embed.add_field(name=f"Members: {total_members}", value=member_info)

        # Channels
        total_channels = len(ctx.guild.channels)
        channel_counts = self.get_channel_type_counts(ctx.guild)
        channel_info = "\n".join(
            f"{channel.title()}: {count}"
            for channel, count in sorted(channel_counts.items()))
        embed.add_field(name=f"Channels: {total_channels}", value=channel_info)

        # Additional info if ran in moderation channels
        if is_mod_channel(ctx.channel):
            embed.add_field(name="Moderation:",
                            value=self.get_extended_server_info(ctx))

        await ctx.send(embed=embed)
Beispiel #7
0
class Action(Enum):
    """Defcon Action."""

    ActionInfo = namedtuple('LogInfoDetails',
                            ['icon', 'emoji', 'color', 'template'])

    SERVER_OPEN = ActionInfo(Icons.defcon_unshutdown, Emojis.defcon_unshutdown,
                             Colours.soft_green, "")
    SERVER_SHUTDOWN = ActionInfo(Icons.defcon_shutdown, Emojis.defcon_shutdown,
                                 Colours.soft_red, "")
    DURATION_UPDATE = ActionInfo(Icons.defcon_update, Emojis.defcon_update,
                                 Colour.og_blurple(),
                                 "**Threshold:** {threshold}\n\n")
Beispiel #8
0
    async def weekly_command(self,
                             ctx: Context,
                             subreddit: Subreddit = "r/Python") -> None:
        """Send the top posts of this week from a given subreddit."""
        async with ctx.typing():
            pages = await self.get_top_posts(subreddit=subreddit,
                                             time="week",
                                             paginate=True)

        await ctx.send(f"Here are this week's top {subreddit} posts!")
        embed = Embed(color=Colour.og_blurple())

        await ImagePaginator.paginate(pages, ctx, embed)
Beispiel #9
0
    async def on_guild_update(self, before: discord.Guild,
                              after: discord.Guild) -> None:
        """Log guild update event to mod log."""
        if before.id != GuildConstant.id:
            return

        diff = DeepDiff(before, after)
        changes = []
        done = []

        diff_values = diff.get("values_changed", {})
        diff_values.update(diff.get("type_changes", {}))

        for key, value in diff_values.items():
            if not key:  # Not sure why, but it happens
                continue

            key = key[5:]  # Remove "root." prefix

            if "[" in key:
                key = key.split("[", 1)[0]

            if "." in key:
                key = key.split(".", 1)[0]

            if key in done:
                continue

            new = value["new_value"]
            old = value["old_value"]

            changes.append(f"**{key.title()}:** `{old}` **→** `{new}`")

            done.append(key)

        if not changes:
            return

        message = ""

        for item in sorted(changes):
            message += f"{Emojis.bullet} {item}\n"

        message = f"**{after.name}** (`{after.id}`)\n{message}"

        await self.send_log_message(
            Icons.guild_update,
            Colour.og_blurple(),
            "Guild updated",
            message,
            thumbnail=after.icon.with_static_format("png"))
Beispiel #10
0
    async def on_member_update(self, before: discord.Member,
                               after: discord.Member) -> None:
        """Log member update event to user log."""
        if before.guild.id != GuildConstant.id:
            return

        if before.id in self._ignored[Event.member_update]:
            self._ignored[Event.member_update].remove(before.id)
            return

        changes = self.get_role_diff(before.roles, after.roles)

        # The regex is a simple way to exclude all sequence and mapping types.
        diff = DeepDiff(before, after, exclude_regex_paths=r".*\[.*")

        # A type change seems to always take precedent over a value change. Furthermore, it will
        # include the value change along with the type change anyway. Therefore, it's OK to
        # "overwrite" values_changed; in practice there will never even be anything to overwrite.
        diff_values = {
            **diff.get("values_changed", {}),
            **diff.get("type_changes", {})
        }

        for attr, value in diff_values.items():
            if not attr:  # Not sure why, but it happens.
                continue

            attr = attr[5:]  # Remove "root." prefix.
            attr = attr.replace("_", " ").replace(".", " ").capitalize()

            new = value.get("new_value")
            old = value.get("old_value")

            changes.append(f"**{attr}:** `{old}` **→** `{new}`")

        if not changes:
            return

        message = ""

        for item in sorted(changes):
            message += f"{Emojis.bullet} {item}\n"

        message = f"{format_user(after)}\n{message}"

        await self.send_log_message(icon_url=Icons.user_update,
                                    colour=Colour.og_blurple(),
                                    title="Member updated",
                                    text=message,
                                    thumbnail=after.display_avatar.url,
                                    channel_id=Channels.user_log)
Beispiel #11
0
    async def subreddits_command(self, ctx: Context) -> None:
        """Send a paginated embed of all the subreddits we're relaying."""
        embed = Embed()
        embed.title = "Relayed subreddits."
        embed.colour = Colour.og_blurple()

        await LinePaginator.paginate(
            RedditConfig.subreddits,
            ctx,
            embed,
            footer_text=
            "Use the reddit commands along with these to view their posts.",
            empty=False,
            max_lines=15)
Beispiel #12
0
    async def on_guild_role_update(self, before: discord.Role,
                                   after: discord.Role) -> None:
        """Log role update event to mod log."""
        if before.guild.id != GuildConstant.id:
            return

        diff = DeepDiff(before, after)
        changes = []
        done = []

        diff_values = diff.get("values_changed", {})
        diff_values.update(diff.get("type_changes", {}))

        for key, value in diff_values.items():
            if not key:  # Not sure why, but it happens
                continue

            key = key[5:]  # Remove "root." prefix

            if "[" in key:
                key = key.split("[", 1)[0]

            if "." in key:
                key = key.split(".", 1)[0]

            if key in done or key == "color":
                continue

            if key in ROLE_CHANGES_UNSUPPORTED:
                changes.append(f"**{key.title()}** updated")
            else:
                new = value["new_value"]
                old = value["old_value"]

                changes.append(f"**{key.title()}:** `{old}` **→** `{new}`")

            done.append(key)

        if not changes:
            return

        message = ""

        for item in sorted(changes):
            message += f"{Emojis.bullet} {item}\n"

        message = f"**{after.name}** (`{after.id}`)\n{message}"

        await self.send_log_message(Icons.crown_blurple, Colour.og_blurple(),
                                    "Role updated", message)
Beispiel #13
0
    async def on_member_unban(self, guild: discord.Guild,
                              member: discord.User) -> None:
        """Log member unban event to mod log."""
        if guild.id != GuildConstant.id:
            return

        if member.id in self._ignored[Event.member_unban]:
            self._ignored[Event.member_unban].remove(member.id)
            return

        await self.send_log_message(Icons.user_unban,
                                    Colour.og_blurple(),
                                    "User unbanned",
                                    format_user(member),
                                    thumbnail=member.display_avatar.url,
                                    channel_id=Channels.mod_log)
Beispiel #14
0
    async def roles_info(self, ctx: Context) -> None:
        """Returns a list of all roles and their corresponding IDs."""
        # Sort the roles alphabetically and remove the @everyone role
        roles = sorted(ctx.guild.roles[1:], key=lambda role: role.name)

        # Build a list
        role_list = []
        for role in roles:
            role_list.append(f"`{role.id}` - {role.mention}")

        # Build an embed
        embed = Embed(
            title=
            f"Role information (Total {len(roles)} role{'s' * (len(role_list) > 1)})",
            colour=Colour.og_blurple())

        await LinePaginator.paginate(role_list, ctx, embed, empty=False)
Beispiel #15
0
    async def info(self, ctx: commands.Context, member: Member) -> None:
        """
        Send an info embed about the member with the team they're in.

        The team is found by searching the permissions of the team channels.
        """
        channel = self.team_channel(ctx.guild, member)
        if not channel:
            await ctx.send(":x: I can't find the team channel for this member."
                           )
            return

        embed = Embed(title=str(member), colour=Colour.og_blurple())
        embed.add_field(name="Team",
                        value=self.team_name(channel),
                        inline=True)

        await ctx.send(embed=embed)
Beispiel #16
0
    async def rules(self, ctx: Context, rules: Greedy[int]) -> None:
        """Provides a link to all rules or, if specified, displays specific rule(s)."""
        rules_embed = Embed(title="Rules",
                            color=Colour.og_blurple(),
                            url="https://www.pythondiscord.com/pages/rules")

        if not rules:
            # Rules were not submitted. Return the default description.
            rules_embed.description = (
                "The rules and guidelines that apply to this community can be found on"
                " our [rules page](https://www.pythondiscord.com/pages/rules). We expect"
                " all members of the community to have read and understood these."
            )

            await ctx.send(embed=rules_embed)
            return

        full_rules = await self.bot.api_client.get(
            "rules", params={"link_format": "md"})

        # Remove duplicates and sort the rule indices
        rules = sorted(set(rules))

        invalid = ", ".join(
            str(index) for index in rules
            if index < 1 or index > len(full_rules))

        if invalid:
            await ctx.send(
                shorten(":x: Invalid rule indices: " + invalid,
                        75,
                        placeholder=" ..."))
            return

        for rule in rules:
            self.bot.stats.incr(f"rule_uses.{rule}")

        final_rules = tuple(f"**{pick}.** {full_rules[pick - 1]}"
                            for pick in rules)

        await LinePaginator.paginate(final_rules,
                                     ctx,
                                     rules_embed,
                                     max_lines=3)
Beispiel #17
0
        # Show more verbose output in moderation channels for infractions and nominations
        if is_mod_channel(ctx.channel):
            fields.append(await self.expanded_user_infraction_counts(user))
            fields.append(await self.user_nomination_counts(user))
        else:
            fields.append(await self.basic_user_infraction_counts(user))

        # Let's build the embed now
        embed = Embed(title=name, description=" ".join(badges))

        for field_name, field_content in fields:
            embed.add_field(name=field_name, value=field_content, inline=False)

        embed.set_thumbnail(url=user.display_avatar.url)
        embed.colour = user.colour if user.colour != Colour.default(
        ) else Colour.og_blurple()

        return embed

    async def basic_user_infraction_counts(
            self, user: MemberOrUser) -> Tuple[str, str]:
        """Gets the total and active infraction counts for the given `member`."""
        infractions = await self.bot.api_client.get('bot/infractions',
                                                    params={
                                                        'hidden': 'False',
                                                        'user__id':
                                                        str(user.id)
                                                    })

        total_infractions = len(infractions)
        active_infractions = sum(infraction['active']
Beispiel #18
0
    async def zen(self,
                  ctx: Context,
                  *,
                  search_value: Union[int, str, None] = None) -> None:
        """
        Show the Zen of Python.

        Without any arguments, the full Zen will be produced.
        If an integer is provided, the line with that index will be produced.
        If a string is provided, the line which matches best will be produced.
        """
        embed = Embed(colour=Colour.og_blurple(),
                      title="The Zen of Python",
                      description=ZEN_OF_PYTHON)

        if search_value is None:
            embed.title += ", by Tim Peters"
            await ctx.send(embed=embed)
            return

        zen_lines = ZEN_OF_PYTHON.splitlines()

        # handle if it's an index int
        if isinstance(search_value, int):
            upper_bound = len(zen_lines) - 1
            lower_bound = -1 * len(zen_lines)
            if not (lower_bound <= search_value <= upper_bound):
                raise BadArgument(
                    f"Please provide an index between {lower_bound} and {upper_bound}."
                )

            embed.title += f" (line {search_value % len(zen_lines)}):"
            embed.description = zen_lines[search_value]
            await ctx.send(embed=embed)
            return

        # Try to handle first exact word due difflib.SequenceMatched may use some other similar word instead
        # exact word.
        for i, line in enumerate(zen_lines):
            for word in line.split():
                if word.lower() == search_value.lower():
                    embed.title += f" (line {i}):"
                    embed.description = line
                    await ctx.send(embed=embed)
                    return

        # handle if it's a search string and not exact word
        matcher = difflib.SequenceMatcher(None, search_value.lower())

        best_match = ""
        match_index = 0
        best_ratio = 0

        for index, line in enumerate(zen_lines):
            matcher.set_seq2(line.lower())

            # the match ratio needs to be adjusted because, naturally,
            # longer lines will have worse ratios than shorter lines when
            # fuzzy searching for keywords. this seems to work okay.
            adjusted_ratio = (len(line) - 5)**0.5 * matcher.ratio()

            if adjusted_ratio > best_ratio:
                best_ratio = adjusted_ratio
                best_match = line
                match_index = index

        if not best_match:
            raise BadArgument(
                "I didn't get a match! Please try again with a different search term."
            )

        embed.title += f" (line {match_index}):"
        embed.description = best_match
        await ctx.send(embed=embed)
Beispiel #19
0
    async def on_voice_state_update(self, member: discord.Member,
                                    before: discord.VoiceState,
                                    after: discord.VoiceState) -> None:
        """Log member voice state changes to the voice log channel."""
        if (member.guild.id != GuildConstant.id or
            (before.channel and self.is_channel_ignored(before.channel.id)) or
            (after.channel and self.is_channel_ignored(after.channel.id))):
            return

        if member.id in self._ignored[Event.voice_state_update]:
            self._ignored[Event.voice_state_update].remove(member.id)
            return

        # Exclude all channel attributes except the name.
        diff = DeepDiff(
            before,
            after,
            exclude_paths=("root.session_id", "root.afk"),
            exclude_regex_paths=r"root\.channel\.(?!name)",
        )

        # A type change seems to always take precedent over a value change. Furthermore, it will
        # include the value change along with the type change anyway. Therefore, it's OK to
        # "overwrite" values_changed; in practice there will never even be anything to overwrite.
        diff_values = {
            **diff.get("values_changed", {}),
            **diff.get("type_changes", {})
        }

        icon = Icons.voice_state_blue
        colour = Colour.og_blurple()
        changes = []

        for attr, values in diff_values.items():
            if not attr:  # Not sure why, but it happens.
                continue

            old = values["old_value"]
            new = values["new_value"]

            attr = attr[5:]  # Remove "root." prefix.
            attr = VOICE_STATE_ATTRIBUTES.get(
                attr,
                attr.replace("_", " ").capitalize())

            changes.append(f"**{attr}:** `{old}` **→** `{new}`")

            # Set the embed icon and colour depending on which attribute changed.
            if any(name in attr for name in ("Channel", "deaf", "mute")):
                if new is None or new is True:
                    # Left a channel or was muted/deafened.
                    icon = Icons.voice_state_red
                    colour = Colours.soft_red
                elif old is None or old is True:
                    # Joined a channel or was unmuted/undeafened.
                    icon = Icons.voice_state_green
                    colour = Colours.soft_green

        if not changes:
            return

        message = "\n".join(f"{Emojis.bullet} {item}"
                            for item in sorted(changes))
        message = f"{format_user(member)}\n{message}"

        await self.send_log_message(icon_url=icon,
                                    colour=colour,
                                    title="Voice state updated",
                                    text=message,
                                    thumbnail=member.display_avatar.url,
                                    channel_id=Channels.voice_log)
Beispiel #20
0
    async def on_message_edit(self, msg_before: discord.Message,
                              msg_after: discord.Message) -> None:
        """Log message edit event to message change log."""
        if self.is_message_blacklisted(msg_before):
            return

        self._cached_edits.append(msg_before.id)

        if msg_before.content == msg_after.content:
            return

        channel = msg_before.channel
        channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}"

        cleaned_contents = (escape_markdown(msg.clean_content).split()
                            for msg in (msg_before, msg_after))
        # Getting the difference per words and group them by type - add, remove, same
        # Note that this is intended grouping without sorting
        diff = difflib.ndiff(*cleaned_contents)
        diff_groups = tuple(
            (diff_type, tuple(s[2:] for s in diff_words))
            for diff_type, diff_words in itertools.groupby(diff,
                                                           key=lambda s: s[0]))

        content_before: t.List[str] = []
        content_after: t.List[str] = []

        for index, (diff_type, words) in enumerate(diff_groups):
            sub = ' '.join(words)
            if diff_type == '-':
                content_before.append(f"[{sub}](http://o.hi)")
            elif diff_type == '+':
                content_after.append(f"[{sub}](http://o.hi)")
            elif diff_type == ' ':
                if len(words) > 2:
                    sub = (
                        f"{words[0] if index > 0 else ''}"
                        " ... "
                        f"{words[-1] if index < len(diff_groups) - 1 else ''}")
                content_before.append(sub)
                content_after.append(sub)

        response = (f"**Author:** {format_user(msg_before.author)}\n"
                    f"**Channel:** {channel_name} (`{channel.id}`)\n"
                    f"**Message ID:** `{msg_before.id}`\n"
                    "\n"
                    f"**Before**:\n{' '.join(content_before)}\n"
                    f"**After**:\n{' '.join(content_after)}\n"
                    "\n"
                    f"[Jump to message]({msg_after.jump_url})")

        if msg_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 = msg_before.edited_at
            delta = humanize_delta(
                relativedelta(msg_after.edited_at, msg_before.edited_at))
            footer = f"Last edited {delta} ago"
        else:
            # Message was not previously edited, use the created_at datetime as the baseline, no
            # delta calculation needed
            timestamp = msg_before.created_at
            footer = None

        await self.send_log_message(Icons.message_edit,
                                    Colour.og_blurple(),
                                    "Message edited",
                                    response,
                                    channel_id=Channels.message_log,
                                    timestamp_override=timestamp,
                                    footer=footer)
Beispiel #21
0
    async def on_guild_channel_update(self, before: GUILD_CHANNEL,
                                      after: GuildChannel) -> None:
        """Log channel update event to mod log."""
        if before.guild.id != GuildConstant.id:
            return

        if before.id in self._ignored[Event.guild_channel_update]:
            self._ignored[Event.guild_channel_update].remove(before.id)
            return

        # Two channel updates are sent for a single edit: 1 for topic and 1 for category change.
        # TODO: remove once support is added for ignoring multiple occurrences for the same channel.
        help_categories = (Categories.help_available, Categories.help_dormant,
                           Categories.help_in_use)
        if after.category and after.category.id in help_categories:
            return

        diff = DeepDiff(before, after)
        changes = []
        done = []

        diff_values = diff.get("values_changed", {})
        diff_values.update(diff.get("type_changes", {}))

        for key, value in diff_values.items():
            if not key:  # Not sure why, but it happens
                continue

            key = key[5:]  # Remove "root." prefix

            if "[" in key:
                key = key.split("[", 1)[0]

            if "." in key:
                key = key.split(".", 1)[0]

            if key in done or key in CHANNEL_CHANGES_SUPPRESSED:
                continue

            if key in CHANNEL_CHANGES_UNSUPPORTED:
                changes.append(f"**{key.title()}** updated")
            else:
                new = value["new_value"]
                old = value["old_value"]

                # Discord does not treat consecutive backticks ("``") as an empty inline code block, so the markdown
                # formatting is broken when `new` and/or `old` are empty values. "None" is used for these cases so
                # formatting is preserved.
                changes.append(
                    f"**{key.title()}:** `{old or 'None'}` **→** `{new or 'None'}`"
                )

            done.append(key)

        if not changes:
            return

        message = ""

        for item in sorted(changes):
            message += f"{Emojis.bullet} {item}\n"

        if after.category:
            message = f"**{after.category}/#{after.name} (`{after.id}`)**\n{message}"
        else:
            message = f"**#{after.name}** (`{after.id}`)\n{message}"

        await self.send_log_message(Icons.hash_blurple, Colour.og_blurple(),
                                    "Channel updated", message)