aboutsummaryrefslogtreecommitdiffstats
path: root/bot/exts/moderation/modlog.py
diff options
context:
space:
mode:
Diffstat (limited to 'bot/exts/moderation/modlog.py')
-rw-r--r--bot/exts/moderation/modlog.py830
1 files changed, 830 insertions, 0 deletions
diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py
new file mode 100644
index 000000000..41ed46b69
--- /dev/null
+++ b/bot/exts/moderation/modlog.py
@@ -0,0 +1,830 @@
+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 bot.bot import Bot
+from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs
+from bot.utils.messages import format_user
+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")
+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"
+
+ # Truncate content to 2000 characters and append an ellipsis.
+ if content and len(content) > 2000:
+ content = content[:2000 - 3] + "..."
+
+ channel = self.bot.get_channel(channel_id)
+ log_message = await channel.send(
+ content=content,
+ embed=embed,
+ files=files,
+ allowed_mentions=discord.AllowedMentions(everyone=True)
+ )
+
+ 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", format_user(member),
+ 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
+
+ now = datetime.utcnow()
+ difference = abs(relativedelta(now, member.created_at))
+
+ message = format_user(member) + "\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
+
+ await self.send_log_message(
+ Icons.sign_out, Colours.soft_red,
+ "User left", format_user(member),
+ 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
+
+ await self.send_log_message(
+ Icons.user_unban, Colour.blurple(),
+ "User unbanned", format_user(member),
+ thumbnail=member.avatar_url_as(static_format="png"),
+ channel_id=Channels.mod_log
+ )
+
+ @staticmethod
+ def get_role_diff(before: t.List[discord.Role], after: t.List[discord.Role]) -> t.List[str]:
+ """Return a list of strings describing the roles added and removed."""
+ changes = []
+ before_roles = set(before)
+ after_roles = set(after)
+
+ for role in (before_roles - after_roles):
+ changes.append(f"**Role removed:** {role.name} (`{role.id}`)")
+
+ for role in (after_roles - before_roles):
+ changes.append(f"**Role added:** {role.name} (`{role.id}`)")
+
+ return changes
+
+ @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
+
+ changes = self.get_role_diff(before.roles, after.roles)
+
+ # The regex is a simple way to exclude all sequence and mapping types.
+ diff = DeepDiff(before, after, exclude_regex_paths=r".*\[.*")
+
+ # A type change seems to always take precedent over a value change. Furthermore, it will
+ # include the value change along with the type change anyway. Therefore, it's OK to
+ # "overwrite" values_changed; in practice there will never even be anything to overwrite.
+ diff_values = {**diff.get("values_changed", {}), **diff.get("type_changes", {})}
+
+ for attr, value in diff_values.items():
+ if not attr: # Not sure why, but it happens.
+ continue
+
+ attr = attr[5:] # Remove "root." prefix.
+ attr = attr.replace("_", " ").replace(".", " ").capitalize()
+
+ new = value.get("new_value")
+ old = value.get("old_value")
+
+ changes.append(f"**{attr}:** `{old}` **→** `{new}`")
+
+ if not changes:
+ return
+
+ message = ""
+
+ for item in sorted(changes):
+ message += f"{Emojis.bullet} {item}\n"
+
+ message = f"{format_user(after)}\n{message}"
+
+ await self.send_log_message(
+ icon_url=Icons.user_update,
+ colour=Colour.blurple(),
+ title="Member updated",
+ text=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
+
+ if channel.category:
+ response = (
+ f"**Author:** {format_user(author)}\n"
+ f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"
+ f"**Message ID:** `{message.id}`\n"
+ "\n"
+ )
+ else:
+ response = (
+ f"**Author:** {format_user(author)}\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
+
+ 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:** {format_user(msg_before.author)}\n"
+ f"**Channel:** {channel_name} (`{channel.id}`)\n"
+ f"**Message ID:** `{msg_before.id}`\n"
+ "\n"
+ f"**Before**:\n{' '.join(content_before)}\n"
+ f"**After**:\n{' '.join(content_after)}\n"
+ "\n"
+ f"[Jump to message]({msg_after.jump_url})"
+ )
+
+ if msg_before.edited_at:
+ # Message was previously edited, to assist with self-bot detection, use the edited_at
+ # datetime as the baseline and create a human-readable delta between this edit event
+ # and the last time the message was edited
+ timestamp = msg_before.edited_at
+ delta = humanize_delta(relativedelta(msg_after.edited_at, msg_before.edited_at))
+ footer = f"Last edited {delta} ago"
+ else:
+ # Message was not previously edited, use the created_at datetime as the baseline, no
+ # delta calculation needed
+ timestamp = msg_before.created_at
+ footer = None
+
+ await self.send_log_message(
+ Icons.message_edit, Colour.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
+
+ channel = message.channel
+ channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}"
+
+ before_response = (
+ f"**Author:** {format_user(message.author)}\n"
+ f"**Channel:** {channel_name} (`{channel.id}`)\n"
+ f"**Message ID:** `{message.id}`\n"
+ "\n"
+ "This message was not cached, so the message content cannot be displayed."
+ )
+
+ after_response = (
+ f"**Author:** {format_user(message.author)}\n"
+ f"**Channel:** {channel_name} (`{channel.id}`)\n"
+ f"**Message ID:** `{message.id}`\n"
+ "\n"
+ f"{message.clean_content}"
+ )
+
+ await self.send_log_message(
+ Icons.message_edit, Colour.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
+
+ message = "\n".join(f"{Emojis.bullet} {item}" for item in sorted(changes))
+ message = f"{format_user(member)}\n{message}"
+
+ await self.send_log_message(
+ icon_url=icon,
+ colour=colour,
+ title="Voice state updated",
+ text=message,
+ thumbnail=member.avatar_url_as(static_format="png"),
+ channel_id=Channels.voice_log
+ )
+
+
+def setup(bot: Bot) -> None:
+ """Load the ModLog cog."""
+ bot.add_cog(ModLog(bot))