diff --git a/bot.py b/bot.py index b3f817a3..70aee67a 100644 --- a/bot.py +++ b/bot.py @@ -8,6 +8,7 @@ import glob import io import os +import sys import threading from typing import Self @@ -181,6 +182,55 @@ async def setup_hook(self: Self) -> None: self.extension_name_list = [] await self.load_extensions() + 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 + """ + await self.logger.send_log( + message=f"Left guild with ID {guild.id}", + level=LogLevel.INFO, + console_only=True, + ) + + async def on_error(self: Self, event_method: str) -> None: + """Catches non-command errors and sends them to the error logger for processing. + + Args: + event_method (str): the event method name associated with the error (eg. on_message) + """ + _, exception, _ = sys.exc_info() + await self.bot.logger.send_log( + message=f"Bot error in {event_method}: {exception}", + level=LogLevel.ERROR, + exception=exception, + ) + + async def on_connect(self: Self) -> None: + """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_connect""" + await self.logger.send_log( + message="Connected to Discord", + level=LogLevel.INFO, + console_only=True, + ) + + async def on_resumed(self: Self) -> None: + """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_resumed""" + await self.logger.send_log( + message="Resume connection to discord", + level=LogLevel.INFO, + console_only=True, + ) + + async def on_disconnect(self: Self) -> None: + """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_disconnect""" + await self.logger.send_log( + message="Disconnected from Discord", + level=LogLevel.INFO, + console_only=True, + ) + async def on_guild_join(self: Self, guild: discord.Guild) -> None: """Configures a new guild upon joining. This registers a new guild config, and starts any loop jobs that are configured @@ -188,6 +238,12 @@ async def on_guild_join(self: Self, guild: discord.Guild) -> None: Args: guild (discord.Guild): the guild that was joined """ + await self.logger.send_log( + message=f"Joined guild with ID {guild.id}", + level=LogLevel.INFO, + console_only=True, + ) + for cog in self.cogs.values(): if getattr(cog, "COG_TYPE", "").lower() == "loop": try: diff --git a/botlogging/logger.py b/botlogging/logger.py index 6331cc47..29ff6e19 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 @@ -216,6 +217,8 @@ async def send_log( exception (Exception, optional): The exception item if you wish to log an exception with this log. Exceptions will be logged in plain text. Defaults to None. + embed_as_is (bool, optional): If the passed embed should be sent without any edits + Defaults to False """ log_level = self.convert_level(level) @@ -238,17 +241,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/changelog.md b/changelog.md index f8bf2269..59a47bb7 100644 --- a/changelog.md +++ b/changelog.md @@ -45,6 +45,9 @@ Changes since 2026.06.04 ## Moderation +### Events +- Complete overhaul of events logging system. A huge number of additional events are now tracked, and information is displayed in a more readable way + ### Logger - Now mentions roles instead of listing text names 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..53f79b8c 100644 --- a/modules/moderation/events.py +++ b/modules/moderation/events.py @@ -2,17 +2,16 @@ from __future__ import annotations -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 import configuration from botlogging import LogContext, LogLevel -from core import auxiliary, cogs +from core import cogs +from modules.moderation import logger if TYPE_CHECKING: import bot @@ -27,778 +26,2217 @@ 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 + + Args: + title (str): The title of the embed to be applied + description (str): The body of the embed to be applied + """ + + def __init__(self: Self, *, title: str, description: str) -> None: + super().__init__( + title=title, + description=description, + colour=discord.Colour.orange(), + timestamp=discord.utils.utcnow(), + ) + + def setEventAuthor(self: Self, author: discord.Member) -> None: + """This sets an author object of an embed to be stylized as a passed member object + This shows the display_name and display_avatar + + Args: + author (discord.Member): The member to make the author of the embed + """ + self.set_author( + name=str(author.display_name), + icon_url=author.display_avatar.url, + ) + + def addMemberField(self: Self, title: str, member: discord.Member) -> None: + """This adds a member info field to the embed. + A mention, username, and id are shown here. + + Args: + title (str): The title of the field + member (discord.Member): The member object to display information of + """ + 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: + """This adds a message content info field to the embed. + Clean content, trimmed to 1024 in size, is what is used. + + Args: + title (str): The title of the field + message (discord.Message): The message object to get the content from + """ + 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: + """This adds a message info field to the embed. + Clean content, trimmed to 50, the author, the ID, and the sent at time are all displayed + + Args: + title (str): The title of the field + message (discord.Message): The message object to get the information from + """ + 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: + """This adds a channel info field into the embed. + Channel link, name, type and ID are all displayed. + + Args: + title (str): The title of the field + channel (discord.abc.GuildChannel): The channel object to get information from + """ + 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 addSoundboardField( + self: Self, title: str, soundboard: discord.SoundboardSound + ) -> None: + """This adds a soundboard info field into the embed. + Name, volume, emoji and ID are all displayed. + + Args: + title (str): The title of the field + soundboard (discord.SoundboardSound): The soundboard object to get information from + """ + self.add_field( + name=title, + value=( + f"**Name:** {soundboard.name}\n" + f"**Volume:** {soundboard.volume}\n" + f"**Emoji:** {soundboard.emoji}\n" + f"**ID:** {soundboard.id}" + ), + inline=True, + ) + + def addEmojiField( + self: Self, title: str, emoji: discord.Emoji | discord.PartialEmoji | str + ) -> None: + """This adds an emoji info field into the embed. + For standard reactions, just the emoji is displayed. + For custom emojis, the emoji will attempt to be displayed, as well as the name and ID + + Args: + title (str): The title of the field + emoji (discord.Emoji | discord.PartialEmoji | str): + The emoji object to get information from + """ + # 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 addStickerField( + self: Self, + title: str, + sticker: discord.GuildSticker, + ) -> None: + """This adds a sticker info field into the embed. + Name, ID, description and emoji are all displayed. + + Args: + title (str): The title of the field + sticker (discord.GuildSticker): The sticker object to get information from + """ + self.add_field( + name=title, + value=( + f"**Name:** {sticker.name}\n" + f"**ID:** {sticker.id}\n" + f"**Description:** {sticker.description or 'None'}\n" + f"**Emoji:** {sticker.emoji or 'None'}" + ), + ) + + def addIntegrationField( + self: Self, + title: str, + integration: discord.Integration, + ) -> None: + """This adds an integration info field into the embed. + Name, type, scope and ID are all displayed + + Args: + title (str): The title of the field + integration (discord.Integration): The integration object to get information from + """ + self.add_field( + name=title, + value=( + f"**Name:** {integration.name}\n" + f"**Type:** {integration.type}\n" + f"**Scope:** {integration.scopes}\n" + f"**ID:** {integration.id}" + ), + ) + + def addPollField(self: Self, title: str, poll: discord.Poll) -> None: + """This adds a poll info field into the embed. + Question, duration, and answers are all displayed. + + Args: + title (str): The title of the field + poll (discord.Poll): The poll object to get information from + """ + 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: + """This adds a poll answer info field into the embed. The answer and ID are both displayed. + + Args: + title (str): The title of the field + answer (discord.PollAnswer): The answer object to get information from + """ + self.add_field( + name=title, + value=(f"**Answer:** {answer.text}\n**ID:** {answer.id}"), + inline=True, + ) + + def addRoleField(self: Self, title: str, role: discord.Role) -> None: + """This adds a role info field into the embed. + Mention, name, ID, position, hoisted and mentionable status are all displayed. + + Args: + title (str): The title of the field + role (discord.Role): The role object to get information from + """ + self.add_field( + name=title, + value=( + f"**Role:** {role.mention}\n" + f"**Name:** {role.name}\n" + f"**ID:** {role.id}\n" + f"**Position:** {role.position}\n" + f"**Hoisted:** {'Yes' if role.hoist else 'No'}\n" + f"**Mentionable:** {'Yes' if role.mentionable else 'No'}" + ), + inline=True, + ) + + def addRoleMetadataField(self: Self, title: str, role: discord.Role) -> None: + """This adds a role metadata field into the embed. + Timestamp, tags, flags, and icon are displayed + + Args: + title (str): The title of the field + role (discord.Role): The role object to get information from + """ + self.add_field( + name=title, + value=( + f"**Created:** " + f"()\n" + f"**Tags:** {role.tags or 'None'}\n" + f"**Flags:** {role.flags}\n" + f"**Icon:** {role.icon or 'None'}\n" + f"**Display Icon:** {role.display_icon or 'None'}" + ), + inline=True, + ) + + def addRoleColorField(self: Self, title: str, role: discord.Role) -> None: + """This adds a role color field into the embed. + Primary, secondary, and tertiary colors are all displayed. + + Args: + title (str): The title of the field + role (discord.Role): The role object to get information from + """ + self.add_field( + name=title, + value=( + f"**Primary:** {role.colour}\n" + f"**Secondary:** {role.secondary_colour}\n" + f"**Tertiary:** {role.tertiary_colour}" + ), + inline=True, + ) + + def addScheduledEventField( + self: Self, title: str, event: discord.ScheduledEvent + ) -> None: + """This adds a scheduled event field into the embed. + Time, ID, status, location are all included. + + Args: + title (str): The title of the field + event (discord.ScheduledEvent): The event object to get information from + """ + start_time = ( + f" " + f"()" + if event.start_time + else "None" + ) + end_time = ( + f" " + f"()" + if event.end_time + else "None" + ) + location = event.channel.mention if event.channel else event.location or "None" + + self.add_field( + name=title, + value=( + f"**Name:** {event.name}\n" + f"**ID:** {event.id}\n" + f"**Status:** {event.status}\n" + f"**Entity Type:** {event.entity_type}\n" + f"**Location:** {location}\n" + f"**Start:** {start_time}\n" + f"**End:** {end_time}" + ), + inline=True, + ) + + def addAutoModRuleField(self: Self, title: str, rule: discord.AutoModRule) -> None: + """This adds an automod rule info field into the embed. + Name, ID, status, type, triggers, actions, and exempt info are all displayed + + Args: + title (str): The title of the field + rule (discord.AutoModRule): The automod object to get information from + """ + actions = ", ".join(str(action.type) for action in rule.actions) or "None" + if len(actions) > 200: + actions = actions[:197] + "..." + + self.add_field( + name=title, + value=( + f"**Name:** {rule.name}\n" + f"**ID:** {rule.id}\n" + f"**Enabled:** {'Yes' if rule.enabled else 'No'}\n" + f"**Event Type:** {rule.event_type}\n" + f"**Trigger:** {rule.trigger.type}\n" + f"**Actions:** {actions}\n" + f"**Exempt Roles:** {len(rule.exempt_role_ids)}\n" + f"**Exempt Channels:** {len(rule.exempt_channel_ids)}" + ), + inline=True, + ) + + def addRolePermissionChangeFields( + self: Self, before: discord.Permissions, after: discord.Permissions + ) -> bool: + """This adds fields displaying permissions changes between two roles + + Args: + before (discord.Permissions): The old set of permissions + after (discord.Permissions): The new set of permissions + + Returns: + bool: Whether anything was added to the embed + """ + added: list[str] = [] + removed: list[str] = [] + changed: list[str] = [] + + before_permissions = dict(iter(before)) + after_permissions = dict(iter(after)) + all_permissions = set(before_permissions) | set(after_permissions) + + for permission in sorted(all_permissions): + old = before_permissions.get(permission) + new = after_permissions.get(permission) + + if old == new: + continue + + permission_name = permission.replace("_", " ").title() + + if old is None: + added.append(f"✅ `{permission_name}` → {new}") + elif new is None: + removed.append(f"❌ `{permission_name}` (was {old})") + else: + old_emoji = "✅" if old else "❌" + new_emoji = "✅" if new else "❌" + changed.append(f"➖ `{permission_name}` {old_emoji} → {new_emoji}") + + if not (added or removed or changed): + return False + + value_parts = [] + + 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) + + if len(value) > 1024: + value = value[:1021] + "..." + + self.add_field( + name="Permissions", + value=value, + inline=False, + ) + + return True + + def addPropertyChangeFields( + self: Self, properties: list[str], before: Any, after: Any # noqa: ANN401 + ) -> bool: + """Adds fields to the embed for an arbitrary list of string properties + to compare betweeen two objects + + Args: + properties (list[str]): A list of properties to get and compared + before (Any): The old object before any changes were made + after (Any): The new object after all changes were made + + Returns: + bool: Whether anything was added to the embed + """ + 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 + + Attributes: + CONFIG_MAP (dict[str, str]): A mpa of types of logs to the config names + of their respective logging channel """ + 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 ) -> None: - """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_message_edit + """This logs message content edit and message (un)pin events + 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 + before (discord.Message): The original message, before the edit occured + after (discord.Message): The new message, after it is been edited """ - # 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} " + f"({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: " + f"{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: - """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_message_delete + """This logs message delete events, by both users and moderators + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_message_delete Args: - message (discord.Message): The deleted message + message (discord.Message): The message that was deleted """ - 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 = EventEmbed( + title="Message deleted", + description=f"[Jump to Message]({message.jump_url})", + ) + + embed.setEventAuthor(message.author) + embed.addMemberField("Message Author", message.author) + embed.addChannelField("Channel", message.channel) + embed.addMessageContentField("Content", message) + embed.add_field( - name="Channel", - value=getattr(message.channel, "name", "DM"), + name="Message Sent", + value=( + f" " + f"()" + ), + inline=False, ) - 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.set_footer(text=f"Message ID: {message.id}") + + console_message = ( + f"Message delete: ID: {message.id} in channel: " + f"{channel.name} ({channel.id}). Content: {message.clean_content}" ) - 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, + 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_bulk_message_delete( self: Self, messages: list[discord.Message] ) -> None: - """ - See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_bulk_message_delete + """This logs bulk message delete events, such as those by a purge command or ban + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_bulk_message_delete Args: - messages (list[discord.Message]): The messages that have been deleted + messages (list[discord.Message]): The list of message objects that have been deleted """ - guild = getattr(messages[0].channel, "guild", None) + channel = messages[0].channel + guild = channel.guild - 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})" - ) + # Don't log stuff not in a guild + if not guild: + return - embed = discord.Embed() - embed.add_field(name="Channels", value=",".join(unique_channels)) - embed.add_field(name="Servers", value=",".join(unique_servers)) + embed = EventEmbed( + title="Bulk message delete", + description="", + ) + embed.addChannelField("Channel", channel) - log_channel = configuration.get_config_entry( - guild.id, "core_guild_events_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} " + f"({channel.id}). Amount: {len(messages)}" ) - 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, + + 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_reaction_add( self: Self, reaction: discord.Reaction, user: discord.Member | discord.User ) -> None: - """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_reaction_add + """This logs events where a reaction has been added to a message + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_reaction_add Args: - reaction (discord.Reaction): The current state of the reaction - user (discord.Member | discord.User): The user who added the reaction + reaction (discord.Reaction): The reaction object that was added + user (discord.Member | discord.User): The account that 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") + if not guild: + return + + embed = EventEmbed( + title="Reaction added", + description=f"[Jump to Message]({message.jump_url})", ) - embed.add_field(name="Server", value=guild.name) - log_channel = configuration.get_config_entry( - guild.id, "core_guild_events_channel" + 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 " + f"ID: {message.id} by user {user.name} ({user.id})" ) - 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, + 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_reaction_remove( self: Self, reaction: discord.Reaction, user: discord.Member | discord.User ) -> None: - """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_reaction_remove + """This logs events where a reaction has been removed from a message + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_reaction_remove Args: - reaction (discord.Reaction): The current state of the reaction - user (discord.Member | discord.User): The user whose reaction was removed + reaction (discord.Reaction): The reaction object that was removed + user (discord.Member | discord.User): The account that originally had that 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}`: 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") + if not guild: + return + + embed = EventEmbed( + title="Reaction removed", + description=f"[Jump to Message]({message.jump_url})", ) - embed.add_field(name="Server", value=guild.name) - log_channel = configuration.get_config_entry( - guild.id, "core_guild_events_channel" + 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 " + f"with ID: {message.id} by user {user.name} ({user.id})" ) - 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, + 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_reaction_clear( self: Self, message: discord.Message, reactions: list[discord.Reaction] ) -> None: - """ - See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_reaction_clear + """This logs events where all reactions or all of a specific reaction + were cleared from a message + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_reaction_clear Args: - message (discord.Message): The message that had its reactions cleared + message (discord.Message): The messages reactions were cleared from reactions (list[discord.Reaction]): The reactions that were removed """ guild = getattr(message.channel, "guild", None) + channel = message.channel + + # Don't log messages without a guild + if not guild: + return - unique_emojis = set() + emoji_str = "" + total_emoji = 0 for reaction in reactions: - unique_emojis.add(reaction.emoji) + emoji_str += f"`{reaction.emoji}`: {reaction.count}\n" + total_emoji += reaction.count - 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) + embed = EventEmbed( + title="Reactions cleared", + description=f"[Jump to Message]({message.jump_url})", + ) - log_channel = configuration.get_config_entry( - guild.id, "core_guild_events_channel" + 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 " + f"ID: {message.id} in channel {channel.name} ({channel.id})" ) - 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, + 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: + """This logs events where a user has voted in a poll + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_poll_vote_add + + Args: + user (discord.Member): The user who voted in the poll + answer (discord.PollAnswer): The answer selected in the poll + """ + if not user.guild: + return + + guild = user.guild + message = answer.poll.message + channel = message.channel + + embed = EventEmbed( + title="Poll answered", + 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}) voted {answer.text} " + f"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_poll_vote_remove( + self: Self, user: discord.Member, answer: discord.PollAnswer ) -> None: + """This logs events where a user has removed their vote in a poll + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_poll_vote_remove + + Args: + user (discord.Member): The user who voted in the poll + answer (discord.PollAnswer): The answer removed in the poll """ - See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_channel_delete + 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} " + f"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: + """This logs events where new members have joined the guild + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_member_join Args: - channel (discord.abc.GuildChannel): The channel that got deleted + member (discord.Member): The member who has joined """ - embed = discord.Embed() - embed.add_field(name="Channel Name", value=channel.name) - embed.add_field(name="Server", value=channel.guild.name) + embed = EventEmbed( + title="Member joined", + description="", + ) - log_channel = configuration.get_config_entry( - channel.guild.id, "core_guild_events_channel" + 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, ) - 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, + @commands.Cog.listener() + async def on_raw_member_remove( + self: Self, payload: discord.RawMemberRemoveEvent + ) -> None: + """This logs events where members left or are kicked/banned from the guild. + This is a raw listener, so it does not rely on the cache to get this callout + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_raw_member_remove + + Args: + payload (discord.RawMemberRemoveEvent): The member who left the service + """ + member = payload.user + embed = EventEmbed( + title="Member left", + description="", + ) + + embed.setEventAuthor(member) + embed.addMemberField("Member", member) + + 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_voice_state_update( + self: Self, + member: discord.Member, + before: discord.VoiceState, + after: discord.VoiceState, + ) -> None: + """This logs events related to server (un)mute/deafing of members + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_voice_state_update + + Args: + member (discord.Member): The member who had their account changed + before (discord.VoiceState): The previous state of the members voice account + after (discord.VoiceState): The new state of the members voice account + """ + # 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="", + ) + + 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, + channel_location=after.channel, + ) + + 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: + """This logs events related to username and display name changes + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_user_update + + Args: + before (discord.User): The old user account object, before changes + after (discord.User): The new user account object, after changes + """ + # 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}" + ) + + console_message = f"Member changed their name: {after.name} ({after.id})" + + for guild in after.mutual_guilds: + await self.send_event_log( + guild=guild, + log_location="member", + string_message=console_message, + embed_message=embed, + ) + + if before.global_name != after.global_name: + embed = EventEmbed( + title="Member global name changed", + description="", + ) + + embed.setEventAuthor(after) + embed.addMemberField("Member", after) + embed.add_field( + name="global_name:", + value=f"**Old:** {before.global_name}\n**New:** {after.global_name}", + ) + + console_message = ( + f"Member changed their global_name: {after.name} ({after.id})" + ) + + 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( + self: Self, before: discord.Member, after: discord.Member + ) -> None: + """This logs events related to nickname changes and member role changes + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_member_update + + Args: + before (discord.Member): The old member object, pre changes + after (discord.Member): The new member object, post changes + """ + # We want to track role and nickname changes + + if before.nick != after.nick: + embed = EventEmbed( + title="Member nickname changed", + description="", + ) + + 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}). " + f"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_guild_channel_create( self: Self, channel: discord.abc.GuildChannel ) -> None: + """This logs events to GuildChannel objects being created + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_channel_create + + Args: + channel (discord.abc.GuildChannel): The channel object that was created. """ - See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_channel_create + embed = EventEmbed( + title="Channel created", + description="", + ) + + 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_channel_delete( + self: Self, channel: discord.abc.GuildChannel + ) -> None: + """This logs events to GuildChannel objects being deleted + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_channel_delete Args: - channel (discord.abc.GuildChannel): The channel that got created + channel (discord.abc.GuildChannel): The channel object that was deleted. """ - 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="Channel deleted", + 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.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, ) @commands.Cog.listener() async def on_guild_channel_update( self: Self, before: discord.abc.GuildChannel, after: discord.abc.GuildChannel ) -> None: + """This logs events related to property changes to guild channels. + A seperate event is logged for channel permission changes + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_channel_update + + Args: + before (discord.abc.GuildChannel): The previous channel, before any changes + after (discord.abc.GuildChannel): The new channel, after any changes """ - See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_channel_update - Args: - before (discord.abc.GuildChannel): The updated guild channel's old info - after (discord.abc.GuildChannel): The updated guild channel's new info - """ - attrs = [ + # This is hell. Thanks claude + if before.overwrites != after.overwrites: + embed = EventEmbed( + title="Channel permissions updated", + description="", + ) + + 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 = "Role:" + value_parts.append(f"{target.mention}") + elif isinstance(target, discord.Member): + target_name = "Member:" + value_parts.append(f"{target.mention}") + else: + target_name = "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 = ( + "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", - "changed_roles", "name", - "overwrites", "permissions_synced", "position", + "topic", + "slowmode_delay", + "bitrate", + "user_limit", + "nsfw", + "rtc_region", + "type", ] - diff = auxiliary.get_object_diff(before, after, attrs) + embed = EventEmbed(title="Channel properties updated", description="") + embed.addChannelField("Channel", after) - 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) + if embed.addPropertyChangeFields(properties_to_track, before, after): + console_message = ( + f"Channel properties updated for channel " f"{after.name} ({after.id})" + ) - 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, - ) + await self.send_event_log( + guild=after.guild, + log_location="guild", + string_message=console_message, + embed_message=embed, + channel_location=after, + ) @commands.Cog.listener() - async def on_guild_channel_pins_update( - self: Self, - channel: discord.abc.GuildChannel | discord.Thread, - _last_pin: datetime.datetime | None, + async def on_guild_update( + self: Self, before: discord.Guild, after: discord.Guild ) -> None: - """ - See: - https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_channel_pins_update + """This logs a huge number of property changes for a given guild + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_update 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. + before (discord.Guild): The old guild state, before any property changes + after (discord.Guild): The new guild state, after any property changes """ - embed = discord.Embed() - embed.add_field(name="Channel Name", value=channel.name) - embed.add_field(name="Server", value=channel.guild) + 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( - channel.guild.id, "core_guild_events_channel" - ) + if embed.addPropertyChangeFields(properties_to_track, before, after): + console_message = "Guild properties updated." - 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, - ) + await self.send_event_log( + guild=after, + log_location="guild", + string_message=console_message, + embed_message=embed, + ) @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 + async def on_thread_create(self: Self, thread: discord.Thread) -> None: + """This logs events related to creating a new thread + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_thread_create Args: - guild (discord.Guild): The guild that had its integrations updated. + thread (discord.Thread): The thread object that was created """ - embed = discord.Embed() - embed.add_field(name="Server", value=guild) - log_channel = configuration.get_config_entry( - guild.id, "core_guild_events_channel" + embed = EventEmbed( + title="Thread created", + description="", ) - 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, + + embed.addChannelField("Thread", thread) + embed.addChannelField("Parent channel", thread.parent) + + console_message = f"Thread {thread.name} ({thread.id}) was created" + + await self.send_event_log( + guild=thread.guild, + log_location="guild", + string_message=console_message, + embed_message=embed, + channel_location=thread.parent, ) @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 + async def on_thread_delete(self: Self, thread: discord.Thread) -> None: + """This logs events related to deleting a thread + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_thread_delete Args: - channel (discord.abc.GuildChannel): The channel that had its webhooks updated. + thread (discord.Thread): The thread object that was deleted """ - embed = discord.Embed() - embed.add_field(name="Channel", value=channel.name) - embed.add_field(name="Server", value=channel.guild) - - log_channel = configuration.get_config_entry( - channel.guild.id, "core_guild_events_channel" + embed = EventEmbed( + title="Thread deleted", + description="", ) - 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, + embed.addChannelField("Thread", thread) + embed.addChannelField("Parent channel", thread.parent) + + console_message = f"Thread {thread.name} ({thread.id}) was deleted" + + await self.send_event_log( + guild=thread.guild, + log_location="guild", + string_message=console_message, + embed_message=embed, + channel_location=thread.parent, ) @commands.Cog.listener() - async def on_member_update( - self: Self, before: discord.Member, after: discord.Member + async def on_thread_update( + self: Self, before: discord.Thread, after: discord.Thread ) -> None: - """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_member_update + """This logs changes related to a handful of properties of threads + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_thread_update Args: - before (discord.Member): The updated member's old info - after (discord.Member): Teh updated member's new info + before (discord.Thread): The previous thread, before any property changes + after (discord.Thread): The new thread, after any property changes """ - 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) + properties_to_track = [ + "applied_tags", + "archived", + "invitable", + "locked", + "name", + "slowmode_delay", + "type", + ] + embed = EventEmbed(title="Thread properties updated", description="") + embed.addChannelField("Thread", after) + embed.addChannelField("Parent channel", after.parent) - log_channel = configuration.get_config_entry( - before.guild.id, "core_member_events_channel" + if embed.addPropertyChangeFields(properties_to_track, before, after): + console_message = ( + f"Thread properties updated for thread " f"{after.name} ({after.id})" ) - 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, + await self.send_event_log( + guild=after.guild, + log_location="guild", + string_message=console_message, + embed_message=embed, + channel_location=after, ) @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_invite_create(self: Self, invite: discord.Invite) -> None: + """This logs events related to creating a new invite for the guild + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_thread_update Args: - member (discord.Member): The member who left + invite (discord.Invite): The invite that was 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="New invite created", description=f"https://discord.gg/{invite.code}" ) - - 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, + if invite.channel: + embed.addChannelField("Channel", invite.channel) + if invite.inviter: + embed.addMemberField("Inviter", invite.inviter) + + console_message = f"New invite created: {invite.code}" + + await self.send_event_log( + guild=invite.guild, + log_location="guild", + string_message=console_message, + embed_message=embed, + channel_location=invite.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_invite_delete(self: Self, invite: discord.Invite) -> None: + """This logs events related to deleting an invite for the guild + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_thread_update Args: - guild (discord.Guild): The guild that got removed + invite (discord.Invite): The invite that was 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="Invite deleted", description=f"https://discord.gg/{invite.code}" + ) + if invite.channel: + embed.addChannelField("Channel", invite.channel) + if invite.inviter: + embed.addMemberField("Inviter", invite.inviter) + + console_message = f"Invite deleted: {invite.code}" + + await self.send_event_log( + guild=invite.guild, + log_location="guild", + string_message=console_message, + embed_message=embed, + channel_location=invite.channel, ) @commands.Cog.listener() - async def on_guild_join(self: Self, guild: discord.Guild) -> None: - """Configures a new guild upon joining. + async def on_soundboard_sound_create( + self: Self, sound: discord.SoundboardSound + ) -> None: + """This logs events related to a new soundboard sound being created + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_soundboard_sound_create Args: - guild (discord.Guild): the guild that was joined + sound (discord.SoundboardSound): The soundboard object that was created """ - embed = discord.Embed() - embed.add_field(name="Server", value=guild.name) - - log_channel = configuration.get_config_entry( - guild.id, "core_guild_events_channel" + embed = EventEmbed(title="Soundboard sound created", description="") + embed.addSoundboardField("Sound", sound) + if sound.user: + embed.addMemberField("Uploader", sound.user) + + console_message = f"Soundboard sound created: {sound.name}" + + await self.send_event_log( + guild=sound.guild, + log_location="guild", + string_message=console_message, + embed_message=embed, ) - 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, + @commands.Cog.listener() + async def on_soundboard_sound_delete( + self: Self, sound: discord.SoundboardSound + ) -> None: + """This logs events related to a new soundboard sound being deleted + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_soundboard_sound_delete + + Args: + sound (discord.SoundboardSound): The soundboard object that was deleted + """ + embed = EventEmbed(title="Soundboard sound deleted", description="") + embed.addSoundboardField("Sound", sound) + if sound.user: + embed.addMemberField("Uploader", sound.user) + + console_message = f"Soundboard sound deleted: {sound.name}" + + await self.send_event_log( + guild=sound.guild, + log_location="guild", + string_message=console_message, + embed_message=embed, ) @commands.Cog.listener() - async def on_guild_update( - self: Self, before: discord.Guild, after: discord.Guild + async def on_soundboard_sound_update( + self: Self, before: discord.SoundboardSound, after: discord.SoundboardSound ) -> None: + """This logs events related to soundboard sounds being edited + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_soundboard_sound_update + + Args: + before (discord.SoundboardSound): The old sound, before any edits + after (discord.SoundboardSound): The new sound, after any edits """ - See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_update - - Args: - before (discord.Guild): The guild prior to being updated - after (discord.Guild): The guild after being updated - """ - diff = auxiliary.get_object_diff( - before, - after, - [ - "banner", - "banner_url", - "bitrate_limit", - "categories", - "default_role", - "description", - "discovery_splash", - "discovery_splash_url", - "emoji_limit", - "emojis", - "explicit_content_filter", - "features", - "icon", - "icon_url", - "name", - "owner", - "region", - "roles", - "rules_channel", - "verification_level", - ], - ) + embed = EventEmbed(title="Soundboard sound modified", description="") + embed.addSoundboardField("Sound", after) + if after.user: + embed.addMemberField("Uploader", after.user) + + properties_to_track = [ + "available", + "emoji", + "name", + "volume", + ] + if embed.addPropertyChangeFields(properties_to_track, before, after): + console_message = f"Soundboard sound modified: {after.name}" + await self.send_event_log( + guild=after.guild, + log_location="guild", + string_message=console_message, + embed_message=embed, + ) - embed = discord.Embed() - embed = auxiliary.add_diff_fields(embed, diff) - embed.add_field(name="Server", value=before.name) + @commands.Cog.listener() + async def on_guild_emojis_update( + self: Self, + guild: discord.Guild, + before: Sequence[discord.Emoji], + after: Sequence[discord.Emoji], + ) -> None: + """This logs events related to custom emojis in guilds being created, deleted or edited. + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_emojis_update - 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, - ) + Args: + guild (discord.Guild): The guild these changes occured in + before (Sequence[discord.Emoji]): The list of guild emojis before any changes + after (Sequence[discord.Emoji]): The list of guild emojis after any changes + """ + before_emojis = {emoji.id: emoji for emoji in before} + after_emojis = {emoji.id: emoji for emoji in after} + + # Created emojis + for emoji_id, emoji in after_emojis.items(): + if emoji_id not in before_emojis: + embed = EventEmbed(title="Emoji created", description="") + embed.addEmojiField("Emoji", emoji) + if emoji.user: + embed.addMemberField("Uploader", emoji.user) + + console_message = f"Emoji created: {emoji.name}" + + await self.send_event_log( + guild=guild, + log_location="guild", + string_message=console_message, + embed_message=embed, + ) + + # Deleted emojis + for emoji_id, emoji in before_emojis.items(): + if emoji_id not in after_emojis: + embed = EventEmbed(title="Emoji deleted", description="") + embed.addEmojiField("Emoji", emoji) + if emoji.user: + embed.addMemberField("Uploader", emoji.user) + + console_message = f"Emoji deleted: {emoji.name}" + + await self.send_event_log( + guild=guild, + log_location="guild", + string_message=console_message, + embed_message=embed, + ) + + # Modified emojis + # I honestly don't think this is possible to hit + properties_to_track = [ + "animated", + "name", + ] + + for emoji_id, after_emoji in after_emojis.items(): + before_emoji = before_emojis.get(emoji_id) + + if before_emoji is None: + continue + + embed = EventEmbed(title="Emoji modified", description="") + embed.addEmojiField("Emoji", after_emoji) + if after_emoji.user: + embed.addMemberField("Uploader", after_emoji.user) + + if embed.addPropertyChangeFields( + properties_to_track, + before_emoji, + after_emoji, + ): + console_message = f"Emoji modified: {after_emoji.name}" + + await self.send_event_log( + guild=guild, + log_location="guild", + string_message=console_message, + embed_message=embed, + ) @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 + async def on_guild_stickers_update( + self: Self, + guild: discord.Guild, + before: Sequence[discord.GuildSticker], + after: Sequence[discord.GuildSticker], + ) -> None: + """This logs events related to custom stickers in guilds being created, deleted or edited. + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_stickers_update Args: - role (discord.Role): The role that was created + guild (discord.Guild): The guild these changes occured in + before (Sequence[discord.GuildSticker]): The list of guild stickers before any changes + after (Sequence[discord.GuildSticker]): The list of guild stickers after any changes """ - embed = discord.Embed() - embed.add_field(name="Server", value=role.guild.name) - log_channel = configuration.get_config_entry( - role.guild.id, "core_guild_events_channel" - ) + before_stickers = {sticker.id: sticker for sticker in before} + after_stickers = {sticker.id: sticker for sticker in after} + + # Created stickers + for sticker_id, sticker in after_stickers.items(): + if sticker_id not in before_stickers: + embed = EventEmbed(title="Sticker created", description="") + embed.addStickerField("Sticker", sticker) + if sticker.user: + embed.addMemberField("Uploader", sticker.user) + + console_message = f"Sticker created: {sticker.name}" + + await self.send_event_log( + guild=guild, + log_location="guild", + string_message=console_message, + embed_message=embed, + ) + + # Deleted stickers + for sticker_id, sticker in before_stickers.items(): + if sticker_id not in after_stickers: + embed = EventEmbed(title="Sticker deleted", description="") + embed.addStickerField("Sticker", sticker) + if sticker.user: + embed.addMemberField("Uploader", sticker.user) + + console_message = f"Sticker deleted: {sticker.name}" + + await self.send_event_log( + guild=guild, + log_location="guild", + string_message=console_message, + embed_message=embed, + ) + + # Modified stickers + properties_to_track = [ + "description", + "emoji", + "name", + ] - await self.bot.logger.send_log( - message=( - f"New role with name {role.name} added to guild with ID {role.guild.id}" - ), - level=LogLevel.INFO, - context=LogContext(guild=role.guild), - channel=log_channel, - embed=embed, - ) + for sticker_id, after_sticker in after_stickers.items(): + before_sticker = before_stickers.get(sticker_id) + + if before_sticker is None: + continue + + embed = EventEmbed(title="Sticker modified", description="") + embed.addStickerField("Sticker", after_sticker) + if after_sticker.user: + embed.addMemberField("Uploader", after_sticker.user) + + if embed.addPropertyChangeFields( + properties_to_track, + before_sticker, + after_sticker, + ): + console_message = f"Sticker modified: {after_sticker.name}" + + await self.send_event_log( + guild=guild, + log_location="guild", + string_message=console_message, + embed_message=embed, + ) @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 + async def on_integration_create( + self: Self, integration: discord.Integration + ) -> None: + """This logs events related to new integrations being added in the guild + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_integration_create Args: - role (discord.Role): The role that was deleted + integration (discord.Integration): The integration that was created """ - embed = discord.Embed() - embed.add_field(name="Server", value=role.guild.name) - log_channel = configuration.get_config_entry( - role.guild.id, "core_guild_events_channel" - ) - await self.bot.logger.send_log( - message=( - f"Role with name {role.name} deleted from guild with ID {role.guild.id}" - ), - level=LogLevel.INFO, - context=LogContext(guild=role.guild), - channel=log_channel, - embed=embed, + embed = EventEmbed(title="Integration created", description="") + embed.addMemberField("Bot user", integration.account) + embed.addMemberField("Uploader", integration.user) + embed.addIntegrationField("Integration info", integration) + + console_message = f"Integration created: {integration.name} ({integration.id})" + + await self.send_event_log( + guild=integration.guild, + log_location="guild", + string_message=console_message, + embed_message=embed, ) @commands.Cog.listener() - async def on_guild_role_update( - self: Self, before: discord.Role, after: discord.Role + async def on_raw_integration_delete( + self: Self, payload: discord.RawIntegrationDeleteEvent ) -> None: - """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_role_update + """This logs events related to new integrations being removed from the guild + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_raw_integration_delete Args: - before (discord.Role): The updated role's old info. - after (discord.Role): The updated role's updated info. + payload (discord.RawIntegrationDeleteEvent): The integration that was deleted """ - attrs = ["color", "mentionable", "name", "permissions", "position", "tags"] - diff = auxiliary.get_object_diff(before, after, attrs) + guild = await self.bot.fetch_guild(payload.guild_id) + embed = EventEmbed( + title="Integration deleted", + description=f"Integration ID: {payload.integration_id}", + ) - embed = discord.Embed() - embed = auxiliary.add_diff_fields(embed, diff) - embed.add_field(name="Server", value=before.name) + console_message = f"Integration deleted: ({payload.integration_id})" - log_channel = configuration.get_config_entry( - before.guild.id, "core_guild_events_channel" + await self.send_event_log( + guild=guild, + log_location="guild", + string_message=console_message, + embed_message=embed, ) - await self.bot.logger.send_log( - message=( - f"Role with name {before.name} updated in guild with ID" - f" {before.guild.id}" - ), - level=LogLevel.INFO, - context=LogContext(guild=before.guild), - channel=log_channel, - embed=embed, + @commands.Cog.listener() + async def on_scheduled_event_create( + self: Self, event: discord.ScheduledEvent + ) -> None: + """Logs events related to creating new scheduled events in a guild + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_scheduled_event_create + + Args: + event (discord.ScheduledEvent): The event that has been created + """ + embed = EventEmbed(title="Scheduled event created", description=event.url) + embed.addScheduledEventField("Scheduled Event", event) + if event.creator: + embed.addMemberField("Creator", event.creator) + + console_message = f"Scheduled event created: {event.name} ({event.id})" + + await self.send_event_log( + guild=event.guild, + log_location="guild", + string_message=console_message, + embed_message=embed, + channel_location=event.channel, ) @commands.Cog.listener() - async def on_guild_emojis_update( - self: Self, - guild: discord.Guild, - _: Sequence[discord.Emoji], - __: Sequence[discord.Emoji], + async def on_scheduled_event_delete( + self: Self, event: discord.ScheduledEvent ) -> None: - """ - See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_emojis_update + """Logs events related to deleting scheduled events in a guild + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_scheduled_event_delete Args: - guild (discord.Guild): The guild who got their emojis updated. + event (discord.ScheduledEvent): The event that has been deleted """ - 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"Emojis updated in guild with ID {guild.id}", - level=LogLevel.INFO, - context=LogContext(guild=guild), - channel=log_channel, - embed=embed, + embed = EventEmbed(title="Scheduled event deleted", description=event.url) + embed.addScheduledEventField("Scheduled Event", event) + if event.creator: + embed.addMemberField("Creator", event.creator) + + console_message = f"Scheduled event deleted: {event.name} ({event.id})" + + await self.send_event_log( + guild=event.guild, + log_location="guild", + string_message=console_message, + embed_message=embed, + channel_location=event.channel, ) @commands.Cog.listener() - async def on_member_ban( - self: Self, guild: discord.Guild, user: discord.User | discord.Member + async def on_scheduled_event_update( + self: Self, + before: discord.ScheduledEvent, + after: discord.ScheduledEvent, ) -> None: - """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_member_ban + """This logs events related to scheduled events being edited + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_scheduled_event_update 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. + before (discord.ScheduledEvent): The original event, before any edits + after (discord.ScheduledEvent): The new event, after any edits """ - embed = discord.Embed() - embed.add_field(name="User", value=user) - embed.add_field(name="Server", value=guild.name) + properties_to_track = [ + "channel_id", + "description", + "end_time", + "entity_type", + "location", + "name", + "privacy_level", + "start_time", + "status", + ] + embed = EventEmbed(title="Scheduled event updated", description=after.url) + embed.addScheduledEventField("Scheduled Event", after) + + if embed.addPropertyChangeFields(properties_to_track, before, after): + console_message = f"Scheduled event updated: {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.channel, + ) - log_channel = configuration.get_config_entry( - guild.id, "core_member_events_channel" + @commands.Cog.listener() + async def on_guild_role_create(self: Self, role: discord.Role) -> None: + """This logs events related to creating new roles + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_role_create + + Args: + role (discord.Role): The role object that was created + """ + embed = EventEmbed(title="Role created", description="") + embed.addRoleField("Role", role) + embed.addRoleMetadataField("Role Metadata", role) + embed.addRoleColorField("Role Colors", role) + + console_message = f"Role created: {role.name} ({role.id})" + + await self.send_event_log( + guild=role.guild, + log_location="guild", + string_message=console_message, + embed_message=embed, ) - 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_guild_role_delete(self: Self, role: discord.Role) -> None: + """This logs events related to deleting roles + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_role_delete + + Args: + role (discord.Role): The role object that was deleted + """ + embed = EventEmbed(title="Role deleted", description="") + embed.addRoleField("Role", role) + embed.addRoleMetadataField("Role Metadata", role) + embed.addRoleColorField("Role Colors", role) + + console_message = f"Role deleted: {role.name} ({role.id})" + + await self.send_event_log( + guild=role.guild, + log_location="guild", + string_message=console_message, + embed_message=embed, ) @commands.Cog.listener() - async def on_member_unban( - self: Self, guild: discord.Guild, user: discord.User + async def on_guild_role_update( + self: Self, before: discord.Role, after: discord.Role ) -> None: - """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_member_unban + """This logs events related to updating a role. 3 different logs are generated here: + Role color changes + Role permission changes + Other role property changes + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_role_update Args: - guild (discord.Guild): The guild the user got unbanned from - user (discord.User): The user that got unbanned + before (discord.Role): The old role, before any changes + after (discord.Role): The new role, after any changes """ - embed = discord.Embed() - embed.add_field(name="User", value=user) - embed.add_field(name="Server", value=guild.name) + general_properties_to_track = [ + "display_icon", + "flags", + "hoist", + "icon", + "managed", + "mentionable", + "name", + "position", + "tags", + "unicode_emoji", + ] - log_channel = configuration.get_config_entry( - guild.id, "core_member_events_channel" - ) + general_embed = EventEmbed(title="Role properties updated", description="") + general_embed.addRoleField("Role", after) - 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, + if general_embed.addPropertyChangeFields( + general_properties_to_track, before, after + ): + console_message = f"Role properties updated: {after.name} ({after.id})" + + await self.send_event_log( + guild=after.guild, + log_location="guild", + string_message=console_message, + embed_message=general_embed, + ) + + permission_embed = EventEmbed(title="Role permissions updated", description="") + permission_embed.addRoleField("Role", after) + + if permission_embed.addRolePermissionChangeFields( + before.permissions, after.permissions + ): + console_message = f"Role permissions updated: {after.name} ({after.id})" + + await self.send_event_log( + guild=after.guild, + log_location="guild", + string_message=console_message, + embed_message=permission_embed, + ) + + color_properties_to_track = [ + "colour", + "secondary_colour", + "tertiary_colour", + ] + color_embed = EventEmbed(title="Role colors updated", description="") + color_embed.addRoleField("Role", after) + color_embed.addRoleColorField("Role Colors", after) + + if color_embed.addPropertyChangeFields( + color_properties_to_track, before, after + ): + console_message = f"Role colors updated: {after.name} ({after.id})" + + await self.send_event_log( + guild=after.guild, + log_location="guild", + string_message=console_message, + embed_message=color_embed, + ) + + @commands.Cog.listener() + async def on_automod_rule_create(self: Self, rule: discord.AutoModRule) -> None: + """This logs events related to creating a new automod rule + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_automod_rule_create + + Args: + rule (discord.AutoModRule): The automod rule that was created + """ + embed = EventEmbed(title="AutoMod rule created", description="") + embed.addAutoModRuleField("AutoMod Rule", rule) + if rule.creator: + embed.addMemberField("Creator", rule.creator) + + console_message = f"AutoMod rule created: {rule.name} ({rule.id})" + + await self.send_event_log( + guild=rule.guild, + log_location="guild", + string_message=console_message, + embed_message=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 + async def on_automod_rule_update(self: Self, rule: discord.AutoModRule) -> None: + """This logs events related to updating an automod rule + As discord does not give the old rule before edits, we cannot log what has changed. + Only that something has changed. + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_automod_rule_update Args: - member (discord.Member): The member who joined + rule (discord.AutoModRule): The automod rule that was updated """ - 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="AutoMod rule updated", description="") + embed.addAutoModRuleField("AutoMod Rule", rule) + if rule.creator: + embed.addMemberField("Creator", rule.creator) + + console_message = f"AutoMod rule updated: {rule.name} ({rule.id})" + + await self.send_event_log( + guild=rule.guild, + log_location="guild", + string_message=console_message, + embed_message=embed, ) - 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, + @commands.Cog.listener() + async def on_automod_rule_delete(self: Self, rule: discord.AutoModRule) -> None: + """This logs events related to deleting an automod rule + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_automod_rule_delete + + Args: + rule (discord.AutoModRule): The automod rule that was deleted + """ + embed = EventEmbed(title="AutoMod rule deleted", description="") + embed.addAutoModRuleField("AutoMod Rule", rule) + if rule.creator: + embed.addMemberField("Creator", rule.creator) + + console_message = f"AutoMod rule deleted: {rule.name} ({rule.id})" + + await self.send_event_log( + guild=rule.guild, + log_location="guild", + string_message=console_message, + embed_message=embed, ) + # Bot Events + @commands.Cog.listener() async def on_command(self: Self, ctx: commands.Context) -> None: """ @@ -828,44 +2266,3 @@ async def on_command(self: Self, ctx: commands.Context) -> None: channel=log_channel, embed=embed, ) - - @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. - - Args: - event_method (str): the event method name associated with the error (eg. on_message) - """ - _, exception, _ = sys.exc_info() - await self.bot.logger.send_log( - message=f"Bot error in {event_method}: {exception}", - level=LogLevel.ERROR, - exception=exception, - ) - - @commands.Cog.listener() - async def on_connect(self: Self) -> None: - """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_connect""" - await self.bot.logger.send_log( - message="Connected to Discord", - level=LogLevel.INFO, - console_only=True, - ) - - @commands.Cog.listener() - async def on_resumed(self: Self) -> None: - """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_resumed""" - await self.bot.logger.send_log( - message="Resume event", - level=LogLevel.INFO, - console_only=True, - ) - - @commands.Cog.listener() - async def on_disconnect(self: Self) -> None: - """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_disconnect""" - await self.bot.logger.send_log( - message="Disconnected from Discord", - level=LogLevel.INFO, - console_only=True, - )