import asyncio import difflib import itertools import logging import typing as t from datetime import datetime from itertools import zip_longest import discord from dateutil.relativedelta import relativedelta from deepdiff import DeepDiff from discord import Colour from discord.abc import GuildChannel from discord.ext.commands import Cog, Context from discord.utils import escape_markdown from bot.bot import Bot from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs from bot.utils.time import humanize_delta log = logging.getLogger(__name__) GUILD_CHANNEL = t.Union[discord.CategoryChannel, discord.TextChannel, discord.VoiceChannel] CHANNEL_CHANGES_UNSUPPORTED = ("permissions",) CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position") MEMBER_CHANGES_SUPPRESSED = ("status", "activities", "_client_status", "nick") ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions") VOICE_STATE_ATTRIBUTES = { "channel.name": "Channel", "self_stream": "Streaming", "self_video": "Broadcasting", } class ModLog(Cog, name="ModLog"): """Logging for server events and staff actions.""" def __init__(self, bot: Bot): self.bot = bot self._ignored = {event: [] for event in Event} self._cached_deletes = [] self._cached_edits = [] async def upload_log( self, messages: t.Iterable[discord.Message], actor_id: int, attachments: t.Iterable[t.List[str]] = None ) -> str: """Upload message logs to the database and return a URL to a page for viewing the logs.""" if attachments is None: attachments = [] response = await self.bot.api_client.post( 'bot/deleted-messages', json={ 'actor': actor_id, 'creation': datetime.utcnow().isoformat(), 'deletedmessage_set': [ { 'id': message.id, 'author': message.author.id, 'channel_id': message.channel.id, 'content': message.content, 'embeds': [embed.to_dict() for embed in message.embeds], 'attachments': attachment, } for message, attachment in zip_longest(messages, attachments, fillvalue=[]) ] } ) return f"{URLs.site_logs_view}/{response['id']}" def ignore(self, event: Event, *items: int) -> None: """Add event to ignored events to suppress log emission.""" for item in items: if item not in self._ignored[event]: self._ignored[event].append(item) async def send_log_message( self, icon_url: t.Optional[str], colour: t.Union[discord.Colour, int], title: t.Optional[str], text: str, thumbnail: t.Optional[t.Union[str, discord.Asset]] = None, channel_id: int = Channels.mod_log, ping_everyone: bool = False, files: t.Optional[t.List[discord.File]] = None, content: t.Optional[str] = None, additional_embeds: t.Optional[t.List[discord.Embed]] = None, additional_embeds_msg: t.Optional[str] = None, timestamp_override: t.Optional[datetime] = None, footer: t.Optional[str] = None, ) -> Context: """Generate log embed and send to logging channel.""" # Truncate string directly here to avoid removing newlines embed = discord.Embed( description=text[:2045] + "..." if len(text) > 2048 else text ) if title and icon_url: embed.set_author(name=title, icon_url=icon_url) embed.colour = colour embed.timestamp = timestamp_override or datetime.utcnow() if footer: embed.set_footer(text=footer) if thumbnail: embed.set_thumbnail(url=thumbnail) if ping_everyone: if content: content = f"@everyone\n{content}" else: content = "@everyone" channel = self.bot.get_channel(channel_id) log_message = await channel.send(content=content, embed=embed, files=files) if additional_embeds: if additional_embeds_msg: await channel.send(additional_embeds_msg) for additional_embed in additional_embeds: await channel.send(embed=additional_embed) return await self.bot.get_context(log_message) # Optionally return for use with antispam @Cog.listener() async def on_guild_channel_create(self, channel: GUILD_CHANNEL) -> None: """Log channel create event to mod log.""" if channel.guild.id != GuildConstant.id: return if isinstance(channel, discord.CategoryChannel): title = "Category created" message = f"{channel.name} (`{channel.id}`)" elif isinstance(channel, discord.VoiceChannel): title = "Voice channel created" if channel.category: message = f"{channel.category}/{channel.name} (`{channel.id}`)" else: message = f"{channel.name} (`{channel.id}`)" else: title = "Text channel created" if channel.category: message = f"{channel.category}/{channel.name} (`{channel.id}`)" else: message = f"{channel.name} (`{channel.id}`)" await self.send_log_message(Icons.hash_green, Colours.soft_green, title, message) @Cog.listener() async def on_guild_channel_delete(self, channel: GUILD_CHANNEL) -> None: """Log channel delete event to mod log.""" if channel.guild.id != GuildConstant.id: return if isinstance(channel, discord.CategoryChannel): title = "Category deleted" elif isinstance(channel, discord.VoiceChannel): title = "Voice channel deleted" else: title = "Text channel deleted" if channel.category and not isinstance(channel, discord.CategoryChannel): message = f"{channel.category}/{channel.name} (`{channel.id}`)" else: message = f"{channel.name} (`{channel.id}`)" await self.send_log_message( Icons.hash_red, Colours.soft_red, title, message ) @Cog.listener() async def on_guild_channel_update(self, before: GUILD_CHANNEL, after: GuildChannel) -> None: """Log channel update event to mod log.""" if before.guild.id != GuildConstant.id: return if before.id in self._ignored[Event.guild_channel_update]: self._ignored[Event.guild_channel_update].remove(before.id) return # Two channel updates are sent for a single edit: 1 for topic and 1 for category change. # TODO: remove once support is added for ignoring multiple occurrences for the same channel. help_categories = (Categories.help_available, Categories.help_dormant, Categories.help_in_use) if after.category and after.category.id in help_categories: return diff = DeepDiff(before, after) changes = [] done = [] diff_values = diff.get("values_changed", {}) diff_values.update(diff.get("type_changes", {})) for key, value in diff_values.items(): if not key: # Not sure why, but it happens continue key = key[5:] # Remove "root." prefix if "[" in key: key = key.split("[", 1)[0] if "." in key: key = key.split(".", 1)[0] if key in done or key in CHANNEL_CHANGES_SUPPRESSED: continue if key in CHANNEL_CHANGES_UNSUPPORTED: changes.append(f"**{key.title()}** updated") else: new = value["new_value"] old = value["old_value"] # Discord does not treat consecutive backticks ("``") as an empty inline code block, so the markdown # formatting is broken when `new` and/or `old` are empty values. "None" is used for these cases so # formatting is preserved. changes.append(f"**{key.title()}:** `{old or 'None'}` **→** `{new or 'None'}`") done.append(key) if not changes: return message = "" for item in sorted(changes): message += f"{Emojis.bullet} {item}\n" if after.category: message = f"**{after.category}/#{after.name} (`{after.id}`)**\n{message}" else: message = f"**#{after.name}** (`{after.id}`)\n{message}" await self.send_log_message( Icons.hash_blurple, Colour.blurple(), "Channel updated", message ) @Cog.listener() async def on_guild_role_create(self, role: discord.Role) -> None: """Log role create event to mod log.""" if role.guild.id != GuildConstant.id: return await self.send_log_message( Icons.crown_green, Colours.soft_green, "Role created", f"`{role.id}`" ) @Cog.listener() async def on_guild_role_delete(self, role: discord.Role) -> None: """Log role delete event to mod log.""" if role.guild.id != GuildConstant.id: return await self.send_log_message( Icons.crown_red, Colours.soft_red, "Role removed", f"{role.name} (`{role.id}`)" ) @Cog.listener() async def on_guild_role_update(self, before: discord.Role, after: discord.Role) -> None: """Log role update event to mod log.""" if before.guild.id != GuildConstant.id: return diff = DeepDiff(before, after) changes = [] done = [] diff_values = diff.get("values_changed", {}) diff_values.update(diff.get("type_changes", {})) for key, value in diff_values.items(): if not key: # Not sure why, but it happens continue key = key[5:] # Remove "root." prefix if "[" in key: key = key.split("[", 1)[0] if "." in key: key = key.split(".", 1)[0] if key in done or key == "color": continue if key in ROLE_CHANGES_UNSUPPORTED: changes.append(f"**{key.title()}** updated") else: new = value["new_value"] old = value["old_value"] changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") done.append(key) if not changes: return message = "" for item in sorted(changes): message += f"{Emojis.bullet} {item}\n" message = f"**{after.name}** (`{after.id}`)\n{message}" await self.send_log_message( Icons.crown_blurple, Colour.blurple(), "Role updated", message ) @Cog.listener() async def on_guild_update(self, before: discord.Guild, after: discord.Guild) -> None: """Log guild update event to mod log.""" if before.id != GuildConstant.id: return diff = DeepDiff(before, after) changes = [] done = [] diff_values = diff.get("values_changed", {}) diff_values.update(diff.get("type_changes", {})) for key, value in diff_values.items(): if not key: # Not sure why, but it happens continue key = key[5:] # Remove "root." prefix if "[" in key: key = key.split("[", 1)[0] if "." in key: key = key.split(".", 1)[0] if key in done: continue new = value["new_value"] old = value["old_value"] changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") done.append(key) if not changes: return message = "" for item in sorted(changes): message += f"{Emojis.bullet} {item}\n" message = f"**{after.name}** (`{after.id}`)\n{message}" await self.send_log_message( Icons.guild_update, Colour.blurple(), "Guild updated", message, thumbnail=after.icon_url_as(format="png") ) @Cog.listener() async def on_member_ban(self, guild: discord.Guild, member: discord.Member) -> None: """Log ban event to user log.""" if guild.id != GuildConstant.id: return if member.id in self._ignored[Event.member_ban]: self._ignored[Event.member_ban].remove(member.id) return await self.send_log_message( Icons.user_ban, Colours.soft_red, "User banned", f"{member} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png"), channel_id=Channels.user_log ) @Cog.listener() async def on_member_join(self, member: discord.Member) -> None: """Log member join event to user log.""" if member.guild.id != GuildConstant.id: return member_str = escape_markdown(str(member)) message = f"{member_str} (`{member.id}`)" now = datetime.utcnow() difference = abs(relativedelta(now, member.created_at)) message += "\n\n**Account age:** " + humanize_delta(difference) if difference.days < 1 and difference.months < 1 and difference.years < 1: # New user account! message = f"{Emojis.new} {message}" await self.send_log_message( Icons.sign_in, Colours.soft_green, "User joined", message, thumbnail=member.avatar_url_as(static_format="png"), channel_id=Channels.user_log ) @Cog.listener() async def on_member_remove(self, member: discord.Member) -> None: """Log member leave event to user log.""" if member.guild.id != GuildConstant.id: return if member.id in self._ignored[Event.member_remove]: self._ignored[Event.member_remove].remove(member.id) return member_str = escape_markdown(str(member)) await self.send_log_message( Icons.sign_out, Colours.soft_red, "User left", f"{member_str} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png"), channel_id=Channels.user_log ) @Cog.listener() async def on_member_unban(self, guild: discord.Guild, member: discord.User) -> None: """Log member unban event to mod log.""" if guild.id != GuildConstant.id: return if member.id in self._ignored[Event.member_unban]: self._ignored[Event.member_unban].remove(member.id) return member_str = escape_markdown(str(member)) await self.send_log_message( Icons.user_unban, Colour.blurple(), "User unbanned", f"{member_str} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png"), channel_id=Channels.mod_log ) @Cog.listener() async def on_member_update(self, before: discord.Member, after: discord.Member) -> None: """Log member update event to user log.""" if before.guild.id != GuildConstant.id: return if before.id in self._ignored[Event.member_update]: self._ignored[Event.member_update].remove(before.id) return diff = DeepDiff(before, after) changes = [] done = [] diff_values = {} diff_values.update(diff.get("values_changed", {})) diff_values.update(diff.get("type_changes", {})) diff_values.update(diff.get("iterable_item_removed", {})) diff_values.update(diff.get("iterable_item_added", {})) diff_user = DeepDiff(before._user, after._user) diff_values.update(diff_user.get("values_changed", {})) diff_values.update(diff_user.get("type_changes", {})) diff_values.update(diff_user.get("iterable_item_removed", {})) diff_values.update(diff_user.get("iterable_item_added", {})) for key, value in diff_values.items(): if not key: # Not sure why, but it happens continue key = key[5:] # Remove "root." prefix if "[" in key: key = key.split("[", 1)[0] if "." in key: key = key.split(".", 1)[0] if key in done or key in MEMBER_CHANGES_SUPPRESSED: continue if key == "_roles": new_roles = after.roles old_roles = before.roles for role in old_roles: if role not in new_roles: changes.append(f"**Role removed:** {role.name} (`{role.id}`)") for role in new_roles: if role not in old_roles: changes.append(f"**Role added:** {role.name} (`{role.id}`)") else: new = value.get("new_value") old = value.get("old_value") if new and old: changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") done.append(key) if before.name != after.name: changes.append( f"**Username:** `{before.name}` **→** `{after.name}`" ) if before.discriminator != after.discriminator: changes.append( f"**Discriminator:** `{before.discriminator}` **→** `{after.discriminator}`" ) if before.display_name != after.display_name: changes.append( f"**Display name:** `{before.display_name}` **→** `{after.display_name}`" ) if not changes: return message = "" for item in sorted(changes): message += f"{Emojis.bullet} {item}\n" member_str = escape_markdown(str(after)) message = f"**{member_str}** (`{after.id}`)\n{message}" await self.send_log_message( Icons.user_update, Colour.blurple(), "Member updated", message, thumbnail=after.avatar_url_as(static_format="png"), channel_id=Channels.user_log ) @Cog.listener() async def on_message_delete(self, message: discord.Message) -> None: """Log message delete event to message change log.""" channel = message.channel author = message.author # Ignore DMs. if not message.guild: return if message.guild.id != GuildConstant.id or channel.id in GuildConstant.modlog_blacklist: return self._cached_deletes.append(message.id) if message.id in self._ignored[Event.message_delete]: self._ignored[Event.message_delete].remove(message.id) return if author.bot: return author_str = escape_markdown(str(author)) if channel.category: response = ( f"**Author:** {author_str} (`{author.id}`)\n" f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" ) else: response = ( f"**Author:** {author_str} (`{author.id}`)\n" f"**Channel:** #{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" ) if message.attachments: # Prepend the message metadata with the number of attachments response = f"**Attachments:** {len(message.attachments)}\n" + response # Shorten the message content if necessary content = message.clean_content remaining_chars = 2040 - len(response) if len(content) > remaining_chars: botlog_url = await self.upload_log(messages=[message], actor_id=message.author.id) ending = f"\n\nMessage truncated, [full message here]({botlog_url})." truncation_point = remaining_chars - len(ending) content = f"{content[:truncation_point]}...{ending}" response += f"{content}" await self.send_log_message( Icons.message_delete, Colours.soft_red, "Message deleted", response, channel_id=Channels.message_log ) @Cog.listener() async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> None: """Log raw message delete event to message change log.""" if event.guild_id != GuildConstant.id or event.channel_id in GuildConstant.modlog_blacklist: return await asyncio.sleep(1) # Wait here in case the normal event was fired if event.message_id in self._cached_deletes: # It was in the cache and the normal event was fired, so we can just ignore it self._cached_deletes.remove(event.message_id) return if event.message_id in self._ignored[Event.message_delete]: self._ignored[Event.message_delete].remove(event.message_id) return channel = self.bot.get_channel(event.channel_id) if channel.category: response = ( f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{event.message_id}`\n" "\n" "This message was not cached, so the message content cannot be displayed." ) else: response = ( f"**Channel:** #{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{event.message_id}`\n" "\n" "This message was not cached, so the message content cannot be displayed." ) await self.send_log_message( Icons.message_delete, Colours.soft_red, "Message deleted", response, channel_id=Channels.message_log ) @Cog.listener() async def on_message_edit(self, msg_before: discord.Message, msg_after: discord.Message) -> None: """Log message edit event to message change log.""" if ( not msg_before.guild or msg_before.guild.id != GuildConstant.id or msg_before.channel.id in GuildConstant.modlog_blacklist or msg_before.author.bot ): return self._cached_edits.append(msg_before.id) if msg_before.content == msg_after.content: return author = msg_before.author author_str = escape_markdown(str(author)) channel = msg_before.channel channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}" # Getting the difference per words and group them by type - add, remove, same # Note that this is intended grouping without sorting diff = difflib.ndiff(msg_before.clean_content.split(), msg_after.clean_content.split()) diff_groups = tuple( (diff_type, tuple(s[2:] for s in diff_words)) for diff_type, diff_words in itertools.groupby(diff, key=lambda s: s[0]) ) content_before: t.List[str] = [] content_after: t.List[str] = [] for index, (diff_type, words) in enumerate(diff_groups): sub = ' '.join(words) if diff_type == '-': content_before.append(f"[{sub}](http://o.hi)") elif diff_type == '+': content_after.append(f"[{sub}](http://o.hi)") elif diff_type == ' ': if len(words) > 2: sub = ( f"{words[0] if index > 0 else ''}" " ... " f"{words[-1] if index < len(diff_groups) - 1 else ''}" ) content_before.append(sub) content_after.append(sub) response = ( f"**Author:** {author_str} (`{author.id}`)\n" f"**Channel:** {channel_name} (`{channel.id}`)\n" f"**Message ID:** `{msg_before.id}`\n" "\n" f"**Before**:\n{' '.join(content_before)}\n" f"**After**:\n{' '.join(content_after)}\n" "\n" f"[Jump to message]({msg_after.jump_url})" ) if msg_before.edited_at: # Message was previously edited, to assist with self-bot detection, use the edited_at # datetime as the baseline and create a human-readable delta between this edit event # and the last time the message was edited timestamp = msg_before.edited_at delta = humanize_delta(relativedelta(msg_after.edited_at, msg_before.edited_at)) footer = f"Last edited {delta} ago" else: # Message was not previously edited, use the created_at datetime as the baseline, no # delta calculation needed timestamp = msg_before.created_at footer = None await self.send_log_message( Icons.message_edit, Colour.blurple(), "Message edited", response, channel_id=Channels.message_log, timestamp_override=timestamp, footer=footer ) @Cog.listener() async def on_raw_message_edit(self, event: discord.RawMessageUpdateEvent) -> None: """Log raw message edit event to message change log.""" try: channel = self.bot.get_channel(int(event.data["channel_id"])) message = await channel.fetch_message(event.message_id) except discord.NotFound: # Was deleted before we got the event return if ( not message.guild or message.guild.id != GuildConstant.id or message.channel.id in GuildConstant.modlog_blacklist or message.author.bot ): return await asyncio.sleep(1) # Wait here in case the normal event was fired if event.message_id in self._cached_edits: # It was in the cache and the normal event was fired, so we can just ignore it self._cached_edits.remove(event.message_id) return author = message.author channel = message.channel channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}" before_response = ( f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** {channel_name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" "This message was not cached, so the message content cannot be displayed." ) after_response = ( f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** {channel_name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" f"{message.clean_content}" ) await self.send_log_message( Icons.message_edit, Colour.blurple(), "Message edited (Before)", before_response, channel_id=Channels.message_log ) await self.send_log_message( Icons.message_edit, Colour.blurple(), "Message edited (After)", after_response, channel_id=Channels.message_log ) @Cog.listener() async def on_voice_state_update( self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState ) -> None: """Log member voice state changes to the voice log channel.""" if ( member.guild.id != GuildConstant.id or (before.channel and before.channel.id in GuildConstant.modlog_blacklist) ): return if member.id in self._ignored[Event.voice_state_update]: self._ignored[Event.voice_state_update].remove(member.id) return # Exclude all channel attributes except the name. diff = DeepDiff( before, after, exclude_paths=("root.session_id", "root.afk"), exclude_regex_paths=r"root\.channel\.(?!name)", ) # A type change seems to always take precedent over a value change. Furthermore, it will # include the value change along with the type change anyway. Therefore, it's OK to # "overwrite" values_changed; in practice there will never even be anything to overwrite. diff_values = {**diff.get("values_changed", {}), **diff.get("type_changes", {})} icon = Icons.voice_state_blue colour = Colour.blurple() changes = [] for attr, values in diff_values.items(): if not attr: # Not sure why, but it happens. continue old = values["old_value"] new = values["new_value"] attr = attr[5:] # Remove "root." prefix. attr = VOICE_STATE_ATTRIBUTES.get(attr, attr.replace("_", " ").capitalize()) changes.append(f"**{attr}:** `{old}` **→** `{new}`") # Set the embed icon and colour depending on which attribute changed. if any(name in attr for name in ("Channel", "deaf", "mute")): if new is None or new is True: # Left a channel or was muted/deafened. icon = Icons.voice_state_red colour = Colours.soft_red elif old is None or old is True: # Joined a channel or was unmuted/undeafened. icon = Icons.voice_state_green colour = Colours.soft_green if not changes: return member_str = escape_markdown(str(member)) message = "\n".join(f"{Emojis.bullet} {item}" for item in sorted(changes)) message = f"**{member_str}** (`{member.id}`)\n{message}" await self.send_log_message( icon_url=icon, colour=colour, title="Voice state updated", text=message, thumbnail=member.avatar_url_as(static_format="png"), channel_id=Channels.voice_log )