Repository: python-discord/bot

Issue:
Log voice channel events
# Abstract

Start logging whenever a user changes their voice state to a new channel called `#voice-log`. Depends on issue: [#161](https://github.com/python-discord/organisation/issues/161) being actioned.

# Specification

Relevant d.py event [here](https://discordpy.readthedocs.io/en/latest/api.html#discord.on_voice_state_update)

* Ignore Staff voice channel
* Ignore Admins voice channel

The format of this log has not yet been discussed, and thus is largely up to the person who implements this feature. I suggest taking the format of the logs in `#user-log` as inspiration.

# Motivation

We think it could be useful information to have. The issue #644 was discussed at the same time as this one.


Discussion:

Resolving patch:
diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py
--- a/bot/cogs/moderation/modlog.py
+++ b/bot/cogs/moderation/modlog.py
@@ -26,6 +26,12 @@
 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."""
@@ -206,7 +212,7 @@ async def on_guild_channel_update(self, before: GUILD_CHANNEL, after: GuildChann
                 new = value["new_value"]
                 old = value["old_value"]
 
-                changes.append(f"**{key.title()}:** `{old}` **->** `{new}`")
+                changes.append(f"**{key.title()}:** `{old}` **→** `{new}`")
 
             done.append(key)
 
@@ -284,7 +290,7 @@ async def on_guild_role_update(self, before: discord.Role, after: discord.Role)
                 new = value["new_value"]
                 old = value["old_value"]
 
-                changes.append(f"**{key.title()}:** `{old}` **->** `{new}`")
+                changes.append(f"**{key.title()}:** `{old}` **→** `{new}`")
 
             done.append(key)
 
@@ -334,7 +340,7 @@ async def on_guild_update(self, before: discord.Guild, after: discord.Guild) ->
             new = value["new_value"]
             old = value["old_value"]
 
-            changes.append(f"**{key.title()}:** `{old}` **->** `{new}`")
+            changes.append(f"**{key.title()}:** `{old}` **→** `{new}`")
 
             done.append(key)
 
@@ -487,23 +493,23 @@ async def on_member_update(self, before: discord.Member, after: discord.Member)
                 old = value.get("old_value")
 
                 if new and old:
-                    changes.append(f"**{key.title()}:** `{old}` **->** `{new}`")
+                    changes.append(f"**{key.title()}:** `{old}` **→** `{new}`")
 
             done.append(key)
 
         if before.name != after.name:
             changes.append(
-                f"**Username:** `{before.name}` **->** `{after.name}`"
+                f"**Username:** `{before.name}` **→** `{after.name}`"
             )
 
         if before.discriminator != after.discriminator:
             changes.append(
-                f"**Discriminator:** `{before.discriminator}` **->** `{after.discriminator}`"
+                f"**Discriminator:** `{before.discriminator}` **→** `{after.discriminator}`"
             )
 
         if before.display_name != after.display_name:
             changes.append(
-                f"**Display name:** `{before.display_name}` **->** `{after.display_name}`"
+                f"**Display name:** `{before.display_name}` **→** `{after.display_name}`"
             )
 
         if not changes:
@@ -749,3 +755,76 @@ async def on_raw_message_edit(self, event: discord.RawMessageUpdateEvent) -> Non
             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.ignored)
+        ):
+            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"**{member}** (`{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
+        )
diff --git a/bot/constants.py b/bot/constants.py
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -333,6 +333,10 @@ class Icons(metaclass=YAMLGetter):
     superstarify: str
     unsuperstarify: str
 
+    voice_state_blue: str
+    voice_state_green: str
+    voice_state_red: str
+
 
 class CleanMessages(metaclass=YAMLGetter):
     section = "bot"
@@ -387,6 +391,7 @@ class Channels(metaclass=YAMLGetter):
     userlog: int
     user_event_a: int
     verification: int
+    voice_log: int
 
 
 class Webhooks(metaclass=YAMLGetter):
@@ -553,6 +558,8 @@ class Event(Enum):
     message_delete = "message_delete"
     message_edit = "message_edit"
 
+    voice_state_update = "voice_state_update"
+
 
 # Debug mode
 DEBUG_MODE = True if 'local' in os.environ.get("SITE_URL", "local") else False


Test patch: