Repository: gpauloski/3pseatBot

Issue:
Play sounds on member voice channel join
### Describe the Request

Members can register a sound to their account that will play when they join a voice channel in the guild.

```
/sounds register [name]  # name should autocomplete
```

Adding a second table to the `SoundCommands` is probably the easiest way to achieve this.

Relevant: 
- [Voice state update hook](https://discordpy.readthedocs.io/en/stable/api.html?highlight=on_voice_state#discord.on_voice_state_update)

### Sample Code

```python
class SoundCommands(CommandGroupExtension):
    ...

    async def post_init(self, bot: discord.ext.commands.Bot) -> None:
        bot.add_listener(self.on_voice_state_update, 'on_voice_state_update')

    def on_voice_state_update(
        self,
        member: discord.Member,
        before: discord.VoiceState,
        after: discord.VoiceState,
    ) -> None:
        # check if joined channel
        if not isinstance(after.channel, discord.VoiceChannel):
            return
        channel = voice_channel(interaction.user)
        sound = self.join_sounds.get(member_id=member.id)

        try:
            await play_sound(self.table.filepath(sound.filename), channel)
        except Exception as e:
            logger.exception(f'caught exception when playing sound: {e}')
```


### Additional Code or Information

_No response_

Discussion:

Patch:
diff --git a/setup.cfg b/setup.cfg
index 370272a..5677875 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
 [metadata]
 name = threepseat
-version = 2.1.5
+version = 2.1.6
 description = 3pseatBot: a Discord Bot
 long_description = file: README.md
 long_description_content_type = text/markdown
diff --git a/threepseat/__init__.py b/threepseat/__init__.py
index 3f3e5bc..51351bc 100644
--- a/threepseat/__init__.py
+++ b/threepseat/__init__.py
@@ -1,3 +1,3 @@
 from __future__ import annotations
 
-__version__ = '2.1.5'
+__version__ = '2.1.6'
diff --git a/threepseat/ext/sounds/commands.py b/threepseat/ext/sounds/commands.py
index dda55cf..185b76f 100644
--- a/threepseat/ext/sounds/commands.py
+++ b/threepseat/ext/sounds/commands.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 
 import datetime
 import logging
+import time
 
 import discord
 from discord import app_commands
@@ -9,6 +10,8 @@ from discord import app_commands
 from threepseat.commands.commands import admin_or_owner
 from threepseat.commands.commands import log_interaction
 from threepseat.ext.extension import CommandGroupExtension
+from threepseat.ext.sounds.data import MemberSound
+from threepseat.ext.sounds.data import MemberSoundTable
 from threepseat.ext.sounds.data import SoundsTable
 from threepseat.utils import leave_on_empty
 from threepseat.utils import play_sound
@@ -28,6 +31,7 @@ class SoundCommands(CommandGroupExtension):
             data_path (str): directory where sound files are stored.
         """
         self.table = SoundsTable(db_path, data_path)
+        self.join_table = MemberSoundTable(db_path)
 
         super().__init__(
             name='sounds',
@@ -40,6 +44,46 @@ class SoundCommands(CommandGroupExtension):
         self._vc_leaver_task = leave_on_empty(bot, 60)
         self._vc_leaver_task.start()
 
+        bot.add_listener(self.on_voice_state_update, 'on_voice_state_update')
+
+    async def on_voice_state_update(
+        self,
+        member: discord.Member,
+        before: discord.VoiceState,
+        after: discord.VoiceState,
+    ) -> None:
+        """Play sounds on member voice channel join."""
+        assert member.guild is not None
+        if (
+            not isinstance(after.channel, discord.VoiceChannel)
+            or before.channel == after.channel
+        ):
+            return
+
+        member_sound = self.join_table.get(
+            member_id=member.id,
+            guild_id=member.guild.id,
+        )
+        if member_sound is None:
+            return
+
+        sound = self.table.get(
+            name=member_sound.name,
+            guild_id=member.guild.id,
+        )
+        if sound is None:
+            return
+
+        try:
+            await play_sound(
+                self.table.filepath(sound.filename),
+                after.channel,
+            )
+        except Exception as e:
+            logger.exception(
+                f'caught exception when playing sound on member join: {e}',
+            )
+
     @app_commands.command(name='add', description='Add a sound')
     @app_commands.describe(name='Name of sound (max 12 characters)')
     @app_commands.describe(link='Link to YouTube clip (max 30 seconds)')
@@ -157,6 +201,40 @@ class SoundCommands(CommandGroupExtension):
         else:
             await interaction.followup.send('Played!')
 
+    @app_commands.command(
+        name='register',
+        description='Register your entry sound',
+    )
+    @app_commands.describe(name='Name of sound to play when joining a channel')
+    @app_commands.autocomplete(name=autocomplete)
+    @app_commands.check(log_interaction)
+    async def register(
+        self,
+        interaction: discord.Interaction,
+        name: str,
+    ) -> None:
+        """Register your sound."""
+        assert interaction.guild is not None
+
+        sound = self.table.get(name, guild_id=interaction.guild.id)
+        if sound is None:
+            await interaction.response.send_message(
+                f'A sound named *{name}* does not exist.',
+                ephemeral=True,
+            )
+            return
+
+        member_sound = MemberSound(
+            member_id=interaction.user.id,
+            guild_id=interaction.guild.id,
+            name=name,
+            updated_time=time.time(),
+        )
+        self.join_table.update(member_sound)
+        await interaction.response.send_message(
+            f'Updated your voice channel entry sound to *{name}*.',
+        )
+
     @app_commands.command(
         name='remove',
         description='[Admin Only] Remove a sound',
diff --git a/threepseat/ext/sounds/data.py b/threepseat/ext/sounds/data.py
index 239bece..42250db 100644
--- a/threepseat/ext/sounds/data.py
+++ b/threepseat/ext/sounds/data.py
@@ -120,6 +120,48 @@ class SoundsTable(SQLTableInterface[Sound]):
             )
 
 
+class MemberSound(NamedTuple):
+    """Sound to play when a member joins a voice channel."""
+
+    member_id: int
+    guild_id: int
+    name: str
+    updated_time: float
+
+
+class MemberSoundTable(SQLTableInterface[MemberSound]):
+    """Sounds to play when members join voice channels."""
+
+    def __init__(self, db_path: str) -> None:
+        """Init MemberSoundTable.
+
+        Args:
+            db_path (str): path to sqlite database.
+        """
+        super().__init__(
+            MemberSound,
+            'member_sounds',
+            db_path,
+            primary_keys=('member_id', 'guild_id'),
+        )
+
+    def _all(self, guild_id: int) -> list[MemberSound]:  # type: ignore
+        """Get all member sounds in guild."""
+        return super()._all(guild_id=guild_id)
+
+    def _get(  # type: ignore
+        self,
+        member_id: int,
+        guild_id: int,
+    ) -> MemberSound | None:
+        """Get MemberSound for member."""
+        return super()._get(member_id=member_id, guild_id=guild_id)
+
+    def remove(self, member_id: int, guild_id: int) -> int:  # type: ignore
+        """Remove a MemberSound from the table."""
+        return super().remove(member_id=member_id, guild_id=guild_id)
+
+
 def download(link: str, filepath: str) -> None:
     """Download sound from YouTube.
 


Test patch:
diff --git a/tests/ext/sounds/commands_test.py b/tests/ext/sounds/commands_test.py
index 6cf99cb..025dc07 100644
--- a/tests/ext/sounds/commands_test.py
+++ b/tests/ext/sounds/commands_test.py
@@ -12,9 +12,11 @@ from discord import app_commands
 from testing.mock import MockGuild
 from testing.mock import MockInteraction
 from testing.mock import MockMember
+from testing.mock import MockVoiceChannel
 from testing.utils import extract
 from threepseat.bot import Bot
 from threepseat.ext.sounds.commands import SoundCommands
+from threepseat.ext.sounds.data import MemberSound
 
 
 @pytest.fixture
@@ -494,3 +496,133 @@ async def test_on_error(
     caplog.set_level(logging.ERROR)
     await sounds.on_error(interaction, app_commands.AppCommandError('test1'))
     assert any(['test1' in record.message for record in caplog.records])
+
+
+@pytest.mark.asyncio
+async def test_post_init(
+    sound_fixtures: tuple[Bot, SoundCommands],
+) -> None:
+    mockbot, sounds = sound_fixtures
+
+    with mock.patch.object(mockbot, 'add_listener'):
+        await sounds.post_init(mockbot)
+    sounds._vc_leaver_task.cancel()
+
+
+@pytest.mark.asyncio
+async def test_voice_state_update(
+    sound_fixtures: tuple[Bot, SoundCommands],
+    caplog,
+) -> None:
+    mockbot, sounds = sound_fixtures
+
+    guild = MockGuild('guild', 1)
+    member = MockMember('member', 2, guild)
+
+    before = mock.MagicMock()
+    after = mock.MagicMock()
+    before.channel = None
+    after.channel = None
+
+    with mock.patch('threepseat.ext.sounds.commands.play_sound') as mock_play:
+        # skip: before and after are the same
+        await sounds.on_voice_state_update(member, before, after)
+        assert mock_play.await_count == 0
+
+        # skip: after channel is not voice channel
+        before.channel = MockVoiceChannel()
+        await sounds.on_voice_state_update(member, before, after)
+        assert mock_play.await_count == 0
+
+        # skip: member has not registered sound
+        after.channel = before.channel
+        before.channel = None
+        await sounds.on_voice_state_update(member, before, after)
+        assert mock_play.await_count == 0
+
+        # skip: sound is not in database
+        sounds.join_table.update(
+            MemberSound(member_id=2, guild_id=1, name='test', updated_time=0),
+        )
+        await sounds.on_voice_state_update(member, before, after)
+        assert mock_play.await_count == 0
+
+        # succeed
+        sounds.table.add(
+            name='test',
+            description='test',
+            link='https://youtube.com',
+            author_id=2,
+            guild_id=1,
+        )
+        await sounds.on_voice_state_update(member, before, after)
+        assert mock_play.await_count == 1
+
+    with mock.patch(
+        'threepseat.ext.sounds.commands.play_sound',
+        side_effect=Exception(),
+    ):
+        # exception should be captured and logged
+        await sounds.on_voice_state_update(member, before, after)
+
+    caplog.set_level(logging.ERROR)
+    assert any(['exception' in record.message for record in caplog.records])
+
+
+@pytest.mark.asyncio
+async def test_register_command_sound_missing(
+    sound_fixtures: tuple[Bot, SoundCommands],
+) -> None:
+    mockbot, sounds = sound_fixtures
+    register_ = extract(sounds.register)
+
+    interaction = MockInteraction(
+        sounds.register,
+        user='calling-user',
+        channel='mychannel',
+        guild='myguild',
+        client=mockbot,
+    )
+
+    await register_(sounds, interaction, name='mysound')
+
+    assert interaction.responded
+    assert (
+        interaction.response_message is not None
+        and 'does not exist' in interaction.response_message
+    )
+
+
+@pytest.mark.asyncio
+async def test_register_command(
+    sound_fixtures: tuple[Bot, SoundCommands],
+) -> None:
+    mockbot, sounds = sound_fixtures
+    register_ = extract(sounds.register)
+
+    interaction = MockInteraction(
+        sounds.register,
+        user='calling-user',
+        channel='mychannel',
+        guild='myguild',
+        client=mockbot,
+    )
+    assert interaction.guild is not None
+    assert interaction.user is not None
+
+    sounds.table.add(
+        name='mysound',
+        description='test',
+        link='https://youtube.com',
+        author_id=interaction.user.id,
+        guild_id=interaction.guild.id,
+    )
+    assert len(sounds.join_table.all(interaction.guild.id)) == 0
+    await register_(sounds, interaction, name='mysound')
+    assert len(sounds.join_table.all(interaction.guild.id)) == 1
+
+    assert interaction.response
+    assert (
+        interaction.response_message is not None
+        and 'Updated your voice channel entry' in interaction.response_message
+    )
diff --git a/tests/ext/sounds/data_test.py b/tests/ext/sounds/data_test.py
index 799caed..91da5ca 100644
--- a/tests/ext/sounds/data_test.py
+++ b/tests/ext/sounds/data_test.py
@@ -8,6 +8,8 @@ from unittest import mock
 import pytest
 
 from threepseat.ext.sounds.data import download
+from threepseat.ext.sounds.data import MemberSound
+from threepseat.ext.sounds.data import MemberSoundTable
 from threepseat.ext.sounds.data import Sound
 from threepseat.ext.sounds.data import SoundsTable
 
@@ -246,3 +248,23 @@ def test_youtube_download_errors(tmp_path: pathlib.Path) -> None:
         ):
             with pytest.raises(ValueError, match='downloading'):
                 download(link, filepath)
+
+
+def test_member_sounds_table(tmp_path: pathlib.Path) -> None:
+    db_path = os.path.join(tmp_path, 'member-sounds.db')
+
+    member_sounds = MemberSoundTable(db_path)
+    sound = MemberSound(member_id=1, guild_id=2, name='test', updated_time=0)
+
+    member_sounds.update(sound)
+    assert len(member_sounds.all(sound.guild_id)) == 1
+    assert len(member_sounds.all(guild_id=42)) == 0
+
+    assert (
+        member_sounds.get(member_id=sound.member_id, guild_id=sound.guild_id)
+        == sound
+    )
+    assert member_sounds.get(member_id=0, guild_id=0) is None
+
+    member_sounds.remove(member_id=sound.member_id, guild_id=sound.guild_id)
+    assert len(member_sounds.all(sound.guild_id)) == 0