refactor(bot): klient Discorda i cienki cog komend

- ZyrarBot buduje intencje, rejestruje cog i synchronizuje drzewo komend.
- MusicCog zawiera już wyłącznie obsługę komend ukośnikowych: walidację
  argumentów, komunikaty dla użytkownika i wywołania MusicPlayer. Cała
  praca ze stanem zniknęła z tego pliku.
- GuildResolver ustala kontekst serwera dla panelu webowego, który nie ma
  interakcji Discorda - wcześniej robiła to funkcja get_active_guild
  w web.py, sięgająca po globalny obiekt bota.
- Powtarzana w czterech komendach odpowiedź "dodano do kolejki" albo
  "teraz odtwarzane" zebrana w jednym helperze.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
lgooseandClaude Bot committed 2026-08-21 01:10:28 +02:00
1 parent a143cb21d3
commit b2651bb957
5 files changed
+460

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Warstwa Discorda: klient bota, cogi i komendy ukośnikowe."""
+54
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Cogi rejestrowane w kliencie Discorda."""
+378
View File
@@ -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}**."
)
+26
View File
@@ -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)