From c5502150701894d0c4ce6546d41b3ac0809dc6ea Mon Sep 17 00:00:00 2001 From: Marcin Macierzanka Date: Sat, 23 May 2026 14:45:37 +0200 Subject: [PATCH] feat: add multi-guild support and text channel mapping persistence via Docker volume --- .gitignore | 1 + bot.py | 159 ++++++++++++++++++++++++-------------- compose.yaml | 2 + web.py | 210 +++++++++++++++++++++++++++++++++++---------------- 4 files changed, 250 insertions(+), 122 deletions(-) diff --git a/.gitignore b/.gitignore index 1058399..885f399 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__/ *.pyc downloads/ .DS_Store +config/ diff --git a/bot.py b/bot.py index 06f37e0..238eca2 100644 --- a/bot.py +++ b/bot.py @@ -15,52 +15,80 @@ GUILD_ID = os.getenv('GUILD_ID') class MusicBot(commands.Cog): def __init__(self, bot): self.bot = bot - self.queue = [] - self.current = None - self.volume = 1.0 # Domyślna głośność ustawiona na 100% (1.0) - self.loop_current = False - self.last_channel = None + self.queues = {} # guild_id -> list + self.currents = {} # guild_id -> player + self.volumes = {} # guild_id -> volume float + self.loop_currents = {} # guild_id -> bool + self.last_channels = {} # guild_id -> text channel - def load_saved_channel_id(self): - if os.path.exists("config.json"): + def get_queue(self, guild_id: int): + if guild_id not in self.queues: + self.queues[guild_id] = [] + return self.queues[guild_id] + + def get_current(self, guild_id: int): + return self.currents.get(guild_id) + + def set_current(self, guild_id: int, player): + self.currents[guild_id] = player + + def get_volume(self, guild_id: int): + return self.volumes.get(guild_id, 1.0) + + def set_volume(self, guild_id: int, volume: float): + self.volumes[guild_id] = volume + + def get_loop_current(self, guild_id: int): + return self.loop_currents.get(guild_id, False) + + def set_loop_current(self, guild_id: int, loop: bool): + self.loop_currents[guild_id] = loop + + def load_saved_channel_id(self, guild_id: int): + config_path = "config/config.json" + if os.path.exists(config_path): try: import json - with open("config.json", "r") as f: + with open(config_path, "r") as f: data = json.load(f) - return data.get("text_channel_id") + val = data.get(str(guild_id)) + return int(val) if val is not None else None except Exception as e: print(f"Error loading config: {e}") return None - def save_channel_id(self, channel_id): + def save_channel_id(self, guild_id: int, channel_id: int): + config_path = "config/config.json" try: import json + os.makedirs(os.path.dirname(config_path), exist_ok=True) data = {} - if os.path.exists("config.json"): - with open("config.json", "r") as f: + if os.path.exists(config_path): + with open(config_path, "r") as f: data = json.load(f) - data["text_channel_id"] = channel_id - with open("config.json", "w") as f: + data[str(guild_id)] = channel_id + with open(config_path, "w") as f: json.dump(data, f, indent=4) except Exception as e: print(f"Error saving config: {e}") - def get_notification_channel(self): - if self.last_channel: - return self.last_channel + def get_notification_channel(self, guild_id: int): + if self.last_channels.get(guild_id): + return self.last_channels[guild_id] - saved_id = self.load_saved_channel_id() + saved_id = self.load_saved_channel_id(guild_id) if saved_id: channel = self.bot.get_channel(int(saved_id)) if channel: - self.last_channel = channel + self.last_channels[guild_id] = channel return channel - voice_client = self.bot.voice_clients[0] if self.bot.voice_clients else None - if voice_client and voice_client.channel: - for tc in voice_client.channel.guild.text_channels: - if tc.permissions_for(voice_client.channel.guild.me).send_messages: - self.last_channel = tc + # Fallback to the first accessible channel in the guild + guild = self.bot.get_guild(guild_id) + if guild: + for tc in guild.text_channels: + if tc.permissions_for(guild.me).send_messages: + self.last_channels[guild_id] = tc return tc return None @@ -92,23 +120,24 @@ class MusicBot(commands.Cog): return await interaction.response.defer() - self.last_channel = interaction.channel + guild_id = interaction.guild.id + self.last_channels[guild_id] = interaction.channel try: player = await YTDLSource.from_url(query, loop=self.bot.loop, stream=True) if interaction.guild.voice_client.is_playing(): - self.queue.append(player) + self.get_queue(guild_id).append(player) await interaction.followup.send( f"Dodano do kolejki: {player.title}", ephemeral=False ) else: - self.current = player + self.set_current(guild_id, player) interaction.guild.voice_client.play( player, after=lambda e: asyncio.run_coroutine_threadsafe( - self.play_next(), + self.play_next(guild_id), self.bot.loop ) ) @@ -122,45 +151,50 @@ class MusicBot(commands.Cog): ephemeral=True ) - async def play_next(self): - voice_client = self.bot.voice_clients[0] if self.bot.voice_clients else None + async def play_next(self, guild_id: int): + voice_client = discord.utils.get(self.bot.voice_clients, guild__id=guild_id) if not voice_client: - self.current = None + self.set_current(guild_id, None) return - if self.loop_current and self.current: + loop_current = self.get_loop_current(guild_id) + current = self.get_current(guild_id) + + if loop_current and current: try: - url = self.current.data.get('webpage_url') or self.current.url + url = current.data.get('webpage_url') or current.url player = await YTDLSource.from_url(url, loop=self.bot.loop, stream=True) - self.current = player + self.set_current(guild_id, player) voice_client.play( - self.current, + player, after=lambda e: asyncio.run_coroutine_threadsafe( - self.play_next(), + self.play_next(guild_id), self.bot.loop ) ) - await self.send_to_last_channel(f"Teraz odtwarzane (zapętlone): {self.current.title}") + await self.send_to_last_channel(guild_id, f"Teraz odtwarzane (zapętlone): {player.title}") return except Exception as e: print(f"Error repeating song: {e}") - if len(self.queue) > 0: - self.current = self.queue.pop(0) + queue = self.get_queue(guild_id) + if len(queue) > 0: + next_player = queue.pop(0) + self.set_current(guild_id, next_player) voice_client.play( - self.current, + next_player, after=lambda e: asyncio.run_coroutine_threadsafe( - self.play_next(), + self.play_next(guild_id), self.bot.loop ) ) - await self.send_to_last_channel(f"Teraz odtwarzane: {self.current.title}") + await self.send_to_last_channel(guild_id, f"Teraz odtwarzane: {next_player.title}") else: - self.current = None - await self.send_to_last_channel("Kolejka jest pusta.") + self.set_current(guild_id, None) + await self.send_to_last_channel(guild_id, "Kolejka jest pusta.") - async def send_to_last_channel(self, text): - channel = self.get_notification_channel() + async def send_to_last_channel(self, guild_id: int, text: str): + channel = self.get_notification_channel(guild_id) if channel: try: await channel.send(text) @@ -170,14 +204,16 @@ class MusicBot(commands.Cog): @app_commands.command(name="queue", description="Pokaż aktualną kolejkę") async def show_queue(self, interaction: discord.Interaction): """Pokaż aktualną kolejkę""" - if not self.queue: + guild_id = interaction.guild.id + queue = self.get_queue(guild_id) + if not queue: await interaction.response.send_message( "Kolejka jest pusta.", ephemeral=True ) return - queue_list = "\n".join([f"{i + 1}. {item.title}" for i, item in enumerate(self.queue)]) + queue_list = "\n".join([f"{i + 1}. {item.title}" for i, item in enumerate(queue)]) await interaction.response.send_message( f"Aktualna kolejka:\n{queue_list}", ephemeral=False @@ -231,10 +267,11 @@ class MusicBot(commands.Cog): @app_commands.command(name="stop", description="Zatrzymaj odtwarzanie i wyczyść kolejkę") async def stop(self, interaction: discord.Interaction): """Zatrzymaj odtwarzanie i wyczyść kolejkę""" + guild_id = interaction.guild.id if interaction.guild.voice_client.is_playing(): interaction.guild.voice_client.stop() - self.queue.clear() - self.current = None + self.get_queue(guild_id).clear() + self.set_current(guild_id, None) await interaction.response.send_message( "Zatrzymano odtwarzanie i wyczyszczono kolejkę.", ephemeral=False @@ -244,6 +281,7 @@ class MusicBot(commands.Cog): @app_commands.describe(volume="Poziom głośności (1-100)") async def volume(self, interaction: discord.Interaction, volume: int): """Zmień głośność odtwarzacza""" + guild_id = interaction.guild.id if interaction.guild.voice_client is None: return await interaction.response.send_message( "Nie połączono z kanałem głosowym.", @@ -251,8 +289,8 @@ class MusicBot(commands.Cog): ) if 0 < volume <= 100: - self.volume = volume / 100 - interaction.guild.voice_client.source.volume = self.volume + self.set_volume(guild_id, volume / 100) + interaction.guild.voice_client.source.volume = self.get_volume(guild_id) await interaction.response.send_message( f"Zmieniono głośność na {volume}%", ephemeral=False @@ -268,9 +306,11 @@ class MusicBot(commands.Cog): @app_commands.command(name="nowplaying", description="Pokaż aktualnie odtwarzany utwór") async def now_playing(self, interaction: discord.Interaction): """Pokaż aktualnie odtwarzany utwór""" - if self.current: + guild_id = interaction.guild.id + current = self.get_current(guild_id) + if current: await interaction.response.send_message( - f"Teraz odtwarzane: {self.current.title}", + f"Teraz odtwarzane: {current.title}", ephemeral=False ) else: @@ -282,10 +322,11 @@ class MusicBot(commands.Cog): @app_commands.command(name="leave", description="Odłącz bota od kanału głosowego") async def leave(self, interaction: discord.Interaction): """Odłącz bota od kanału głosowego""" + guild_id = interaction.guild.id if interaction.guild.voice_client is not None: await interaction.guild.voice_client.disconnect() - self.queue.clear() - self.current = None + self.get_queue(guild_id).clear() + self.set_current(guild_id, None) await interaction.response.send_message( "Rozłączono z kanału głosowego.", ephemeral=False @@ -299,8 +340,10 @@ class MusicBot(commands.Cog): @app_commands.command(name="loop", description="Włącz/wyłącz zapętlanie aktualnego utworu") async def loop_command(self, interaction: discord.Interaction): """Włącz/wyłącz zapętlanie aktualnego utworu""" - self.loop_current = not self.loop_current - state = "włączone" if self.loop_current else "wyłączone" + guild_id = interaction.guild.id + loop_current = not self.get_loop_current(guild_id) + self.set_loop_current(guild_id, loop_current) + state = "włączone" if loop_current else "wyłączone" await interaction.response.send_message( f"Zapętlanie aktualnego utworu zostało {state}.", ephemeral=False diff --git a/compose.yaml b/compose.yaml index e1f6157..cb9419d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -8,4 +8,6 @@ services: - "80:8000" environment: - DISCORD_TOKEN=${DISCORD_TOKEN} + volumes: + - ./config:/app/config restart: unless-stopped diff --git a/web.py b/web.py index ff4e9b0..8ba334e 100644 --- a/web.py +++ b/web.py @@ -3,6 +3,7 @@ from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from typing import List, Optional import asyncio +import discord # Import bot instance and YTDL class from bot import bot @@ -33,6 +34,15 @@ class ConnectRequest(BaseModel): class TextChannelRequest(BaseModel): channel_id: str +# Helper to determine active guild +def get_active_guild(): + voice_client = bot.voice_clients[0] if bot.voice_clients else None + if voice_client: + return voice_client.guild + elif bot.guilds: + return bot.guilds[0] + return None + # FastAPI Endpoints @app.get("/api/status") async def get_status(): @@ -40,7 +50,25 @@ async def get_status(): if not music_cog: return {"error": "MusicBot cog not loaded"} - voice_client = bot.voice_clients[0] if bot.voice_clients else None + guild = get_active_guild() + if not guild: + return { + "playing": False, + "paused": False, + "loop_single": False, + "volume": 100, + "current": None, + "queue": [], + "voice_channels": [], + "active_voice_channel": None, + "text_channels": [], + "active_text_channel_id": None + } + + guild_id = guild.id + + # Active voice channel + voice_client = discord.utils.get(bot.voice_clients, guild__id=guild_id) active_vc = None if voice_client and voice_client.channel: active_vc = { @@ -50,53 +78,54 @@ async def get_status(): "guild_id": str(voice_client.channel.guild.id) } - # Gather voice channels available to the bot across all guilds + # Gather voice channels available to the bot on this active guild channels = [] - for guild in bot.guilds: - for channel in guild.voice_channels: - channels.append({ + for channel in guild.voice_channels: + channels.append({ + "guild_name": guild.name, + "guild_id": str(guild.id), + "channel_name": channel.name, + "channel_id": str(channel.id) + }) + + # Gather text channels available to the bot on this active guild where it can write + text_channels = [] + for channel in guild.text_channels: + if channel.permissions_for(guild.me).send_messages: + text_channels.append({ "guild_name": guild.name, "guild_id": str(guild.id), "channel_name": channel.name, "channel_id": str(channel.id) }) - - # Gather text channels available to the bot across all guilds where it has send permissions - text_channels = [] - for guild in bot.guilds: - for channel in guild.text_channels: - if channel.permissions_for(guild.me).send_messages: - text_channels.append({ - "guild_name": guild.name, - "guild_id": str(guild.id), - "channel_name": channel.name, - "channel_id": str(channel.id) - }) active_tc_id = None - active_tc = music_cog.get_notification_channel() + active_tc = music_cog.get_notification_channel(guild_id) if active_tc: active_tc_id = str(active_tc.id) playing = voice_client.is_playing() if voice_client else False paused = voice_client.is_paused() if voice_client else False + current_track = music_cog.get_current(guild_id) + queue = music_cog.get_queue(guild_id) + return { "playing": playing, "paused": paused, - "loop_single": music_cog.loop_current, - "volume": int(music_cog.volume * 100), + "loop_single": music_cog.get_loop_current(guild_id), + "volume": int(music_cog.get_volume(guild_id) * 100), "current": { - "title": music_cog.current.title, - "url": music_cog.current.data.get("webpage_url") or music_cog.current.url, - "id": music_cog.current.id - } if music_cog.current else None, + "title": current_track.title, + "url": current_track.data.get("webpage_url") or current_track.url, + "id": current_track.id + } if current_track else None, "queue": [ { "title": item.title, "url": item.data.get("webpage_url") or item.url, "id": item.id - } for item in music_cog.queue + } for item in queue ], "voice_channels": channels, "active_voice_channel": active_vc, @@ -110,54 +139,71 @@ async def api_play(req: PlayRequest): if not music_cog: raise HTTPException(status_code=500, detail="MusicBot cog not loaded") - voice_client = bot.voice_clients[0] if bot.voice_clients else None + guild = get_active_guild() + if not guild: + raise HTTPException(status_code=400, detail="Bot has no active server context") + + guild_id = guild.id + voice_client = discord.utils.get(bot.voice_clients, guild__id=guild_id) if not voice_client: - raise HTTPException(status_code=400, detail="Bot is not connected to a voice channel") + raise HTTPException(status_code=400, detail="Bot is not connected to a voice channel on this server") try: player = await YTDLSource.from_url(req.query, loop=bot.loop, stream=True) if voice_client.is_playing() or voice_client.is_paused(): - music_cog.queue.append(player) - await music_cog.send_to_last_channel(f"Dodano do kolejki (Web): {player.title}") + music_cog.get_queue(guild_id).append(player) + await music_cog.send_to_last_channel(guild_id, f"Dodano do kolejki (Web): {player.title}") return {"status": "queued", "title": player.title} else: - music_cog.current = player + music_cog.set_current(guild_id, player) voice_client.play( player, after=lambda e: asyncio.run_coroutine_threadsafe( - music_cog.play_next(), + music_cog.play_next(guild_id), bot.loop ) ) - await music_cog.send_to_last_channel(f"Teraz odtwarzane (Web): {player.title}") + await music_cog.send_to_last_channel(guild_id, f"Teraz odtwarzane (Web): {player.title}") return {"status": "playing", "title": player.title} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.post("/api/pause") async def api_pause(): - voice_client = bot.voice_clients[0] if bot.voice_clients else None + guild = get_active_guild() + if not guild: + raise HTTPException(status_code=400, detail="Bot has no active server context") + + voice_client = discord.utils.get(bot.voice_clients, guild__id=guild.id) if voice_client and voice_client.is_playing(): voice_client.pause() return {"status": "paused"} - raise HTTPException(status_code=400, detail="Bot is not playing") + raise HTTPException(status_code=400, detail="Bot is not playing on this server") @app.post("/api/resume") async def api_resume(): - voice_client = bot.voice_clients[0] if bot.voice_clients else None + guild = get_active_guild() + if not guild: + raise HTTPException(status_code=400, detail="Bot has no active server context") + + voice_client = discord.utils.get(bot.voice_clients, guild__id=guild.id) if voice_client and voice_client.is_paused(): voice_client.resume() return {"status": "resumed"} - raise HTTPException(status_code=400, detail="Bot is not paused") + raise HTTPException(status_code=400, detail="Bot is not paused on this server") @app.post("/api/skip") async def api_skip(): - voice_client = bot.voice_clients[0] if bot.voice_clients else None + guild = get_active_guild() + if not guild: + raise HTTPException(status_code=400, detail="Bot has no active server context") + + voice_client = discord.utils.get(bot.voice_clients, guild__id=guild.id) if voice_client and (voice_client.is_playing() or voice_client.is_paused()): voice_client.stop() return {"status": "skipped"} - raise HTTPException(status_code=400, detail="Bot is not playing anything") + raise HTTPException(status_code=400, detail="Bot is not playing anything on this server") @app.post("/api/stop") async def api_stop(): @@ -165,12 +211,17 @@ async def api_stop(): if not music_cog: raise HTTPException(status_code=500, detail="MusicBot cog not loaded") - voice_client = bot.voice_clients[0] if bot.voice_clients else None + guild = get_active_guild() + if not guild: + return {"status": "stopped"} + + guild_id = guild.id + voice_client = discord.utils.get(bot.voice_clients, guild__id=guild_id) if voice_client and (voice_client.is_playing() or voice_client.is_paused()): voice_client.stop() - music_cog.queue.clear() - music_cog.current = None + music_cog.get_queue(guild_id).clear() + music_cog.set_current(guild_id, None) return {"status": "stopped"} @app.post("/api/volume") @@ -179,11 +230,16 @@ async def api_volume(req: VolumeRequest): if not music_cog: raise HTTPException(status_code=500, detail="MusicBot cog not loaded") + guild = get_active_guild() + if not guild: + raise HTTPException(status_code=400, detail="Bot has no active server context") + + guild_id = guild.id if 0 <= req.volume <= 100: - music_cog.volume = req.volume / 100 - voice_client = bot.voice_clients[0] if bot.voice_clients else None + music_cog.set_volume(guild_id, req.volume / 100) + voice_client = discord.utils.get(bot.voice_clients, guild__id=guild_id) if voice_client and voice_client.source: - voice_client.source.volume = music_cog.volume + voice_client.source.volume = music_cog.get_volume(guild_id) return {"status": "success", "volume": req.volume} raise HTTPException(status_code=400, detail="Volume must be between 0 and 100") @@ -193,7 +249,11 @@ async def api_loop(req: LoopRequest): if not music_cog: raise HTTPException(status_code=500, detail="MusicBot cog not loaded") - music_cog.loop_current = req.loop + guild = get_active_guild() + if not guild: + raise HTTPException(status_code=400, detail="Bot has no active server context") + + music_cog.set_loop_current(guild.id, req.loop) return {"status": "success", "loop": req.loop} @app.post("/api/remove") @@ -202,10 +262,17 @@ async def api_remove(req: RemoveRequest): if not music_cog: raise HTTPException(status_code=500, detail="MusicBot cog not loaded") - initial_len = len(music_cog.queue) - music_cog.queue = [item for item in music_cog.queue if item.id != req.id] + guild = get_active_guild() + if not guild: + raise HTTPException(status_code=400, detail="Bot has no active server context") + + guild_id = guild.id + queue = music_cog.get_queue(guild_id) + initial_len = len(queue) + new_queue = [item for item in queue if item.id != req.id] + music_cog.queues[guild_id] = new_queue - if len(music_cog.queue) < initial_len: + if len(new_queue) < initial_len: return {"status": "removed"} raise HTTPException(status_code=404, detail="Track not found in queue") @@ -215,7 +282,13 @@ async def api_reorder(req: ReorderRequest): if not music_cog: raise HTTPException(status_code=500, detail="MusicBot cog not loaded") - queue_map = {item.id: item for item in music_cog.queue} + guild = get_active_guild() + if not guild: + raise HTTPException(status_code=400, detail="Bot has no active server context") + + guild_id = guild.id + queue = music_cog.get_queue(guild_id) + queue_map = {item.id: item for item in queue} new_queue = [] for track_id in req.queue_ids: @@ -223,11 +296,11 @@ async def api_reorder(req: ReorderRequest): new_queue.append(queue_map[track_id]) # Retain any items not present in the reordered IDs list - for item in music_cog.queue: + for item in queue: if item.id not in req.queue_ids: new_queue.append(item) - music_cog.queue = new_queue + music_cog.queues[guild_id] = new_queue return {"status": "success"} @app.post("/api/connect") @@ -241,35 +314,43 @@ async def api_connect(req: ConnectRequest): if not channel: raise HTTPException(status_code=404, detail="Voice channel not found") - if bot.voice_clients: - for vc in bot.voice_clients: + guild_id = channel.guild.id + + # Only disconnect voice clients from the SAME guild! + for vc in bot.voice_clients: + if vc.guild.id == guild_id: await vc.disconnect() await channel.connect() music_cog = bot.get_cog("MusicBot") - if music_cog and not music_cog.last_channel: + if music_cog and not music_cog.get_notification_channel(guild_id): # Save first accessible text channel to send updates for tc in channel.guild.text_channels: if tc.permissions_for(channel.guild.me).send_messages: - music_cog.last_channel = tc + music_cog.last_channels[guild_id] = tc break return {"status": "connected", "channel": channel.name} @app.post("/api/disconnect") async def api_disconnect(): - if bot.voice_clients: - for vc in bot.voice_clients: - await vc.disconnect() + guild = get_active_guild() + if not guild: + raise HTTPException(status_code=400, detail="Bot has no active server context") + + guild_id = guild.id + voice_client = discord.utils.get(bot.voice_clients, guild__id=guild_id) + if voice_client: + await voice_client.disconnect() music_cog = bot.get_cog("MusicBot") if music_cog: - music_cog.queue.clear() - music_cog.current = None + music_cog.get_queue(guild_id).clear() + music_cog.set_current(guild_id, None) return {"status": "disconnected"} - raise HTTPException(status_code=400, detail="Bot not connected to voice") + raise HTTPException(status_code=400, detail="Bot not connected to voice on this server") @app.post("/api/settings/text_channel") async def api_set_text_channel(req: TextChannelRequest): @@ -281,10 +362,11 @@ async def api_set_text_channel(req: TextChannelRequest): if not channel: raise HTTPException(status_code=404, detail="Text channel not found") - music_cog.last_channel = channel - music_cog.save_channel_id(int(req.channel_id)) + guild_id = channel.guild.id + music_cog.last_channels[guild_id] = channel + music_cog.save_channel_id(guild_id, int(req.channel_id)) - # Send test message to confirm in Polish with new bot name + # Send test message to confirm try: await channel.send(f"🔔 Ten kanał został ustawiony jako główny kanał powiadomień dla ŻyrarBOT z panelu webowego.") except Exception as e: