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:
1 parent
a143cb21d3
commit
b2651bb957
5 files changed
+460
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Warstwa Discorda: klient bota, cogi i komendy ukośnikowe."""
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Cogi rejestrowane w kliencie Discorda."""
|
||||
@@ -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}**."
|
||||
)
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user