diff --git a/botlogging/logger.py b/botlogging/logger.py index 6331cc47..41eee000 100644 --- a/botlogging/logger.py +++ b/botlogging/logger.py @@ -199,6 +199,7 @@ async def send_log( console_only: bool = False, embed: discord.Embed = None, exception: Exception = None, + embed_as_is: bool = False, ) -> None: """A comprehensive logging system This will log a message, embed, and/or exception to the console and discord @@ -238,17 +239,18 @@ async def send_log( if console_only or not self.send: return - # Ensure message is never too long - if len(message) > 4000: - message = f"{message[:4000]}..." - # Get the appropriate target to send to on discord log_channel = await self.get_discord_target(channel) - if embed: - embed = log_level.embed(message).modify_embed(embed) - else: - embed = log_level.embed(message) + if not embed_as_is: + # Ensure message is never too long + if len(message) > 4000: + message = f"{message[:4000]}..." + + if embed: + embed = log_level.embed(message).modify_embed(embed) + else: + embed = log_level.embed(message) try: await log_channel.send(embed=embed) diff --git a/configuration/config.default.json b/configuration/config.default.json index 91cd2ba6..82af4605 100644 --- a/configuration/config.default.json +++ b/configuration/config.default.json @@ -23,6 +23,7 @@ "core_guild_id": "", "core_logging_channel": "", "core_member_events_channel": "", + "core_message_events_channel": "", "core_nickname_filter": false, "core_private_channels": [], "duck_allow_manipulation": true, diff --git a/configuration/config.meta.json b/configuration/config.meta.json index c543e9a6..7f4fa682 100644 --- a/configuration/config.meta.json +++ b/configuration/config.meta.json @@ -81,7 +81,7 @@ }, "core_guild_events_channel": { "datatype": "discord.TextChannel", - "description": "The channel to log guild events to. This includes message events" + "description": "The channel to log guild events to." }, "core_guild_id": { "datatype": "discord.Guild", @@ -95,6 +95,10 @@ "datatype": "discord.TextChannel", "description": "The channel to log member events to." }, + "core_message_events_channel": { + "datatype": "discord.TextChannel", + "description": "The channel to log message events to." + }, "core_nickname_filter": { "datatype": "bool", "description": "Whether to run the nickname filter or not" diff --git a/modules/moderation/events.py b/modules/moderation/events.py index 6d58df65..494e2e01 100644 --- a/modules/moderation/events.py +++ b/modules/moderation/events.py @@ -5,7 +5,7 @@ import datetime import sys from collections.abc import Sequence -from typing import TYPE_CHECKING, Self +from typing import TYPE_CHECKING, Any, Self import discord from discord.ext import commands @@ -13,6 +13,7 @@ import configuration from botlogging import LogContext, LogLevel from core import auxiliary, cogs +from modules.moderation import logger if TYPE_CHECKING: import bot @@ -27,11 +28,204 @@ async def setup(bot: bot.TechSupportBot) -> None: await bot.add_cog(EventLogger(bot=bot)) +class EventEmbed(discord.Embed): + """This subclass of embed contains several functions to create consistent fields for displaying various types of data in the event logs""" + + def __init__(self, *, title, description) -> None: + super().__init__( + title=title, + description=description, + colour=discord.Colour.orange(), + timestamp=discord.utils.utcnow(), + ) + + def setEventAuthor(self, author: discord.Member) -> None: + self.set_author( + name=str(author.display_name), + icon_url=author.display_avatar.url, + ) + + def addMemberField(self: Self, title: str, member: discord.Member) -> None: + self.add_field( + name=title, + value=( + f"**User:** {member.mention}\n" + f"**Name:** {member.name}\n" + f"**ID:** {member.id}" + ), + inline=True, + ) + + def addMessageContentField( + self: Self, title: str, message: discord.Message + ) -> None: + if not message.clean_content: + content = "*No content*" + elif len(message.clean_content) > 1024: + content = message.clean_content[:1021] + "..." + else: + content = message.clean_content + self.add_field( + name=title, + value=content, + inline=True, + ) + + def addMessageInfoField(self: Self, title: str, message: discord.Message) -> None: + self.add_field( + name=title, + value=( + f"**Message Content:** {message.clean_content[:50]}\n" + f"**Message Author:** {message.author.name} ({message.author.mention})\n" + f"**Message ID:** {message.id}\n" + f"**Sent:** " + f"()" + ), + ) + + def addChannelField( + self: Self, title: str, channel: discord.abc.GuildChannel + ) -> None: + self.add_field( + name=title, + value=( + f"**Channel:** {channel.mention}\n" + f"**Name:** #{channel.name}\n" + f"**Type:** {channel.type.name}\n" + f"**ID:** {channel.id}" + ), + inline=True, + ) + + def addEmojiField( + self: Self, title: str, emoji: discord.Emoji | discord.PartialEmoji | str + ) -> None: + # This is to better display custom emotes + if isinstance(emoji, (discord.Emoji, discord.PartialEmoji)): + emoji_value = ( + f"**Emoji:** {emoji}\n" + f"**Name:** {emoji.name}\n" + f"**ID:** {emoji.id}" + ) + else: + emoji_value = f"**Emoji:** {emoji}" + + self.add_field(name=title, value=emoji_value) + + def addPollField(self: Self, title: str, poll: discord.Poll) -> None: + self.add_field( + name=title, + value=( + f"**Question:** {poll.question}\n" + f"**Duration:** {poll.duration}\n" + f"**Answers:** {', '.join([answer.text for answer in poll.answers])}" + ), + inline=True, + ) + + def addPollAnswerField(self: Self, title: str, answer: discord.PollAnswer) -> None: + self.add_field( + name=title, + value=(f"**Answer:** {answer.text}\n**ID:** {answer.id}"), + inline=True, + ) + + def addPropertyChangeFields( + self: Self, properties: list[str], before: Any, after: Any + ) -> bool: + changes = [] + + for attr in properties: + old_value = getattr(before, attr, None) + new_value = getattr(after, attr, None) + + # If both are lists, sort them before comparing + if isinstance(old_value, list) and isinstance(new_value, list): + old_compare = sorted(old_value, key=str) + new_compare = sorted(new_value, key=str) + else: + old_compare = old_value + new_compare = new_value + + if old_compare != new_compare: + changes.append((attr, old_value, new_value)) + + if changes: + for attr, old_value, new_value in changes: + # Make the property name prettier + field_name = attr.replace("_", " ").title() + + # Special formatting for categories + if attr == "category": + old_value = old_value.mention if old_value else "None" + new_value = new_value.mention if new_value else "None" + + # Better formatting for booleans + elif isinstance(old_value, bool): + old_value = "Yes" if old_value else "No" + new_value = "Yes" if new_value else "No" + + self.add_field( + name=field_name, + value=f"**Old:** {old_value}\n**New:** {new_value}", + inline=True, + ) + + return True + + return False + + class EventLogger(cogs.BaseCog): """This is the cog that holds all of the discord event listeners For the explicit purpose of logging, not taking further action """ + CONFIG_MAP: dict[str, str] = { + "bot": "core_logging_channel", + "guild": "core_guild_events_channel", + "member": "core_member_events_channel", + "message": "core_message_events_channel", + } + + async def send_event_log( + self: Self, + guild: discord.Guild, + log_location: str, + string_message: str, + embed_message: discord.Embed, + channel_location: discord.abc.GuildChannel = None, + ) -> None: + """This sends a log to discord and the console for the event + + Args: + guild (discord.Guild): The guild the event happened in + log_location (str): The location to log, string in CONFIG_MAP + string_message (str): The string message to send to the console + embed_message (discord.Embed): The embed to send to the configured log channel + channel_location (discord.abc.GuildChannel, optional): + The channel the event happened in, if applicable. Defaults to None. + """ + + # Do nothing if events is disabled in current guild + if not self.extension_enabled(guild): + return + + context = LogContext(guild=guild, channel=channel_location) + message_header = f"Events for {guild.name} ({guild.id}): " + log_channel = self.CONFIG_MAP[log_location] + log_channel_id = configuration.get_config_entry(guild.id, log_channel) + await self.bot.logger.send_log( + message=message_header + string_message, + level=LogLevel.INFO, + context=context, + channel=log_channel_id, + embed=embed_message, + embed_as_is=True, + ) + + # Message events + @commands.Cog.listener() async def on_message_edit( self: Self, before: discord.Message, after: discord.Message @@ -39,42 +233,86 @@ async def on_message_edit( """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_message_edit Args: - before (discord.Message): The previous version of the message - after (discord.Message): The current version of the message + payload (discord.RawMessageUpdateEvent): The raw payload object for the message edit events """ - # this seems to spam, not sure why - if before.content == after.content: + # If for some reason there is no message object, log nothing + if not after or not before: return - guild = getattr(before.channel, "guild", None) + guild = getattr(after.channel, "guild", None) + + # Ignore all message edit events in DMs + if not guild: + return # Ignore ephemeral slash command messages - if not guild and before.type == discord.MessageType.chat_input_command: + if after.type == discord.MessageType.chat_input_command: return - attrs = ["content", "embeds"] - diff = auxiliary.get_object_diff(before, after, attrs) - embed = discord.Embed() - embed = auxiliary.add_diff_fields(embed, diff) - embed.add_field(name="Author", value=before.author) - embed.add_field(name="Channel", value=getattr(before.channel, "name", "DM")) - embed.add_field( - name="Server", - value=guild, - ) - embed.set_footer(text=f"Author ID: {before.author.id}") + # Message edits for content edit: + if before.content != after.content: + embed = EventEmbed( + title="Message edited", + description=f"[Jump to Message]({after.jump_url})", + ) - log_channel = configuration.get_config_entry( - before.author.id, "core_guild_events_channel" - ) + embed.setEventAuthor(after.author) + embed.addMemberField("Message Author", after.author) + embed.addChannelField("Channel", after.channel) + + old_content = before.clean_content + embed.addMessageContentField("Old Content", before) + embed.addMessageContentField("New Content", after) + + # Custom field for this event + embed.add_field( + name="Timestamps", + value=( + f"**Sent:** " + f"()\n" + f"**Edited:** " + f"()" + ), + inline=False, + ) - await self.bot.logger.send_log( - message=f"Message edit detected on message with ID {before.id}", - level=LogLevel.INFO, - context=LogContext(guild=before.channel.guild, channel=before.channel), - channel=log_channel, - embed=embed, - ) + embed.set_footer(text=f"Message ID: {after.id}") + + console_message = f"Message edit: ID: {after.id} in channel: {after.channel.name} ({after.channel.id}). Old: {old_content}, new {after.clean_content}" + + await self.send_event_log( + guild=after.guild, + log_location="message", + string_message=console_message, + embed_message=embed, + channel_location=after.channel, + ) + + # Message edits for pin update: + if before.pinned != after.pinned: + + title = "Message pinned" if after.pinned else "Message unpinned" + embed = EventEmbed( + title=title, + description=f"[Jump to Message]({after.jump_url})", + ) + + embed.setEventAuthor(after.author) + embed.addMemberField("Message Author", after.author) + embed.addChannelField("Channel", after.channel) + embed.addMessageContentField("Content", after) + + embed.set_footer(text=f"Message ID: {after.id}") + + console_message = f"Message pins changed: ID: {after.id} in channel: {after.channel.name} ({after.channel.id}). Pinned status: {after.pinned}" + + await self.send_event_log( + guild=after.guild, + log_location="message", + string_message=console_message, + embed_message=embed, + channel_location=after.channel, + ) @commands.Cog.listener() async def on_message_delete(self: Self, message: discord.Message) -> None: @@ -83,38 +321,42 @@ async def on_message_delete(self: Self, message: discord.Message) -> None: Args: message (discord.Message): The deleted message """ - guild = getattr(message.channel, "guild", None) + guild = message.guild + channel = message.channel # Ignore ephemeral slash command messages - if not guild and message.type == discord.MessageType.chat_input_command: + if message.type == discord.MessageType.chat_input_command: return - embed = discord.Embed() - embed.add_field(name="Content", value=message.content[:1024] or "None") - if len(message.content) > 1024: - embed.add_field(name="\a", value=message.content[1025:2048]) - if len(message.content) > 2048: - embed.add_field(name="\a", value=message.content[2049:3072]) - if len(message.content) > 3072: - embed.add_field(name="\a", value=message.content[3073:4096]) - embed.add_field(name="Author", value=message.author) - embed.add_field( - name="Channel", - value=getattr(message.channel, "name", "DM"), + embed = EventEmbed( + title="Message deleted", + description=f"[Jump to Message]({message.jump_url})", ) - embed.add_field(name="Server", value=getattr(guild, "name", "None")) - embed.set_footer(text=f"Author ID: {message.author.id}") - log_channel = configuration.get_config_entry( - message.author.id, "core_guild_events_channel" + embed.setEventAuthor(message.author) + embed.addMemberField("Message Author", message.author) + embed.addChannelField("Channel", message.channel) + embed.addMessageContentField("Content", message) + + embed.add_field( + name="Message Sent", + value=( + f" " + f"()" + ), + inline=False, ) - await self.bot.logger.send_log( - message=f"Message with ID {message.id} deleted", - level=LogLevel.INFO, - context=LogContext(guild=message.channel.guild, channel=message.channel), - channel=log_channel, - embed=embed, + embed.set_footer(text=f"Message ID: {message.id}") + + console_message = f"Message delete: ID: {message.id} in channel: {channel.name} ({channel.id}). Content: {message.clean_content}" + + await self.send_event_log( + guild=guild, + log_location="message", + string_message=console_message, + embed_message=embed, + channel_location=channel, ) @commands.Cog.listener() @@ -127,31 +369,52 @@ async def on_bulk_message_delete( Args: messages (list[discord.Message]): The messages that have been deleted """ - guild = getattr(messages[0].channel, "guild", None) - - unique_channels = set() - unique_servers = set() - for message in messages: - unique_channels.add(message.channel.name) - unique_servers.add( - f"{message.channel.guild.name} ({message.channel.guild.id})" - ) + channel = messages[0].channel + guild = channel.guild - embed = discord.Embed() - embed.add_field(name="Channels", value=",".join(unique_channels)) - embed.add_field(name="Servers", value=",".join(unique_servers)) + # Don't log stuff not in a guild + if not guild: + return - log_channel = configuration.get_config_entry( - guild.id, "core_guild_events_channel" + embed = EventEmbed( + title="Bulk message delete", + description="", ) - await self.bot.logger.send_log( - message=f"{len(messages)} messages bulk deleted!", - level=LogLevel.INFO, - context=LogContext( - guild=messages[0].channel.guild, channel=messages[0].channel - ), - channel=log_channel, - embed=embed, + embed.addChannelField("Channel", channel) + + description_prefix = f"{len(messages)} messages were deleted:\n" + + max_embed_length = 4096 + content_limit = 100 + + while True: + lines: list[str] = [] + + for message in messages: + clean_content = message.clean_content + + if len(clean_content) > content_limit: + clean_content = f"{clean_content[:content_limit]}..." + + lines.append(f"{message.id}, {message.author.name}: {clean_content}") + + description = description_prefix + "\n".join(lines) + + if len(description) <= max_embed_length or content_limit <= 0: + break + + content_limit -= 1 + + embed.description = description + + console_message = f"Bulk message delete: Channel: {channel.name} ({channel.id}). Amount: {len(messages)}" + + await self.send_event_log( + guild=guild, + log_location="message", + string_message=console_message, + embed_message=embed, + channel_location=channel, ) @commands.Cog.listener() @@ -164,43 +427,42 @@ async def on_reaction_add( reaction (discord.Reaction): The current state of the reaction user (discord.Member | discord.User): The user who added the reaction """ - guild = getattr(reaction.message.channel, "guild", None) + message = reaction.message + channel = message.channel + guild = getattr(channel, "guild", None) - if isinstance(reaction.message.channel, discord.DMChannel): + if isinstance(channel, discord.DMChannel): await self.bot.logger.send_log( message=( f"PM from `{user}`: added {reaction.emoji} reaction to message" - f" {reaction.message.content} in DMs" + f" {message.content} in DMs" ), level=LogLevel.INFO, ) return - embed = discord.Embed() - embed.add_field(name="Emoji", value=reaction.emoji) - embed.add_field(name="User", value=user) - embed.add_field(name="Message", value=reaction.message.content or "None") - embed.add_field(name="Message Author", value=reaction.message.author) - embed.add_field( - name="Channel", value=getattr(reaction.message.channel, "name", "DM") - ) - embed.add_field(name="Server", value=guild.name) + if not guild: + return - log_channel = configuration.get_config_entry( - guild.id, "core_guild_events_channel" + embed = EventEmbed( + title="Reaction added", + description=f"[Jump to Message]({message.jump_url})", ) - await self.bot.logger.send_log( - message=( - f"Reaction added to message with ID {reaction.message.id} by user with" - f" ID {user.id}" - ), - level=LogLevel.INFO, - context=LogContext( - guild=reaction.message.channel.guild, channel=reaction.message.channel - ), - channel=log_channel, - embed=embed, + embed.setEventAuthor(user) + embed.addEmojiField("Emoji", reaction.emoji) + embed.addMemberField("Message Author", user) + embed.addChannelField("Channel", message.channel) + embed.addMessageInfoField("Message Info", message) + + console_message = f"Reaction {reaction.emoji} added to message with ID: {message.id} by user {user.name} ({user.id})" + + await self.send_event_log( + guild=guild, + log_location="message", + string_message=console_message, + embed_message=embed, + channel_location=channel, ) @commands.Cog.listener() @@ -213,43 +475,42 @@ async def on_reaction_remove( reaction (discord.Reaction): The current state of the reaction user (discord.Member | discord.User): The user whose reaction was removed """ - guild = getattr(reaction.message.channel, "guild", None) + message = reaction.message + channel = message.channel + guild = getattr(channel, "guild", None) - if isinstance(reaction.message.channel, discord.DMChannel): + if isinstance(channel, discord.DMChannel): await self.bot.logger.send_log( message=( - f"PM from `{user}`: removed {reaction.emoji} reaction to message" - f" {reaction.message.content} in DMs" + f"PM from `{user}`: added {reaction.emoji} reaction to message" + f" {message.content} in DMs" ), level=LogLevel.INFO, ) return - embed = discord.Embed() - embed.add_field(name="Emoji", value=reaction.emoji) - embed.add_field(name="User", value=user) - embed.add_field(name="Message", value=reaction.message.content or "None") - embed.add_field(name="Message Author", value=reaction.message.author) - embed.add_field( - name="Channel", value=getattr(reaction.message.channel, "name", "DM") - ) - embed.add_field(name="Server", value=guild.name) + if not guild: + return - log_channel = configuration.get_config_entry( - guild.id, "core_guild_events_channel" + embed = EventEmbed( + title="Reaction removed", + description=f"[Jump to Message]({message.jump_url})", ) - await self.bot.logger.send_log( - message=( - f"Reaction removed from message with ID {reaction.message.id} by user" - f" with ID {user.id}" - ), - level=LogLevel.INFO, - context=LogContext( - guild=reaction.message.channel.guild, channel=reaction.message.channel - ), - channel=log_channel, - embed=embed, + embed.setEventAuthor(user) + embed.addEmojiField("Emoji", reaction.emoji) + embed.addMemberField("Message Author", user) + embed.addChannelField("Channel", message.channel) + embed.addMessageInfoField("Message Info", message) + + console_message = f"Reaction {reaction.emoji} removed from message with ID: {message.id} by user {user.name} ({user.id})" + + await self.send_event_log( + guild=guild, + log_location="message", + string_message=console_message, + embed_message=embed, + channel_location=channel, ) @commands.Cog.listener() @@ -264,208 +525,276 @@ async def on_reaction_clear( reactions (list[discord.Reaction]): The reactions that were removed """ guild = getattr(message.channel, "guild", None) + channel = message.channel - unique_emojis = set() - for reaction in reactions: - unique_emojis.add(reaction.emoji) + # Don't log messages without a guild + if not guild: + return - embed = discord.Embed() - embed.add_field(name="Emojis", value=",".join(unique_emojis)) - embed.add_field(name="Message", value=message.content or "None") - embed.add_field(name="Message Author", value=message.author) - embed.add_field(name="Channel", value=getattr(message.channel, "name", "DM")) - embed.add_field(name="Server", value=guild.name) + emoji_str = "" + total_emoji = 0 + for reaction in reactions: + emoji_str += f"`{reaction.emoji}`: {reaction.count}\n" + total_emoji += reaction.count - log_channel = configuration.get_config_entry( - guild.id, "core_guild_events_channel" + embed = EventEmbed( + title="Reactions cleared", + description=f"[Jump to Message]({message.jump_url})", ) - await self.bot.logger.send_log( - message=f"{len(reactions)} cleared from message with ID {message.id}", - level=LogLevel.INFO, - context=LogContext(guild=message.channel.guild, channel=message.channel), - channel=log_channel, - embed=embed, + embed.add_field(name="Emojis", value=emoji_str) + + embed.addChannelField("Channel", message.channel) + embed.addMessageInfoField("Message Info", message) + + console_message = f"{total_emoji} reactions cleared from message with ID: {message.id} in channel {channel.name} ({channel.id})" + + await self.send_event_log( + guild=guild, + log_location="message", + string_message=console_message, + embed_message=embed, + channel_location=channel, ) @commands.Cog.listener() - async def on_guild_channel_delete( - self: Self, channel: discord.abc.GuildChannel + async def on_poll_vote_add( + self: Self, user: discord.Member, answer: discord.PollAnswer ) -> None: - """ - See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_channel_delete + if not user.guild: + return - Args: - channel (discord.abc.GuildChannel): The channel that got deleted - """ - embed = discord.Embed() - embed.add_field(name="Channel Name", value=channel.name) - embed.add_field(name="Server", value=channel.guild.name) + guild = user.guild + message = answer.poll.message + channel = message.channel - log_channel = configuration.get_config_entry( - channel.guild.id, "core_guild_events_channel" + embed = EventEmbed( + title="Poll answered", + description=f"[Jump to Message]({message.jump_url})", ) - await self.bot.logger.send_log( - message=( - f"Channel with ID {channel.id} deleted in guild with ID" - f" {channel.guild.id}" - ), - level=LogLevel.INFO, - context=LogContext(guild=channel.guild, channel=channel), - channel=log_channel, - embed=embed, + embed.setEventAuthor(user) + embed.addPollField("Poll", answer.poll) + embed.addPollAnswerField("Answer", answer) + embed.addChannelField("Channel", channel) + embed.addMemberField("Member", user) + embed.addMessageInfoField("Message", message) + + console_message = f"User {user.name} ({user.id}) voted {answer.text} to poll message {message.id} in channel {channel.name} ({channel.id})" + + await self.send_event_log( + guild=guild, + log_location="message", + string_message=console_message, + embed_message=embed, + channel_location=channel, ) @commands.Cog.listener() - async def on_guild_channel_create( - self: Self, channel: discord.abc.GuildChannel + async def on_poll_vote_remove( + self: Self, user: discord.Member, answer: discord.PollAnswer ) -> None: - """ - See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_channel_create + if not user.guild: + return + + guild = user.guild + message = answer.poll.message + channel = message.channel + + embed = EventEmbed( + title="Poll answer removed", + description=f"[Jump to Message]({message.jump_url})", + ) + + embed.setEventAuthor(user) + embed.addPollField("Poll", answer.poll) + embed.addPollAnswerField("Answer", answer) + embed.addChannelField("Channel", channel) + embed.addMemberField("Member", user) + embed.addMessageInfoField("Message", message) + + console_message = f"User {user.name} ({user.id}) removed vote {answer.text} from a poll message {message.id} in channel {channel.name} ({channel.id})" + + await self.send_event_log( + guild=guild, + log_location="message", + string_message=console_message, + embed_message=embed, + channel_location=channel, + ) + + # Member events + + @commands.Cog.listener() + async def on_member_join(self: Self, member: discord.Member) -> None: + """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_member_join Args: - channel (discord.abc.GuildChannel): The channel that got created + member (discord.Member): The member who joined """ - embed = discord.Embed() - embed.add_field(name="Channel Name", value=channel.name) - embed.add_field(name="Server", value=channel.guild.name) - log_channel = configuration.get_config_entry( - channel.guild.id, "core_guild_events_channel" + embed = EventEmbed( + title="Member joined", + description="", ) - await self.bot.logger.send_log( - message=( - f"Channel with ID {channel.id} created in guild with ID" - f" {channel.guild.id}" - ), - level=LogLevel.INFO, - context=LogContext(guild=channel.guild, channel=channel), - channel=log_channel, - embed=embed, + + embed.setEventAuthor(member) + embed.addMemberField("New Member", member) + + if member.flags.did_rejoin: + embed.set_footer(text="This user has joined this server before") + + console_message = f"Member joined: {member.name} ({member.id})" + + await self.send_event_log( + guild=member.guild, + log_location="member", + string_message=console_message, + embed_message=embed, ) @commands.Cog.listener() - async def on_guild_channel_update( - self: Self, before: discord.abc.GuildChannel, after: discord.abc.GuildChannel + async def on_raw_member_remove( + self: Self, payload: discord.RawMemberRemoveEvent ) -> None: - """ - See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_channel_update + """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_member_remove Args: - before (discord.abc.GuildChannel): The updated guild channel's old info - after (discord.abc.GuildChannel): The updated guild channel's new info + member (discord.Member): The member who left """ - attrs = [ - "category", - "changed_roles", - "name", - "overwrites", - "permissions_synced", - "position", - ] - diff = auxiliary.get_object_diff(before, after, attrs) + member = payload.user + embed = EventEmbed( + title="Member left", + description="", + ) - embed = discord.Embed() - embed = auxiliary.add_diff_fields(embed, diff) - embed.add_field(name="Channel Name", value=before.name) - embed.add_field(name="Server", value=before.guild.name) + embed.setEventAuthor(member) + embed.addMemberField("Member", member) - log_channel = configuration.get_config_entry( - before.guild.id, "core_guild_events_channel" - ) - await self.bot.logger.send_log( - message=( - f"Channel with ID {before.id} modified in guild with ID" - f" {before.guild.id}" - ), - level=LogLevel.INFO, - context=LogContext(guild=before.guild, channel=before), - channel=log_channel, - embed=embed, + if isinstance(member, discord.Member): + embed.add_field( + name="Joined at", + value=( + f" " + f"()" + ), + ) + embed.add_field( + name="Roles", + value=", ".join(logger.generate_role_list(member)), + ) + + # If member object, show roles and date joined? + + console_message = f"Member left: {member.name} ({member.id})" + + await self.send_event_log( + guild=member.guild, + log_location="member", + string_message=console_message, + embed_message=embed, ) @commands.Cog.listener() - async def on_guild_channel_pins_update( + async def on_voice_state_update( self: Self, - channel: discord.abc.GuildChannel | discord.Thread, - _last_pin: datetime.datetime | None, + member: discord.Member, + before: discord.VoiceState, + after: discord.VoiceState, ) -> None: - """ - See: - https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_channel_pins_update + # We need to handle server deafen and server mute + if before.mute != after.mute: + embed = EventEmbed( + title=f"Member Server {'un' if before.mute else ''}muted", + description="", + ) - Args: - channel (discord.abc.GuildChannel | discord.Thread): The guild channel - that had its pins updated. - _last_pin (datetime.datetime | None): The latest message that was pinned as an - aware datetime in UTC. Could be None. - """ - embed = discord.Embed() - embed.add_field(name="Channel Name", value=channel.name) - embed.add_field(name="Server", value=channel.guild) + embed.setEventAuthor(member) + embed.addMemberField("Member", member) - log_channel = configuration.get_config_entry( - channel.guild.id, "core_guild_events_channel" - ) + if after.channel: + embed.addChannelField("Current Channel", after.channel) - await self.bot.logger.send_log( - message=( - f"Channel pins updated in channel with ID {channel.id} in guild with ID" - f" {channel.guild.id}" - ), - level=LogLevel.INFO, - context=LogContext(guild=channel.guild, channel=channel), - channel=log_channel, - embed=embed, - ) + console_message = f"{embed.title}: {member.name} ({member.id})" + + await self.send_event_log( + guild=member.guild, + log_location="member", + string_message=console_message, + embed_message=embed, + ) + + if before.deaf != after.deaf: + embed = EventEmbed( + title=f"Member Server {'un' if before.deaf else ''}deafened", + description="", + ) + + embed.setEventAuthor(member) + embed.addMemberField("Member", member) + + if after.channel: + embed.addChannelField("Current Channel", after.channel) + + console_message = f"{embed.title}: {member.name} ({member.id})" + + await self.send_event_log( + guild=member.guild, + log_location="member", + string_message=console_message, + embed_message=embed, + ) + + @commands.Cog.listener() + async def on_user_update( + self: Self, before: discord.User, after: discord.User + ) -> None: + # We want to track name and global name changes + if before.name != after.name: + embed = EventEmbed( + title="Member username changed", + description="", + ) + + embed.setEventAuthor(after) + embed.addMemberField("Member", after) + embed.add_field( + name="name:", value=f"**Old:** {before.name}\n**New:** {after.name}" + ) - @commands.Cog.listener() - async def on_guild_integrations_update(self: Self, guild: discord.Guild) -> None: - """ - See: - https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_integrations_update + console_message = f"Member changed their name: {after.name} ({after.id})" - Args: - guild (discord.Guild): The guild that had its integrations updated. - """ - embed = discord.Embed() - embed.add_field(name="Server", value=guild) - log_channel = configuration.get_config_entry( - guild.id, "core_guild_events_channel" - ) - await self.bot.logger.send_log( - message=f"Integrations updated in guild with ID {guild.id}", - level=LogLevel.INFO, - context=LogContext(guild=guild), - channel=log_channel, - embed=embed, - ) + for guild in after.mutual_guilds: + await self.send_event_log( + guild=guild, + log_location="member", + string_message=console_message, + embed_message=embed, + ) - @commands.Cog.listener() - async def on_webhooks_update(self: Self, channel: discord.abc.GuildChannel) -> None: - """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_webhooks_update + if before.global_name != after.global_name: + embed = EventEmbed( + title="Member global name changed", + description="", + ) - Args: - channel (discord.abc.GuildChannel): The channel that had its webhooks updated. - """ - embed = discord.Embed() - embed.add_field(name="Channel", value=channel.name) - embed.add_field(name="Server", value=channel.guild) + embed.setEventAuthor(after) + embed.addMemberField("Member", after) + embed.add_field( + name="global_name:", + value=f"**Old:** {before.global_name}\n**New:** {after.global_name}", + ) - log_channel = configuration.get_config_entry( - channel.guild.id, "core_guild_events_channel" - ) + console_message = ( + f"Member changed their global_name: {after.name} ({after.id})" + ) - await self.bot.logger.send_log( - message=( - f"Webooks updated for channel with ID {channel.id} in guild with ID" - f" {channel.guild.id}" - ), - level=LogLevel.INFO, - context=LogContext(guild=channel.guild, channel=channel), - channel=log_channel, - embed=embed, - ) + for guild in after.mutual_guilds: + await self.send_event_log( + guild=guild, + log_location="member", + string_message=console_message, + embed_message=embed, + ) @commands.Cog.listener() async def on_member_update( @@ -477,94 +806,270 @@ async def on_member_update( before (discord.Member): The updated member's old info after (discord.Member): Teh updated member's new info """ - changed_role = set(before.roles) ^ set(after.roles) - if changed_role: - if len(before.roles) < len(after.roles): - embed = discord.Embed() - embed.add_field(name="Roles added", value=next(iter(changed_role))) - embed.add_field(name="Server", value=before.guild.name) - else: - embed = discord.Embed() - embed.add_field(name="Roles lost", value=next(iter(changed_role))) - embed.add_field(name="Server", value=before.guild.name) + # We want to track role and nickname changes - log_channel = configuration.get_config_entry( - before.guild.id, "core_member_events_channel" + if before.nick != after.nick: + embed = EventEmbed( + title="Member nickname changed", + description="", ) - await self.bot.logger.send_log( - message=( - f"Member with ID {before.id} has changed status in guild with ID" - f" {before.guild.id}" - ), - level=LogLevel.INFO, - context=LogContext(guild=before.guild), - channel=log_channel, - embed=embed, + embed.setEventAuthor(after) + embed.addMemberField("Member", after) + embed.add_field( + name="nick:", + value=f"**Old:** {before.nick}\n**New:** {after.nick}", + ) + + console_message = f"Member changed their nick: {after.name} ({after.id})" + + await self.send_event_log( + guild=after.guild, + log_location="member", + string_message=console_message, + embed_message=embed, + ) + + roles_lost = set(before.roles) - set(after.roles) + roles_gained = set(after.roles) - set(before.roles) + changed_role = set(before.roles) ^ set(after.roles) + if changed_role: + embed = EventEmbed( + title="Member roles updated", + description="", ) + embed.setEventAuthor(after) + embed.addMemberField("Member", after) + + if roles_gained: + embed.add_field( + name="Roles added", + value=", ".join([role.mention for role in roles_gained]), + ) + + if roles_lost: + embed.add_field( + name="Roles removed", + value=", ".join([role.mention for role in roles_lost]), + ) + + console_message = f"Member roles updated: {after.name} ({after.id}). Roles changed {', '.join(role.name for role in changed_role)}" + + await self.send_event_log( + guild=after.guild, + log_location="member", + string_message=console_message, + embed_message=embed, + ) + + # Guild events @commands.Cog.listener() - async def on_member_remove(self: Self, member: discord.Member) -> None: - """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_member_remove + async def on_guild_channel_create( + self: Self, channel: discord.abc.GuildChannel + ) -> None: + """ + See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_channel_create Args: - member (discord.Member): The member who left + channel (discord.abc.GuildChannel): The channel that got created """ - embed = discord.Embed() - embed.add_field(name="Member", value=member) - embed.add_field(name="Server", value=member.guild.name) - log_channel = configuration.get_config_entry( - member.guild.id, "core_member_events_channel" + + embed = EventEmbed( + title="Channel created", + description=f"", ) - await self.bot.logger.send_log( - message=( - f"Member with ID {member.id} has left guild with ID {member.guild.id}" - ), - level=LogLevel.INFO, - context=LogContext(guild=member.guild), - channel=log_channel, - embed=embed, + embed.addChannelField("Channel", channel) + + console_message = f"Channel {channel.name} ({channel.id}) was created" + + await self.send_event_log( + guild=channel.guild, + log_location="guild", + string_message=console_message, + embed_message=embed, + channel_location=channel, ) @commands.Cog.listener() - async def on_guild_remove(self: Self, guild: discord.Guild) -> None: - """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_remove + async def on_guild_channel_delete( + self: Self, channel: discord.abc.GuildChannel + ) -> None: + """ + See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_channel_delete Args: - guild (discord.Guild): The guild that got removed + channel (discord.abc.GuildChannel): The channel that got deleted """ - embed = discord.Embed() - embed.add_field(name="Server", value=guild.name) - await self.bot.logger.send_log( - message=f"Left guild with ID {guild.id}", - level=LogLevel.INFO, - context=LogContext(guild=guild), - embed=embed, + embed = EventEmbed( + title="Channel deleted", + description=f"", + ) + + embed.addChannelField("Channel", channel) + + console_message = f"Channel {channel.name} ({channel.id}) was deleted" + + await self.send_event_log( + guild=channel.guild, + log_location="guild", + string_message=console_message, + embed_message=embed, + channel_location=channel, ) + # Useful @commands.Cog.listener() - async def on_guild_join(self: Self, guild: discord.Guild) -> None: - """Configures a new guild upon joining. + async def on_guild_channel_update( + self: Self, before: discord.abc.GuildChannel, after: discord.abc.GuildChannel + ) -> None: + """ + See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_channel_update Args: - guild (discord.Guild): the guild that was joined + before (discord.abc.GuildChannel): The updated guild channel's old info + after (discord.abc.GuildChannel): The updated guild channel's new info """ - embed = discord.Embed() - embed.add_field(name="Server", value=guild.name) - log_channel = configuration.get_config_entry( - guild.id, "core_guild_events_channel" - ) + # This is hell. Thanks claude + if before.overwrites != after.overwrites: + embed = EventEmbed( + title="Channel permissions updated", + description="", + ) - await self.bot.logger.send_log( - message=f"Joined guild with ID {guild.id}", - level=LogLevel.INFO, - context=LogContext(guild=guild), - channel=log_channel, - embed=embed, - ) + embed.addChannelField("Channel", after) + + console_changes: list[str] = [] + + all_targets = set(before.overwrites) | set(after.overwrites) + + for target in all_targets: + before_overwrite = before.overwrites.get( + target, discord.PermissionOverwrite() + ) + after_overwrite = after.overwrites.get( + target, discord.PermissionOverwrite() + ) + + before_perms = dict(before_overwrite) + after_perms = dict(after_overwrite) + + added: list[str] = [] + removed: list[str] = [] + changed: list[str] = [] + + all_permissions = set(before_perms) | set(after_perms) + + for permission in sorted(all_permissions): + old = before_perms.get(permission) + new = after_perms.get(permission) + + if old == new: + continue + + if old is None: + added.append( + f"✅ `{permission.replace('_', ' ').title()}` → {new}" + ) + elif new is None: + removed.append( + f"❌ `{permission.replace('_', ' ').title()}` (was {old})" + ) + else: + old_emoji = "✅" if old else "❌" + new_emoji = "✅" if new else "❌" + + changed.append( + f"➖ `{permission.replace('_', ' ').title()}` " + f"{old_emoji} → {new_emoji}" + ) + + if not (added or removed or changed): + continue + + value_parts = [] + + if isinstance(target, discord.Role): + target_name = f"Role:" + value_parts.append(f"{target.mention}") + elif isinstance(target, discord.Member): + target_name = f"Member:" + value_parts.append(f"{target.mention}") + else: + target_name = f"Unknown:" + value_parts.append(f"{target.id}") + + if added: + value_parts.append("**Added**\n" + "\n".join(added)) + + if removed: + value_parts.append("**Removed**\n" + "\n".join(removed)) + + if changed: + value_parts.append("**Changed**\n" + "\n".join(changed)) + + value = "\n\n".join(value_parts) + + # Discord field value limit + if len(value) > 1024: + value = value[:1021] + "..." + embed.add_field( + name=target_name, + value=value, + inline=False, + ) + + console_changes.append(target_name) + + if not console_changes: + return + + console_message = ( + f"Permission overwrites updated for channel " + f"{after.name} ({after.id})" + ) + + await self.send_event_log( + guild=after.guild, + log_location="guild", + string_message=console_message, + embed_message=embed, + channel_location=after, + ) + + properties_to_track = [ + "category", + "name", + "permissions_synced", + "position", + "topic", + "slowmode_delay", + "bitrate", + "user_limit", + "nsfw", + "rtc_region", + "type", + ] + embed = EventEmbed(title="Channel properties updated", description="") + embed.addChannelField("Channel", after) + + if embed.addPropertyChangeFields(properties_to_track, before, after): + console_message = ( + f"Channel properties updated for channel " f"{after.name} ({after.id})" + ) + + await self.send_event_log( + guild=after.guild, + log_location="guild", + string_message=console_message, + embed_message=embed, + channel_location=after, + ) + + # Useful @commands.Cog.listener() async def on_guild_update( self: Self, before: discord.Guild, after: discord.Guild @@ -603,21 +1108,48 @@ async def on_guild_update( ], ) - embed = discord.Embed() - embed = auxiliary.add_diff_fields(embed, diff) - embed.add_field(name="Server", value=before.name) + properties_to_track = [ + "afk_channel", + "afk_timeout", + "banner", + "bitrate_limit", + "categories", + "description", + "default_notifications", + "dms_paused_until", + "discovery_splash", + "emoji_limit", + "explicit_content_filter", + "features", + "filesize_limit", + "icon", + "invites_paused_until", + "mfa_level", + "name", + "nsfw_level", + "owner", + "preferred_locale", + "premium_tier", + "public_updates_channel", + "rules_channel", + "safety_alerts_channel", + "splash", + "system_channel", + "verification_level", + ] + embed = EventEmbed(title="Guild properties updated", description="") - log_channel = configuration.get_config_entry( - before.guild.id, "core_guild_events_channel" - ) - await self.bot.logger.send_log( - message=f"Guild with ID {before.id} updated", - level=LogLevel.INFO, - context=LogContext(guild=before), - channel=log_channel, - embed=embed, - ) + if embed.addPropertyChangeFields(properties_to_track, before, after): + console_message = f"Guild properties updated." + + await self.send_event_log( + guild=after, + log_location="guild", + string_message=console_message, + embed_message=embed, + ) + # Useful @commands.Cog.listener() async def on_guild_role_create(self: Self, role: discord.Role) -> None: """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_role_create @@ -641,6 +1173,7 @@ async def on_guild_role_create(self: Self, role: discord.Role) -> None: embed=embed, ) + # Useful @commands.Cog.listener() async def on_guild_role_delete(self: Self, role: discord.Role) -> None: """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_role_delete @@ -663,6 +1196,7 @@ async def on_guild_role_delete(self: Self, role: discord.Role) -> None: embed=embed, ) + # Useful @commands.Cog.listener() async def on_guild_role_update( self: Self, before: discord.Role, after: discord.Role @@ -674,6 +1208,12 @@ async def on_guild_role_update( after (discord.Role): The updated role's updated info. """ attrs = ["color", "mentionable", "name", "permissions", "position", "tags"] + # Tags cannot change, so doesn't matter + # Probably want to do better with color changes, with 2nd/3rd color + # Probably want to do display_icon changes + # Probably want to do hoist changes + + # We probably want properties (everything but permissions) and permissions as 2 different logs diff = auxiliary.get_object_diff(before, after, attrs) embed = discord.Embed() @@ -695,6 +1235,7 @@ async def on_guild_role_update( embed=embed, ) + # Useful @commands.Cog.listener() async def on_guild_emojis_update( self: Self, @@ -722,82 +1263,7 @@ async def on_guild_emojis_update( embed=embed, ) - @commands.Cog.listener() - async def on_member_ban( - self: Self, guild: discord.Guild, user: discord.User | discord.Member - ) -> None: - """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_member_ban - - Args: - guild (discord.Guild): The guild the user got banned from - user (discord.User | discord.Member): The user that got banned. Can be either User - or Member depending if the user was in the guild or not at the time of removal. - """ - embed = discord.Embed() - embed.add_field(name="User", value=user) - embed.add_field(name="Server", value=guild.name) - - log_channel = configuration.get_config_entry( - guild.id, "core_member_events_channel" - ) - - await self.bot.logger.send_log( - message=f"User with ID {user.id} banned from guild with ID {guild.id}", - level=LogLevel.INFO, - context=LogContext(guild=guild), - channel=log_channel, - embed=embed, - ) - - @commands.Cog.listener() - async def on_member_unban( - self: Self, guild: discord.Guild, user: discord.User - ) -> None: - """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_member_unban - - Args: - guild (discord.Guild): The guild the user got unbanned from - user (discord.User): The user that got unbanned - """ - embed = discord.Embed() - embed.add_field(name="User", value=user) - embed.add_field(name="Server", value=guild.name) - - log_channel = configuration.get_config_entry( - guild.id, "core_member_events_channel" - ) - - await self.bot.logger.send_log( - message=f"User with ID {user.id} unbanned from guild with ID {guild.id}", - level=LogLevel.INFO, - context=LogContext(guild=guild), - channel=log_channel, - embed=embed, - ) - - @commands.Cog.listener() - async def on_member_join(self: Self, member: discord.Member) -> None: - """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_member_join - - Args: - member (discord.Member): The member who joined - """ - embed = discord.Embed() - embed.add_field(name="Member", value=member) - embed.add_field(name="Server", value=member.guild.name) - log_channel = configuration.get_config_entry( - member.guild.id, "core_member_events_channel" - ) - - await self.bot.logger.send_log( - message=( - f"Member with ID {member.id} has joined guild with ID {member.guild.id}" - ), - level=LogLevel.INFO, - context=LogContext(guild=member.guild), - channel=log_channel, - embed=embed, - ) + # Bot Events @commands.Cog.listener() async def on_command(self: Self, ctx: commands.Context) -> None: @@ -829,6 +1295,8 @@ async def on_command(self: Self, ctx: commands.Context) -> None: embed=embed, ) + # CONSOLE ONLY STUFF + @commands.Cog.listener() async def on_error(self: Self, event_method: str) -> None: """Catches non-command errors and sends them to the error logger for processing. @@ -869,3 +1337,69 @@ async def on_disconnect(self: Self) -> None: level=LogLevel.INFO, console_only=True, ) + + @commands.Cog.listener() + async def on_guild_remove(self: Self, guild: discord.Guild) -> None: + """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_remove + + Args: + guild (discord.Guild): The guild that got removed + """ + embed = discord.Embed() + embed.add_field(name="Server", value=guild.name) + await self.bot.logger.send_log( + message=f"Left guild with ID {guild.id}", + level=LogLevel.INFO, + context=LogContext(guild=guild), + embed=embed, + ) + + @commands.Cog.listener() + async def on_guild_join(self: Self, guild: discord.Guild) -> None: + """Configures a new guild upon joining. + + Args: + guild (discord.Guild): the guild that was joined + """ + embed = discord.Embed() + embed.add_field(name="Server", value=guild.name) + + log_channel = configuration.get_config_entry( + guild.id, "core_guild_events_channel" + ) + + await self.bot.logger.send_log( + message=f"Joined guild with ID {guild.id}", + level=LogLevel.INFO, + context=LogContext(guild=guild), + channel=log_channel, + embed=embed, + ) + + +# Should probably log: +""" +Thread creation/delete (guild) - MAYBE + discord.on_thread_create + discord.on_thread_update + discord.on_thread_delete +Automod stuff (guild) + discord.on_automod_rule_create + discord.on_automod_rule_update + discord.on_automod_rule_delete +Soundboard & stickers (guild) + discord.on_soundboard_sound_create + discord.on_soundboard_sound_delete + discord.on_soundboard_sound_update + discord.on_guild_stickers_update +Integrations (guild) + discord.on_integration_create + discord.on_integration_update +Invites (Guild) + discord.on_invite_create + discord.on_invite_delete +Scheduled Events (guild) + discord.on_scheduled_event_create + discord.on_scheduled_event_delete + discord.on_scheduled_event_update +"""