diff --git a/.github/workflows/main-deploy.yml b/.github/workflows/main-deploy.yml index 069217a..e311db8 100644 --- a/.github/workflows/main-deploy.yml +++ b/.github/workflows/main-deploy.yml @@ -39,5 +39,4 @@ jobs: run: | gcloud compute instances update-container meowbot-instance-vm \ --container-image gcr.io/meowbot-448505/meow-bot:latest \ - --zone us-central1-c \ - --container-arg="--log-driver=gcplogs" + --zone us-central1-c diff --git a/README.md b/README.md index 1061380..2a0ee53 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ To install and actually run meowBot on your local machine, perform the following ``` BOT_TOKEN = "your_bot_token" CLIENT_ID = "your_client_id" - +``` 5. To run the bot, execute ```poetry run python -m meowbot.bot.meowbot``` in your terminal. diff --git a/meowbot/application/services/voice_service.py b/meowbot/application/services/voice_service.py new file mode 100644 index 0000000..df20b42 --- /dev/null +++ b/meowbot/application/services/voice_service.py @@ -0,0 +1,176 @@ +import discord +from discord.ext import tasks +from meowbot.application.models.ytdl_source import YTDLSource +from meowbot.utils.logger import logging, setup_logger +from meowbot.application.models.music_queue import MusicQueue +from itertools import islice + + +class VoiceService: + def __init__(self, bot): + self.bot = bot + self.logger = setup_logger(name="service.voice", level=logging.INFO) + self.check_leave.start() + self.music_queue = MusicQueue() + + def is_user_in_voice_channel(self, ctx): + return ctx.message.author.voice is not None + + def is_bot_in_voice_channel(self, ctx): + return ctx.voice_client is not None + + async def send_message(self, ctx, content): + await ctx.send(content) + self.logger.info(content) + + @tasks.loop(minutes=8) + async def check_leave(self): + voice_lists = self.bot.voice_clients + for vl in voice_lists: + if vl.is_connected() and not vl.is_playing(): + await vl.disconnect() + self.logger.info(f"meowBot left {vl.channel}.") + + async def join_voice_channel(self, ctx): + if not self.is_user_in_voice_channel(ctx): + await self.send_message(ctx, "You are not in a voice channel!") + return + + channel = ctx.message.author.voice.channel + voice_client = ctx.voice_client + + if voice_client: + await voice_client.move_to(channel) + await self.send_message(ctx, f"Bot moved to {channel}.") + else: + self.check_leave.restart() + await channel.connect() + await self.send_message(ctx, f"Joined {channel}! ") + + async def leave_voice_channel(self, ctx): + if not self.is_bot_in_voice_channel(ctx): + await self.send_message(ctx, "I'm not in a voice channel!") + return + + channel = ctx.voice_client.channel + self.music_queue.clear() + await ctx.voice_client.disconnect() + await self.send_message(ctx, f"Bot left {channel}.") + + async def pause_song(self, ctx): + if ctx.voice_client.is_playing(): + ctx.voice_client.pause() + await self.send_message(ctx, "Song has been paused!") + + async def resume_song(self, ctx): + if ctx.voice_client.is_paused(): + ctx.voice_client.resume() + await self.send_message(ctx, "Song has resumed playing!") + + async def stop_song(self, ctx): + ctx.voice_client.stop() + await self.send_message(ctx, "Song has stopped playing!") + + async def skip_song(self, ctx): + if ctx.voice_client.is_playing(): + ctx.voice_client.stop() + await self.send_message(ctx, "Song has been skipped!") + + async def get_queue(self, ctx): + if self.music_queue.is_empty(): + await self.send_message(ctx, "Queue is empty!") + return + + embed = discord.Embed( + title="**Current Queue**", + description="List of songs in queue:", + color=discord.Colour.teal(), + ) + length = len(self.music_queue.queue) + + for i, song in enumerate(islice(self.music_queue.queue, 10)): + embed.add_field(name=f"Song {i+1}: ", value=song.title, inline=True) + + if length > 10: + embed.set_footer(text=f"...and {length - 10} more songs.") + + await ctx.send(embed=embed) + + async def clear_queue(self, ctx): + self.music_queue.clear() + await self.send_message(ctx, "Queue has been cleared!") + + async def play_song(self, ctx, url): + if not self.is_user_in_voice_channel(ctx): + await self.send_message(ctx, "You are not in a voice channel!") + return + + channel = ctx.message.author.voice.channel + + if not self.is_bot_in_voice_channel(ctx): + await channel.connect() + + async with ctx.typing(): + try: + player = await YTDLSource.from_url(url, loop=self.bot.loop, stream=True) + if not player: + self.send_message(ctx, "Failed to retrieve audio source.") + return + except Exception as e: + self.logger.error(f"Error when trying to play {url}: {e}") + return + + embed = self.add_song_to_queue(ctx, player, url) + await ctx.channel.send(embed=embed) + + def add_song_to_queue(self, ctx, player, url): + position = self.music_queue.add_to_queue(player) + + if position == 1 and not ctx.voice_client.is_playing(): + self.start_playback(ctx.voice_client, player) + embed_title = "**Current Song Playing!**" + embed_desc = f"Playing: {player.title}" + else: + embed_title = "**Song Added to Queue!**" + embed_desc = f"Added: {player.title}" + + embed = discord.Embed( + title=embed_title, description=embed_desc, color=discord.Colour.teal() + ) + embed.add_field(name="```User Input```", value=f"Input: {url}", inline=False) + if position > 1: + embed.add_field( + name="```Position in Queue```", value=f"Position: {position}", inline=True + ) + + return embed + + def start_playback(self, voice_client, player): + if not player or not isinstance(player, discord.AudioSource): + self.logger.error("Invalid audio source.") + return + + def after_playback(error): + if error: + self.logger.error(f"Error while playing: {error}") + else: + self.logger.info(f"Finished playing: {player.title}") + self.play_next(voice_client) + + voice_client.play(player, after=after_playback) + voice_client.source.volume = 0.10 + self.logger.info(f"Now playing: {player.title}") + + def play_next(self, voice_client): + next_song = self.music_queue.remove_from_queue() + + if not next_song: + self.logger.info("Queue is empty.") + if voice_client.is_playing(): + voice_client.stop() + self.logger.info("Stopped playing.") + self.check_leave.restart() + return + + self.start_playback(voice_client, next_song) + self.logger.info(f"Now playing: {next_song.title}") diff --git a/meowbot/bot/cogs/voice.py b/meowbot/bot/cogs/voice.py index e2ea144..562be80 100644 --- a/meowbot/bot/cogs/voice.py +++ b/meowbot/bot/cogs/voice.py @@ -1,182 +1,52 @@ -import discord -from discord.ext import commands, tasks -from meowbot.application.models.ytdl_source import YTDLSource +from discord.ext import commands from meowbot.utils.logger import logging, setup_logger -from meowbot.application.models.music_queue import MusicQueue +from meowbot.application.services.voice_service import VoiceService class Voice(commands.Cog): def __init__(self, bot): self.bot = bot self.logger = setup_logger(name="cog.voice", level=logging.INFO) - self.check_leave.start() - self.music_queue = MusicQueue() + self.voice_service = VoiceService(bot) @commands.hybrid_command(name="join", description="Joins a voice channel") async def join(self, ctx): - try: - if ctx.message.author.voice is None: - await ctx.send("You are not in a voice channel!") - self.logger.error("User is not in a voice channel.") - return - channel = ctx.message.author.voice.channel - if ctx.voice_client is not None: - await ctx.voice_client.move_to(channel) - self.logger.info(f"Bot moved to {channel}.") - - else: - self.check_leave.restart() - await channel.connect() - self.logger.info(f"Bot connected to {channel}.") - - except AttributeError: - await ctx.send("Join a voice channel first! -.-") - self.logger.error("User did not join a voice channel before attempting to join.") - - await ctx.send(f"Joined {channel}! ") - self.logger.info(f"Bot joined {channel}.") + await self.voice_service.join_voice_channel(ctx) @commands.hybrid_command(name="leave", description="Leaves a voice channel") async def leave(self, ctx): - if ctx.voice_client: - channel = ctx.voice_client.channel - self.music_queue.clear() - await ctx.voice_client.disconnect() - self.logger.info(f"Bot left {channel}.") - else: - await ctx.send("I'm not in a voice channel!") - self.logger.error("Bot is not in a voice channel.") - - @tasks.loop(minutes=8) - async def check_leave(self): - voice_lists = self.bot.voice_clients - for x in voice_lists: - if x.is_connected() and not x.is_playing(): - await x.disconnect() - self.logger.info(f"meowBot left {x.channel}.") + await self.voice_service.leave_voice_channel(ctx) @commands.hybrid_command(name="pause", description="Pauses the current song") async def pause(self, ctx): - if ctx.voice_client.is_playing(): - ctx.voice_client.pause() - await ctx.send("Song has been paused!") - self.logger.info("Song has been paused.") + await self.voice_service.pause_song(ctx) @commands.hybrid_command(name="resume", description="Resumes the current song") async def resume(self, ctx): - if ctx.voice_client.is_paused(): - ctx.voice_client.resume() - await ctx.send("Song has resumed playing!") - self.logger.info("Song has resumed playing.") + await self.voice_service.resume_song(ctx) @commands.hybrid_command(name="stop", description="Stops the current song") async def stop(self, ctx): - ctx.voice_client.stop() - await ctx.send("Song has stopped playing!") - self.logger.info("Song has stopped playing.") + await self.voice_service.stop_song(ctx) @commands.hybrid_command(name="play", description="Plays a song from youtube given a URL") async def play(self, ctx, *, url): - if ctx.message.author.voice is None: - await ctx.send("You are not in a voice channel!") - self.logger.error("User is not in a voice channel.") - return - - channel = ctx.message.author.voice.channel - - if not ctx.voice_client: - await channel.connect() - - async with ctx.typing(): - try: - player = await YTDLSource.from_url(url, loop=self.bot.loop, stream=True) - if not player: - await ctx.send("Failed to retrieve audio source.") - self.logger.error(f"Failed to retrieve audio source for {url}.") - return - except Exception as e: - self.logger.error(f"Error when trying to play {url}: {e}") - return - - position = self.music_queue.add_to_queue(player) - if position == 1 and not ctx.voice_client.is_playing(): - self.start_playback(ctx.voice_client, player) - else: - self.logger.info(f"Added {player.title} to queue at position {position}.") - - embed = discord.Embed( - title="**Current Song Playing!**", - description=f"Playing: {player.title}", - color=discord.Colour.teal(), - ) - embed.add_field(name="```User Input```", value=f"Input: {url}", inline=False) - await ctx.channel.send(embed=embed) - self.logger.info(f"Playing {player.title} in {channel}.") - - def start_playback(self, voice_client, player): - if not player or not isinstance(player, discord.AudioSource): - self.logger.error("Invalid audio source.") - return - - self.logger.info(f"Type of player: {type(player)}") - - def after_playback(error): - self.logger.info(f"After playback triggered. Error: {error} (Type: {type(error)})") - - if error: - self.logger.error(f"Error while playing: {error}") - else: - self.logger.info(f"Finished playing: {player.title}") - self.play_next(voice_client) - - voice_client.play(player, after=after_playback) - voice_client.source.volume = 0.10 - self.logger.info(f"Now playing: {player.title}") - - def play_next(self, voice_client): - if not self.music_queue.is_empty(): - next_song = self.music_queue.remove_from_queue() - if next_song: - self.start_playback(voice_client, next_song) - self.logger.info(f"Now playing: {next_song.title}") - else: - self.logger.error("Failed to retrieve next song.") - else: - self.logger.info("Queue is empty.") - if voice_client.is_playing(): - voice_client.stop() - self.logger.info("Stopped playing.") - self.check_leave.restart() + await self.voice_service.play_song(ctx, url) @commands.hybrid_command(name="queue", description="Displays the current queue") async def queue(self, ctx): - queue = self.music_queue.queue - if not queue: - await ctx.send("Queue is empty!") - return - - embed = discord.Embed( - title="**Current Queue**", - description="List of songs in queue", - color=discord.Colour.teal(), - ) - for i, song in enumerate(queue): - embed.add_field(name=f"Song {i+1}", value=song.title, inline=False) - await ctx.send(embed=embed) - self.logger.info("Queue displayed.") + await self.voice_service.get_queue(ctx) @commands.hybrid_command(name="clear", description="Clears the current queue") async def clear(self, ctx): - self.music_queue.clear() - await ctx.send("Queue has been cleared!") - self.logger.info("Queue has been cleared.") + await self.voice_service.clear_queue(ctx) @commands.hybrid_command(name="skip", description="Skips the current song") async def skip(self, ctx): - if ctx.voice_client.is_playing(): - ctx.voice_client.stop() - await ctx.send("Song has been skipped!") - self.logger.info("Song has been skipped.") + await self.voice_service.skip_song(ctx) + + def cog_unload(self): + self.logger.info("Voice cog unloaded.") async def setup(bot): diff --git a/tests/unit/application/models/test_music_queue.py b/tests/unit/application/models/test_music_queue.py new file mode 100644 index 0000000..39de988 --- /dev/null +++ b/tests/unit/application/models/test_music_queue.py @@ -0,0 +1,63 @@ +from collections import deque +from meowbot.application.models.music_queue import MusicQueue + + +def test_music_queue_initialization(): + """Test the initialization of MusicQueue.""" + music_queue = MusicQueue() + + assert music_queue is not None + assert music_queue.queue == deque() + assert music_queue.current_song is None + + +def test_add_to_queue(): + """Test the add_to_queue method.""" + music_queue = MusicQueue() + song = "https://www.youtube.com/watch?v=6n3pFFPSlW4" + result = music_queue.add_to_queue(song) + + assert result is not None + assert result == 1 + assert music_queue.queue == deque([song]) + + +def test_remove_from_queue(): + """Test the remove_from_queue method.""" + music_queue = MusicQueue() + song = "https://www.youtube.com/watch?v=6n3pFFPSlW4" + music_queue.add_to_queue(song) + result = music_queue.remove_from_queue() + + assert result is not None + assert result == song + assert music_queue.queue == deque() + + +def test_remove_from_queue_empty_queue(): + """Test remove_from_queue when queue is empty.""" + music_queue = MusicQueue() + result = music_queue.remove_from_queue() + + assert result is None + assert music_queue.queue == deque() + + +def test_is_empty(): + """Test the is_empty method.""" + music_queue = MusicQueue() + result = music_queue.is_empty() + + assert result is not None + assert result is True + + +def test_clear(): + """Test the clear method.""" + music_queue = MusicQueue() + song = "https://www.youtube.com/watch?v=6n3pFFPSlW4" + music_queue.add_to_queue(song) + music_queue.clear() + + assert music_queue.queue == deque() + assert music_queue.current_song is None diff --git a/tests/unit/application/services/test_event_handler_service.py b/tests/unit/application/services/test_event_handler_service.py new file mode 100644 index 0000000..6bbd9db --- /dev/null +++ b/tests/unit/application/services/test_event_handler_service.py @@ -0,0 +1,28 @@ +import pytest +from unittest.mock import Mock, AsyncMock +from meowbot.application.services.event_handler_service import EventHandlerService + + +def test_event_handler_service_initialization(): + """Test the initialization of EventHandlerService.""" + event_handler = EventHandlerService() + + assert event_handler is not None + assert event_handler.logger is not None + assert event_handler.logger.name == "service.event.handler" + assert event_handler.logger.level == 20 + + +@pytest.mark.asyncio +async def test_serve_on_guild_join_embed(): + """Test the serve_on_guild_join_embed method.""" + event_handler = EventHandlerService() + guild = Mock() + + text_channel = Mock() + text_channel.send = AsyncMock() + text_channel.permissions_for.return_value.send_messages = True + guild.text_channels = [text_channel] + + await event_handler.serve_on_guild_join_event(guild) + assert guild.text_channels[0].send.called is True diff --git a/tests/unit/application/services/test_helper_service.py b/tests/unit/application/services/test_helper_service.py new file mode 100644 index 0000000..876c1f4 --- /dev/null +++ b/tests/unit/application/services/test_helper_service.py @@ -0,0 +1,68 @@ +import discord +from meowbot.application.services.helper_service import HelperService + + +def test_helper_service_initialization(): + """Test the initialization of HelperService.""" + helper = HelperService() + + assert helper is not None + assert helper.logger is not None + assert helper.logger.name == "service.helper" + assert helper.logger.level == 20 + + +def test_serve_help_command_embed(): + """Test the serve_help_command_embed method.""" + helper = HelperService() + embed = helper.serve_help_command_embed() + + assert embed is not None + assert embed.title == "**meowBot >.< General Commands**" + assert embed.description == "**Some useful commands to access meowBot:**" + assert embed.color == discord.Colour.red() + assert embed.thumbnail.url == helper.logo_url + assert len(embed.fields) == 5 + + +def test_serve_music_command_embed(): + """Test the serve_music_command_embed method.""" + helper = HelperService() + embed = helper.serve_music_command_embed() + + assert embed is not None + assert embed.title == "**meowBot >.< Music Commands**" + assert embed.description == "**Some useful commands to access meowBot's music functionality:**" + assert embed.color == discord.Colour.red() + assert embed.thumbnail.url == helper.logo_url + assert len(embed.fields) == 6 + + +def test_serve_misc_command_embed(): + """Test the serve_misc_command_embed method.""" + helper = HelperService() + embed = helper.serve_misc_command_embed() + + assert embed is not None + assert embed.title == "**meowBot >.< Misc Commands**" + assert embed.description == "**Some fun and miscellaneous functions that meowBot offers:**" + assert embed.color == discord.Colour.red() + assert embed.thumbnail.url == helper.logo_url + assert len(embed.fields) == 4 + + +def test_serve_channels_command_embed(): + """Test the serve_channels_command_embed method.""" + helper = HelperService() + embed = helper.serve_channels_command_embed() + + assert embed is not None + assert embed.title == "**meowBot >.< Channel Commands**" + assert ( + embed.description == "**NOTE**: You need the *Manage Channels* " + "permission to use these commands.\n" + "**Some useful commands to create and delete channels with meowBot:**" + ) + assert embed.color == discord.Colour.red() + assert embed.thumbnail.url == helper.logo_url + assert len(embed.fields) == 5 diff --git a/tests/unit/application/services/test_interactions_service.py b/tests/unit/application/services/test_interactions_service.py new file mode 100644 index 0000000..65f6270 --- /dev/null +++ b/tests/unit/application/services/test_interactions_service.py @@ -0,0 +1,139 @@ +import discord +import pytest +from unittest.mock import patch, Mock, AsyncMock +import meowbot.application.services.interactions_service as interactions_service + + +@pytest.fixture +def mock_env(): + """Fixture to mock environment variables.""" + with patch("os.environ", {"CLIENT_ID": "fake_id"}): + yield + + +def test_interactions_service_initialization(): + """Test the initialization of InteractionsService.""" + interactions = interactions_service.InteractionsService() + + assert interactions is not None + assert interactions.logger is not None + assert interactions.logger.name == "service.interactions" + assert interactions.logger.level == 20 + + +def test_serve_intro_command(): + """Test the serve_intro_command method.""" + interactions = interactions_service.InteractionsService() + message = interactions.serve_intro_command() + + assert message is not None + assert message == "Well, hai! :3 I'm jaimegarjr's cat-based discord bot!" + + +def test_serve_github_command(): + """Test the serve_github_command method.""" + interactions = interactions_service.InteractionsService() + embed = interactions.serve_github_command() + github_link = "https://github.com/JJgar2725/meowBot" + + assert embed is not None + assert embed.title == "**GitHub Repository**" + assert embed.description == f"meowBot is [open-source]({github_link})!" + assert embed.color == discord.Colour.light_grey() + assert len(embed.fields) == 1 + assert embed.thumbnail.url == interactions.logo_url + + +def test_serve_invite_command(mock_env): + """Test the serve_invite_command method.""" + interactions = interactions_service.InteractionsService() + embed = interactions.serve_invite_command() + link = ( + "https://discord.com/oauth2/authorize?client_id=fake_id&" + "permissions=271969366&scope=bot+applications.commands" + ) + + assert embed is not None + assert embed.title == "Invite meowBot :3" + assert embed.description == f"Click [here]({link}) to invite me to another server!" + assert embed.color == discord.Colour.light_gray() + assert embed.thumbnail.url == interactions.logo_url + assert len(embed.fields) == 0 + + +def test_serve_invite_command_missing_env_var(): + """Test serve_invite_command when CLIENT_ID is missing.""" + interactions = interactions_service.InteractionsService() + interactions.logger = Mock() + + with patch("os.environ", {}): + result = interactions.serve_invite_command() + + assert result is None + interactions.logger.error.assert_called_once_with("CLIENT_ID environment variable is missing.") + + +def test_serve_profile_command(): + """Test the serve_profile_command method.""" + interactions = interactions_service.InteractionsService() + user = Mock() + user.name = "test_user" + user.id = 1234567890 + + embed = interactions.serve_profile_command(user) + + assert embed is not None + assert embed.title == "**User Profile**" + assert embed.description == f"**Detailed profile for {user}!**" + assert embed.color == discord.Colour.light_grey() + assert len(embed.fields) == 5 + assert embed.fields[0].name == "Username " + assert embed.fields[0].value == "test_user" + assert embed.fields[0].inline is True + + +def test_serve_users_command(): + """Test the serve_users_command method.""" + interactions = interactions_service.InteractionsService() + guild = Mock() + guild.member_count = 10 + + embed = interactions.serve_users_command(guild) + + assert embed is not None + assert embed.title == "**Current User Count on Guild!**" + assert embed.description == "Number of Members: 10" + assert embed.color == discord.Colour.green() + assert len(embed.fields) == 0 + + +@pytest.mark.asyncio +async def test_serve_quote_command(): + """Test the serve_quote_command method.""" + interactions = interactions_service.InteractionsService() + + mock_response = {"text": "Code is like humor.", "author": "Unknown"} + interactions.prog_quote_client.get = AsyncMock(return_value=mock_response) + + quote = await interactions.serve_quote_command() + + expected_quote = "Code is like humor. - Unknown" + assert quote == expected_quote + + +@pytest.mark.asyncio +async def test_serve_dad_joke_command(): + """Test the serve_dad_joke_command method.""" + interactions = interactions_service.InteractionsService() + + mock_response = {"joke": "Why do programmers prefer dark mode?"} + interactions.dad_joke_client.get = AsyncMock(return_value=mock_response) + + embed = await interactions.serve_dad_joke_command() + + assert embed is not None + assert embed.title == "**Random Dad Joke**" + assert embed.description == "**Fetched from icanhazdadjoke.com!**" + assert embed.color == discord.Colour.light_grey() + assert embed.fields[0].name == "```Joke```" + assert embed.fields[0].value == "Why do programmers prefer dark mode?"