diff --git a/zyrarbot/bot/__init__.py b/zyrarbot/bot/__init__.py new file mode 100644 index 0000000..9c50dd5 --- /dev/null +++ b/zyrarbot/bot/__init__.py @@ -0,0 +1 @@ +"""Warstwa Discorda: klient bota, cogi i komendy ukośnikowe.""" diff --git a/zyrarbot/bot/client.py b/zyrarbot/bot/client.py new file mode 100644 index 0000000..1f67144 --- /dev/null +++ b/zyrarbot/bot/client.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import logging + +import discord +from discord.ext import commands + +from zyrarbot.audio.track_factory import TrackFactory +from zyrarbot.bot.cogs.music_cog import MusicCog +from zyrarbot.music.music_player import MusicPlayer +from zyrarbot.settings import Settings +from zyrarbot.storage.uploads_repository import UploadsRepository + +logger = logging.getLogger(__name__) + + +class ZyrarBot(commands.Bot): + """Klient Discorda ŻyrarBOT-a rejestrujący cog muzyczny i synchronizujący komendy. + + ``player`` przypisuje kompozycja aplikacji tuż po utworzeniu klienta - + odtwarzacz potrzebuje bota, a bot odtwarzacza, więc pętlę domykamy tutaj. + """ + + def __init__(self, settings: Settings, tracks: TrackFactory, uploads: UploadsRepository): + intents = discord.Intents.default() + intents.message_content = True + intents.voice_states = True + super().__init__(command_prefix="!", intents=intents) + + self.settings = settings + self.tracks = tracks + self.uploads = uploads + self.player: MusicPlayer | None = None + + async def setup_hook(self) -> None: + if self.player is None: + raise RuntimeError("MusicPlayer musi zostać przypisany przed startem bota") + + await self.add_cog( + MusicCog(self, self.player, self.tracks, self.uploads, self.settings) + ) + + if self.settings.guild_id: + guild = discord.Object(id=self.settings.guild_id) + self.tree.copy_global_to(guild=guild) + await self.tree.sync(guild=guild) + logger.info("Zsynchronizowano komendy z serwerem %s", self.settings.guild_id) + else: + await self.tree.sync() + logger.info("Zsynchronizowano komendy globalnie") + + async def on_ready(self) -> None: + if self.user: + logger.info("Zalogowano jako %s (%s)", self.user.name, self.user.id) diff --git a/zyrarbot/bot/cogs/__init__.py b/zyrarbot/bot/cogs/__init__.py new file mode 100644 index 0000000..1855458 --- /dev/null +++ b/zyrarbot/bot/cogs/__init__.py @@ -0,0 +1 @@ +"""Cogi rejestrowane w kliencie Discorda.""" diff --git a/zyrarbot/bot/cogs/music_cog.py b/zyrarbot/bot/cogs/music_cog.py new file mode 100644 index 0000000..dea5b14 --- /dev/null +++ b/zyrarbot/bot/cogs/music_cog.py @@ -0,0 +1,378 @@ +from __future__ import annotations + +import logging + +import discord +from discord import app_commands +from discord.ext import commands + +from zyrarbot.audio.track_factory import TrackFactory +from zyrarbot.music.loop_mode import LoopMode +from zyrarbot.music.music_player import MusicPlayer +from zyrarbot.settings import Settings +from zyrarbot.storage.uploads_repository import UploadsRepository + +logger = logging.getLogger(__name__) + +QUEUE_PREVIEW_LIMIT = 30 +HISTORY_PREVIEW_LIMIT = 15 +AUTOCOMPLETE_LIMIT = 25 + + +class MusicCog(commands.Cog, name="MusicBot"): + """Komendy ukośnikowe sterujące odtwarzaczem. Cała logika żyje w ``MusicPlayer``.""" + + def __init__( + self, + bot: commands.Bot, + player: MusicPlayer, + tracks: TrackFactory, + uploads: UploadsRepository, + settings: Settings, + ): + self.bot = bot + self.player = player + self._tracks = tracks + self._uploads = uploads + self._settings = settings + + # --- Zdarzenia ---------------------------------------------------------- + + @commands.Cog.listener() + async def on_voice_state_update( + self, + member: discord.Member, + before: discord.VoiceState, + after: discord.VoiceState, + ) -> None: + is_bot_leaving = ( + self.bot.user + and member.id == self.bot.user.id + and before.channel is not None + and after.channel is None + ) + if is_bot_leaving: + self.player.clear(before.channel.guild.id) + await self.player.trigger_state_change() + + # --- Pomocnicze --------------------------------------------------------- + + async def _ensure_voice(self, interaction: discord.Interaction) -> bool: + if not interaction.user.voice: + await interaction.response.send_message( + "Nie jesteś połączony z kanałem głosowym.", ephemeral=True + ) + return False + + voice_client = interaction.guild.voice_client + if voice_client is None: + await interaction.user.voice.channel.connect() + elif voice_client.channel != interaction.user.voice.channel: + await interaction.response.send_message( + "Jestem już połączony z innym kanałem głosowym.", ephemeral=True + ) + return False + return True + + async def _enqueue_and_reply(self, interaction: discord.Interaction, track, *, prefix: str = "") -> None: + result = await self.player.enqueue( + interaction.guild.id, track, channel=interaction.channel + ) + title = discord.utils.escape_markdown(track.title or "Nieznany tytuł") + action = "Dodano do kolejki" if result["status"] == "queued" else "Teraz odtwarzane" + await interaction.followup.send(f"{action}{prefix}: {title}") + + # --- Odtwarzanie -------------------------------------------------------- + + @app_commands.command(name="play", description="Odtwórz utwór z YouTube") + @app_commands.describe(query="Link YouTube lub zapytanie wyszukiwania") + async def play(self, interaction: discord.Interaction, query: str) -> None: + if not await self._ensure_voice(interaction): + return + await interaction.response.defer() + try: + track = await self._tracks.resolve(query, loop=self.bot.loop) + await self._enqueue_and_reply(interaction, track) + except Exception as error: # noqa: BLE001 - komunikat trafia do użytkownika + logger.error("Komenda /play nie powiodła się: %s", error) + await interaction.followup.send(f"Wystąpił błąd: {error}", ephemeral=True) + + async def _uploads_autocomplete( + self, interaction: discord.Interaction, current: str + ) -> list[app_commands.Choice[str]]: + names = self._uploads.search(current, limit=AUTOCOMPLETE_LIMIT) + return [app_commands.Choice(name=name, value=name) for name in names] + + @app_commands.command(name="playfile", description="Odtwórz plik przesłany na serwer") + @app_commands.describe(filename="Wybierz plik z listy przesłanych") + @app_commands.autocomplete(filename=_uploads_autocomplete) + async def playfile(self, interaction: discord.Interaction, filename: str) -> None: + if not await self._ensure_voice(interaction): + return + await interaction.response.defer() + + if not self._uploads.exists(filename): + await interaction.followup.send("Nie znaleziono pliku na serwerze.", ephemeral=True) + return + + try: + track = await self._tracks.from_file(self._uploads.resolve(filename)) + await self._enqueue_and_reply(interaction, track) + except Exception as error: # noqa: BLE001 + logger.error("Komenda /playfile nie powiodła się: %s", error) + await interaction.followup.send(f"Wystąpił błąd: {error}", ephemeral=True) + + # --- Kolejka i historia ------------------------------------------------- + + @app_commands.command(name="queue", description="Pokaż aktualną kolejkę") + async def show_queue(self, interaction: discord.Interaction) -> None: + state = self.player.state(interaction.guild.id) + if not state.queue: + await interaction.response.send_message("Kolejka jest pusta.", ephemeral=True) + return + + lines: list[str] = [] + if state.current and not 0 <= state.index < len(state.queue): + title = discord.utils.escape_markdown(state.current.title or "") + plays = self.player.plays_left(state.current) + lines.append( + f"▶️ **{title}** (obecnie odtwarzane - ostatnie odtworzenie) [🔁 {plays}]\n---" + ) + + for position, track in enumerate(state.queue): + is_current = position == state.index + prefix = "▶️ **" if is_current else f"{position + 1}. " + suffix = "** (obecnie odtwarzane)" if is_current else "" + title = discord.utils.escape_markdown(track.title or "") + lines.append(f"{prefix}{title}{suffix} [🔁 {self.player.plays_left(track)}]") + + preview = "\n".join(lines[:QUEUE_PREVIEW_LIMIT]) + if len(lines) > QUEUE_PREVIEW_LIMIT: + preview += f"\n... i {len(lines) - QUEUE_PREVIEW_LIMIT} więcej." + await interaction.response.send_message(f"Aktualna kolejka:\n{preview}") + + async def _history_autocomplete( + self, interaction: discord.Interaction, current: str + ) -> list[app_commands.Choice[str]]: + entries = self.player.history(interaction.guild.id) + matches = [entry for entry in entries if current.lower() in entry.title.lower()] + return [ + app_commands.Choice(name=entry.title[:100], value=entry.id) + for entry in matches[:AUTOCOMPLETE_LIMIT] + ] + + @app_commands.command(name="history", description="Pokaż historię ostatnio odtwarzanych utworów") + async def show_history(self, interaction: discord.Interaction) -> None: + entries = self.player.history(interaction.guild.id) + if not entries: + await interaction.response.send_message( + "Historia odtwarzania jest pusta.", ephemeral=True + ) + return + + listing = "\n".join( + f"{position + 1}. {discord.utils.escape_markdown(entry.title)}" + for position, entry in enumerate(entries[:HISTORY_PREVIEW_LIMIT]) + ) + await interaction.response.send_message( + f"📜 Historia odtwarzania (ostatnie {HISTORY_PREVIEW_LIMIT}):\n{listing}" + ) + + @app_commands.command(name="replay", description="Odtwórz ponownie utwór z historii") + @app_commands.describe(track_id="Wybierz utwór z historii odtwarzania") + @app_commands.autocomplete(track_id=_history_autocomplete) + async def replay(self, interaction: discord.Interaction, track_id: str) -> None: + if not await self._ensure_voice(interaction): + return + await interaction.response.defer() + + guild_id = interaction.guild.id + entries = self.player.history(guild_id) + entry = self.player.find_history_entry(guild_id, track_id) + if entry is None and track_id.isdigit(): + position = int(track_id) - 1 + if 0 <= position < len(entries): + entry = entries[position] + + if entry is None: + await interaction.followup.send("Nie znaleziono utworu w historii.", ephemeral=True) + return + + try: + if entry.is_local: + filepath = entry.replay_source or "" + if not self._uploads.exists(filepath): + await interaction.followup.send( + "Plik lokalny z historii już nie istnieje na serwerze.", ephemeral=True + ) + return + track = await self._tracks.from_file( + self._uploads.resolve(filepath), title=entry.title + ) + else: + track = await self._tracks.from_url(entry.url or "", loop=self.bot.loop) + await self._enqueue_and_reply(interaction, track, prefix=" z historii") + except Exception as error: # noqa: BLE001 + logger.error("Komenda /replay nie powiodła się: %s", error) + await interaction.followup.send(f"Wystąpił błąd: {error}", ephemeral=True) + + # --- Sterowanie --------------------------------------------------------- + + @app_commands.command(name="skip", description="Pomiń aktualny utwór") + async def skip(self, interaction: discord.Interaction) -> None: + guild_id = interaction.guild.id + state = self.player.state(guild_id) + if state.loop_mode.loops_track: + state.loop_mode = LoopMode.OFF + + if not self.player.is_active(guild_id): + await interaction.response.send_message("Nic nie jest odtwarzane.", ephemeral=True) + return + + interaction.guild.voice_client.stop() + await self.player.trigger_state_change() + await interaction.response.send_message("Pominięto aktualny utwór.") + + @app_commands.command(name="pause", description="Wstrzymaj odtwarzanie") + async def pause(self, interaction: discord.Interaction) -> None: + voice_client = interaction.guild.voice_client + if not voice_client or not voice_client.is_playing(): + await interaction.response.send_message("Nic nie jest odtwarzane.", ephemeral=True) + return + voice_client.pause() + await self.player.trigger_state_change() + await interaction.response.send_message("Odtwarzanie wstrzymane.") + + @app_commands.command(name="resume", description="Wznów odtwarzanie") + async def resume(self, interaction: discord.Interaction) -> None: + voice_client = interaction.guild.voice_client + if not voice_client or not voice_client.is_paused(): + await interaction.response.send_message( + "Odtwarzanie nie jest wstrzymane.", ephemeral=True + ) + return + voice_client.resume() + await self.player.trigger_state_change() + await interaction.response.send_message("Odtwarzanie wznowione.") + + @app_commands.command(name="stop", description="Zatrzymaj odtwarzanie i wyczyść kolejkę") + async def stop(self, interaction: discord.Interaction) -> None: + guild_id = interaction.guild.id + voice_client = interaction.guild.voice_client + if voice_client and voice_client.is_playing(): + voice_client.stop() + self.player.clear(guild_id) + await self.player.trigger_state_change() + await interaction.response.send_message( + "Zatrzymano odtwarzanie i wyczyszczono kolejkę." + ) + + @app_commands.command(name="volume", description="Zmień głośność odtwarzacza") + @app_commands.describe(volume="Poziom głośności (1-100)") + async def volume(self, interaction: discord.Interaction, volume: int) -> None: + if interaction.guild.voice_client is None: + await interaction.response.send_message( + "Nie połączono z kanałem głosowym.", ephemeral=True + ) + return + if not 0 < volume <= 100: + await interaction.response.send_message( + "Wprowadź wartość z przedziału od 1 do 100.", ephemeral=True + ) + return + + self.player.set_volume(interaction.guild.id, volume / 100) + await self.player.trigger_state_change() + await interaction.response.send_message(f"Zmieniono głośność na {volume}%") + + @app_commands.command(name="nowplaying", description="Pokaż aktualnie odtwarzany utwór") + async def now_playing(self, interaction: discord.Interaction) -> None: + current = self.player.state(interaction.guild.id).current + if not current: + await interaction.response.send_message( + "Nic nie jest obecnie odtwarzane.", ephemeral=True + ) + return + title = discord.utils.escape_markdown(current.title or "") + await interaction.response.send_message(f"Teraz odtwarzane: {title}") + + @app_commands.command(name="leave", description="Odłącz bota od kanału głosowego") + async def leave(self, interaction: discord.Interaction) -> None: + if interaction.guild.voice_client is None: + await interaction.response.send_message("Nie jestem na kanale głosowym.", ephemeral=True) + return + await interaction.guild.voice_client.disconnect() + self.player.clear(interaction.guild.id) + await interaction.response.send_message("Rozłączono z kanału głosowego.") + + # --- Zapętlanie i limity odtworzeń -------------------------------------- + + @app_commands.command( + name="loop", + description="Zmień tryb zapętlania (Wyłączone -> Cała kolejka -> Jeden utwór)", + ) + async def loop_command(self, interaction: discord.Interaction) -> None: + mode = self.player.cycle_loop_mode(interaction.guild.id) + await self.player.trigger_state_change() + await interaction.response.send_message(mode.label) + + @app_commands.command( + name="loopplaylist", + description="Włącz/wyłącz zapętlanie całej kolejki (tryb playlisty)", + ) + async def loopplaylist_command(self, interaction: discord.Interaction) -> None: + guild_id = interaction.guild.id + enabled = not self.player.state(guild_id).loop_mode.loops_queue + self.player.set_loop_mode(guild_id, LoopMode.QUEUE if enabled else LoopMode.OFF) + await self.player.trigger_state_change() + + description = ( + "włączone (utwory po zakończeniu wracają na koniec kolejki)" + if enabled + else "wyłączone" + ) + await interaction.response.send_message(f"🔁 Zapętlanie całej kolejki: **{description}**.") + + @app_commands.command( + name="defaultplays", + description="Ustaw domyślną liczbę odtworzeń dla nowych utworów w kolejce", + ) + @app_commands.describe(count="Domyślna liczba odtworzeń (1-999)") + async def default_plays_command(self, interaction: discord.Interaction, count: int) -> None: + if not self._settings.is_valid_plays(count): + await interaction.response.send_message( + "❌ Liczba odtworzeń musi być z przedziału 1-999.", ephemeral=True + ) + return + self.player.set_default_plays(count) + await self.player.trigger_state_change() + await interaction.response.send_message( + f"✅ Domyślna liczba odtworzeń dla nowych utworów została ustawiona na: **{count}**." + ) + + @app_commands.command( + name="trackplays", description="Zmień liczbę odtworzeń dla utworu w kolejce" + ) + @app_commands.describe(index="Pozycja w kolejce (od 1)", count="Nowa liczba odtworzeń (1-999)") + async def track_plays_command( + self, interaction: discord.Interaction, index: int, count: int + ) -> None: + queue = self.player.state(interaction.guild.id).queue + if not 1 <= index <= len(queue): + await interaction.response.send_message( + "❌ Nieprawidłowy numer utworu w kolejce.", ephemeral=True + ) + return + if not self._settings.is_valid_plays(count): + await interaction.response.send_message( + "❌ Liczba odtworzeń musi być z przedziału 1-999.", ephemeral=True + ) + return + + track = queue[index - 1] + track.plays_left = count + await self.player.trigger_state_change() + title = discord.utils.escape_markdown(track.title or "") + await interaction.response.send_message( + f"✅ Ustawiono liczbę odtworzeń dla utworu **{title}** na **{count}**." + ) diff --git a/zyrarbot/bot/guild_resolver.py b/zyrarbot/bot/guild_resolver.py new file mode 100644 index 0000000..f7d1408 --- /dev/null +++ b/zyrarbot/bot/guild_resolver.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import discord +from discord.ext import commands + + +class GuildResolver: + """Ustala kontekst serwera dla panelu webowego, który nie zna interakcji Discorda.""" + + def __init__(self, bot: commands.Bot): + self._bot = bot + + def active_guild(self) -> discord.Guild | None: + """Serwer z aktywnym połączeniem głosowym, a w razie jego braku - pierwszy znany.""" + if self._bot.voice_clients: + return self._bot.voice_clients[0].guild + return self._bot.guilds[0] if self._bot.guilds else None + + def guilds(self) -> list[discord.Guild]: + return list(self._bot.guilds) + + def channel(self, channel_id: int) -> discord.abc.GuildChannel | None: + return self._bot.get_channel(channel_id) + + def voice_client(self, guild_id: int) -> discord.VoiceClient | None: + return discord.utils.get(self._bot.voice_clients, guild__id=guild_id)