diff options
| author | salmonik <raisersoft@proton.me> | 2026-05-21 17:10:23 +0200 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-05-21 17:10:23 +0200 |
| commit | 2e614665e0c62fe572b5af53a2a6be1116d66720 (patch) | |
| tree | dbb48ad5d665a3f3d8c793db30a523264f17bcde /cogs | |
| parent | fix naming not resolving things like %20 (diff) | |
| parent | Fix voice reconnect race: reuse VoiceClient instead of destroying it (diff) | |
| download | senator-sperma-2e614665e0c62fe572b5af53a2a6be1116d66720.tar.gz senator-sperma-2e614665e0c62fe572b5af53a2a6be1116d66720.zip | |
Merge pull request 'Voice related fixes, alongside simplifying assistant' (#5) from dev into main
Reviewed-on: https://codeberg.org/salmonik/senator-sperma/pulls/5
Diffstat (limited to '')
| -rw-r--r-- | cogs/ai.py | 7 | ||||
| -rw-r--r-- | cogs/assistant.py | 112 | ||||
| -rw-r--r-- | cogs/cron.py | 26 | ||||
| -rw-r--r-- | cogs/music/audio_playback.py | 282 | ||||
| -rw-r--r-- | cogs/music/join.py | 4 | ||||
| -rw-r--r-- | cogs/music/play_commands.py | 11 | ||||
| -rw-r--r-- | cogs/music/stop.py | 5 | ||||
| -rw-r--r-- | cogs/tts.py | 16 | ||||
| -rw-r--r-- | cogs/vclogs.py | 19 | ||||
| -rw-r--r-- | cogs/voice.py | 504 |
10 files changed, 429 insertions, 557 deletions
| @@ -9,7 +9,6 @@ from discord.ext import commands | |||
| 9 | import logging | 9 | import logging |
| 10 | import httpx | 10 | import httpx |
| 11 | from gtts import gTTS | 11 | from gtts import gTTS |
| 12 | from cogs.music import audio_playback | ||
| 13 | import io | 12 | import io |
| 14 | 13 | ||
| 15 | logger = logging.getLogger(__name__) | 14 | logger = logging.getLogger(__name__) |
| @@ -61,12 +60,14 @@ class AI(commands.Cog): | |||
| 61 | else: | 60 | else: |
| 62 | response_text = response_data["choices"][0]["message"]["content"] | 61 | response_text = response_data["choices"][0]["message"]["content"] |
| 63 | if tts: | 62 | if tts: |
| 64 | # Generate TTS | ||
| 65 | tts_obj = gTTS(text=response_text, lang='pl') | 63 | tts_obj = gTTS(text=response_text, lang='pl') |
| 66 | tts_buffer = io.BytesIO() | 64 | tts_buffer = io.BytesIO() |
| 67 | tts_obj.write_to_fp(tts_buffer) | 65 | tts_obj.write_to_fp(tts_buffer) |
| 68 | tts_buffer.seek(0) | 66 | tts_buffer.seek(0) |
| 69 | await audio_playback.AudioPlaybackManager.play(ctx, discord.FFmpegPCMAudio(tts_buffer), delete_after_play=True, interrupt=False) | 67 | from cogs.voice import get_voice_manager |
| 68 | vm = get_voice_manager(self.bot) | ||
| 69 | channel = ctx.author.voice.channel | ||
| 70 | await vm.play_source(channel, discord.FFmpegPCMAudio(tts_buffer), interrupt=False) | ||
| 70 | 71 | ||
| 71 | else: | 72 | else: |
| 72 | await ctx.send_followup(response_text) | 73 | await ctx.send_followup(response_text) |
diff --git a/cogs/assistant.py b/cogs/assistant.py index a91d027..fed7825 100644 --- a/cogs/assistant.py +++ b/cogs/assistant.py | |||
| @@ -3,16 +3,11 @@ | |||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io |
| 4 | # https://codeberg.org/salmonik/senator-sperma | 4 | # https://codeberg.org/salmonik/senator-sperma |
| 5 | 5 | ||
| 6 | import re | ||
| 7 | import discord | 6 | import discord |
| 8 | from discord.ext import commands | 7 | from discord.ext import commands |
| 9 | import httpx | 8 | import httpx |
| 10 | import json | ||
| 11 | import logging | 9 | import logging |
| 12 | import shutil | ||
| 13 | import os | 10 | import os |
| 14 | from tempfile import NamedTemporaryFile | ||
| 15 | from cogs.downloader import Downloader | ||
| 16 | 11 | ||
| 17 | logger = logging.getLogger(__name__) | 12 | logger = logging.getLogger(__name__) |
| 18 | 13 | ||
| @@ -46,15 +41,16 @@ class Jarvis(commands.Cog): | |||
| 46 | messages = Jarvis._build_messages(message, chain) | 41 | messages = Jarvis._build_messages(message, chain) |
| 47 | payload = Jarvis._build_payload(messages) | 42 | payload = Jarvis._build_payload(messages) |
| 48 | 43 | ||
| 49 | response_data = await Jarvis._make_api_request(payload, headers) | 44 | response_data = await Jarvis._safe_api_call(payload, headers) |
| 50 | response_message, action = Jarvis._parse_response(response_data) | 45 | if response_data is None: |
| 46 | await message.reply("Przepraszam, nie mogę teraz odpowiedzieć. Spróbuj ponownie później.") | ||
| 47 | return | ||
| 48 | |||
| 49 | response_message = Jarvis._parse_response(response_data) | ||
| 51 | 50 | ||
| 52 | channel = message.channel | 51 | channel = message.channel |
| 53 | await message.reply(response_message) | 52 | await message.reply(response_message) |
| 54 | 53 | ||
| 55 | if action == "download": | ||
| 56 | await Jarvis._handle_download_action(message, channel) | ||
| 57 | |||
| 58 | @staticmethod | 54 | @staticmethod |
| 59 | async def _build_message_chain(message): | 55 | async def _build_message_chain(message): |
| 60 | chain = [] | 56 | chain = [] |
| @@ -80,10 +76,10 @@ class Jarvis(commands.Cog): | |||
| 80 | messages = [{ | 76 | messages = [{ |
| 81 | "role": "system", | 77 | "role": "system", |
| 82 | "content": "Jesteś Senator Sperma Armostrong, także znany jako Jarvis." | 78 | "content": "Jesteś Senator Sperma Armostrong, także znany jako Jarvis." |
| 83 | "Twoim zadaniem, po za asystowaniem użytkownika, jest mówienie programowi nadrzędnemu jak sie zachować" | 79 | "Twoim zadaniem jest asystowanie użytkownika" |
| 84 | "pole 'message' może być tylko wypełnione w języku Polskim." | ||
| 85 | "zachowuj sie jak Jarvis, bądź osobistym asystentem użytkownika, nie używaj emotikon" | 80 | "zachowuj sie jak Jarvis, bądź osobistym asystentem użytkownika, nie używaj emotikon" |
| 86 | "nie pytaj sie użytkownika czy chcesz abyś coś zrobił, użytkownik ci sam powie abyś coś wykonał" | 81 | "nie pytaj sie użytkownika czy chce abyś coś zrobił, użytkownik ci sam powie abyś coś wykonał" |
| 82 | "Nie pozostawiaj wiadomości pustej oraz nie dawaj swojej wiadomości w pole myślenia" | ||
| 87 | }] | 83 | }] |
| 88 | 84 | ||
| 89 | if chain is None: | 85 | if chain is None: |
| @@ -92,7 +88,7 @@ class Jarvis(commands.Cog): | |||
| 92 | for msg in chain: | 88 | for msg in chain: |
| 93 | messages.append({ | 89 | messages.append({ |
| 94 | "role": "user", | 90 | "role": "user", |
| 95 | "content": f"{msg.author.display_name}: {msg.content}" | 91 | "content": f"USER: {msg.author.display_name}\nMESSAGE: {msg.content}" |
| 96 | }) | 92 | }) |
| 97 | 93 | ||
| 98 | return messages | 94 | return messages |
| @@ -102,38 +98,12 @@ class Jarvis(commands.Cog): | |||
| 102 | return { | 98 | return { |
| 103 | "model": os.getenv("OPENROUTER_MODEL"), | 99 | "model": os.getenv("OPENROUTER_MODEL"), |
| 104 | "messages": messages, | 100 | "messages": messages, |
| 105 | "response_format": Jarvis._get_response_schema(), | ||
| 106 | "plugins": [{"id": "response-healing"}] | 101 | "plugins": [{"id": "response-healing"}] |
| 107 | } | 102 | } |
| 108 | 103 | ||
| 109 | @staticmethod | 104 | @staticmethod |
| 110 | def _get_response_schema(): | ||
| 111 | return { | ||
| 112 | "type": "json_schema", | ||
| 113 | "json_schema": { | ||
| 114 | "name": "response", | ||
| 115 | "strict": True, | ||
| 116 | "schema": { | ||
| 117 | "type": "object", | ||
| 118 | "properties": { | ||
| 119 | "message": { | ||
| 120 | "type": "string", | ||
| 121 | "description": "the message that user will receive", | ||
| 122 | }, | ||
| 123 | "action": { | ||
| 124 | "type": "string", | ||
| 125 | "description": "the action to do, either download, compress or none" | ||
| 126 | } | ||
| 127 | }, | ||
| 128 | "required": ["message", "action"], | ||
| 129 | "additionalProperties": False, | ||
| 130 | } | ||
| 131 | } | ||
| 132 | } | ||
| 133 | |||
| 134 | @staticmethod | ||
| 135 | async def _make_api_request(payload, headers): | 105 | async def _make_api_request(payload, headers): |
| 136 | async with httpx.AsyncClient() as client: | 106 | async with httpx.AsyncClient(timeout=30.0) as client: |
| 137 | url = "https://openrouter.ai/api/v1/chat/completions" | 107 | url = "https://openrouter.ai/api/v1/chat/completions" |
| 138 | response = await client.post(url=url, headers=headers, json=payload) | 108 | response = await client.post(url=url, headers=headers, json=payload) |
| 139 | response_data = response.json() | 109 | response_data = response.json() |
| @@ -141,62 +111,20 @@ class Jarvis(commands.Cog): | |||
| 141 | return response_data | 111 | return response_data |
| 142 | 112 | ||
| 143 | @staticmethod | 113 | @staticmethod |
| 144 | def _parse_response(response_data): | 114 | async def _safe_api_call(payload, headers): |
| 145 | content = response_data['choices'][0]['message']['content'] | ||
| 146 | inner_json_str = content.strip('```json\n').strip('```').strip() | ||
| 147 | try: | 115 | try: |
| 148 | inner_data = json.loads(inner_json_str) | 116 | return await Jarvis._make_api_request(payload, headers) |
| 149 | response_message = inner_data['message'] | 117 | except httpx.HTTPError as e: |
| 150 | action = inner_data['action'] | 118 | logger.error(f"API request failed: {e}") |
| 151 | except (json.JSONDecodeError, KeyError): | 119 | return None |
| 152 | response_message = content | ||
| 153 | action = "none" | ||
| 154 | |||
| 155 | logger.info(response_message) | ||
| 156 | logger.info(action) | ||
| 157 | |||
| 158 | return response_message, action | ||
| 159 | 120 | ||
| 160 | @staticmethod | 121 | @staticmethod |
| 161 | async def _handle_download_action(message, channel): | 122 | def _parse_response(response_data): |
| 162 | url_match = Jarvis._extract_url(message.content) | 123 | content = response_data['choices'][0]['message']['content'] |
| 163 | if not url_match: | ||
| 164 | await channel.send("Nie znaleziono poprawnego linku w odpowiedzi.") | ||
| 165 | return | ||
| 166 | |||
| 167 | response = await Downloader.download_standalone(url=url_match) | ||
| 168 | await Jarvis._process_download_response(response, channel) | ||
| 169 | |||
| 170 | @staticmethod | ||
| 171 | def _extract_url(content): | ||
| 172 | match = re.search(r'https://(?:[A-Za-z0-9-]+\.)*[A-Za-z0-9-]{1,63}\.[A-Za-z]{2,63}/\S*', content) | ||
| 173 | return match.group(0) if match else None | ||
| 174 | |||
| 175 | @staticmethod | ||
| 176 | async def _process_download_response(response, channel): | ||
| 177 | if not isinstance(response, dict): | ||
| 178 | return | ||
| 179 | 124 | ||
| 180 | if response["status"] == "success": | 125 | logger.info(content) |
| 181 | await Jarvis._handle_successful_download(response, channel) | ||
| 182 | else: | ||
| 183 | await Jarvis._handle_failed_download(response) | ||
| 184 | 126 | ||
| 185 | @staticmethod | 127 | return content |
| 186 | async def _handle_successful_download(response, channel): | ||
| 187 | if response["type"] == "url": | ||
| 188 | await channel.send(f"pobioron i wysyłon: {response['content']}") | ||
| 189 | elif response["type"] == "file": | ||
| 190 | file_data = response["content"] | ||
| 191 | await channel.send("wysyłon:", file=discord.File(file_data)) | ||
| 192 | shutil.rmtree(response["temp_dir"]) | ||
| 193 | |||
| 194 | @staticmethod | ||
| 195 | async def _handle_failed_download(response): | ||
| 196 | logger.error(f"Error: {response['content']}") | ||
| 197 | if 'details' in response: | ||
| 198 | logger.error(f"Details: {response['details']}") | ||
| 199 | shutil.rmtree(response["temp_dir"]) | ||
| 200 | 128 | ||
| 201 | def setup(bot): | 129 | def setup(bot): |
| 202 | bot.add_cog(Jarvis(bot)) | 130 | bot.add_cog(Jarvis(bot)) |
diff --git a/cogs/cron.py b/cogs/cron.py index aad43d2..ee88085 100644 --- a/cogs/cron.py +++ b/cogs/cron.py | |||
| @@ -145,29 +145,23 @@ class Cron(commands.Cog): | |||
| 145 | 145 | ||
| 146 | if isinstance(channel, discord.VoiceChannel): | 146 | if isinstance(channel, discord.VoiceChannel): |
| 147 | voice_mgr = get_voice_manager(self.bot) | 147 | voice_mgr = get_voice_manager(self.bot) |
| 148 | voice_client = await voice_mgr.join_channel(channel) | ||
| 149 | if not voice_client: | ||
| 150 | if channel.guild.system_channel: | ||
| 151 | await channel.guild.system_channel.send( | ||
| 152 | "Nie udano się połączyć z kanałem głosowym" | ||
| 153 | ) | ||
| 154 | return | ||
| 155 | 148 | ||
| 156 | try: | 149 | try: |
| 157 | audio_source = discord.FFmpegPCMAudio(audio_url) | 150 | audio_source = discord.FFmpegPCMAudio(audio_url) |
| 158 | voice_client.play(audio_source) | 151 | success = await voice_mgr.play_source(channel, audio_source) |
| 159 | 152 | ||
| 160 | while voice_client.is_playing(): | 153 | if success: |
| 161 | await asyncio.sleep(1) | 154 | logger.info(f"Finished playing audio: {audio_url}") |
| 155 | else: | ||
| 156 | raise Exception("Playback failed") | ||
| 162 | 157 | ||
| 163 | logger.info("Audio playback finished normally - disconnecting") | 158 | await voice_mgr.disconnect(channel.guild) |
| 164 | await voice_client.disconnect() | ||
| 165 | logger.info(f"Finished playing audio: {audio_url}") | ||
| 166 | except Exception as e: | 159 | except Exception as e: |
| 167 | logger.error(f"Error playing audio: {e}") | 160 | logger.error(f"Error playing audio: {e}") |
| 168 | if voice_client.is_connected(): | 161 | try: |
| 169 | logger.info("Audio playback failed - disconnecting") | 162 | await voice_mgr.disconnect(channel.guild) |
| 170 | await voice_client.disconnect() | 163 | except Exception: |
| 164 | pass | ||
| 171 | if channel.guild.system_channel: | 165 | if channel.guild.system_channel: |
| 172 | await channel.guild.system_channel.send( | 166 | await channel.guild.system_channel.send( |
| 173 | f"Błąd podczas odgrywania audio: {audio_url}" | 167 | f"Błąd podczas odgrywania audio: {audio_url}" |
diff --git a/cogs/music/audio_playback.py b/cogs/music/audio_playback.py deleted file mode 100644 index 1313d01..0000000 --- a/cogs/music/audio_playback.py +++ /dev/null | |||
| @@ -1,282 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import asyncio | ||
| 7 | import logging | ||
| 8 | import os | ||
| 9 | import urllib.parse | ||
| 10 | |||
| 11 | import discord | ||
| 12 | import mutagen | ||
| 13 | |||
| 14 | logger = logging.getLogger(__name__) | ||
| 15 | |||
| 16 | |||
| 17 | class AudioPlaybackManager: | ||
| 18 | """Handles audio playback, voice connections, and source preparation.""" | ||
| 19 | |||
| 20 | @staticmethod | ||
| 21 | async def cleanup_temp_file(source): | ||
| 22 | """Clean up temporary file if it exists.""" | ||
| 23 | if source and os.path.exists(source): | ||
| 24 | try: | ||
| 25 | os.remove(source) | ||
| 26 | logger.info(f"Cleaned up temporary file: {source}") | ||
| 27 | except Exception as e: | ||
| 28 | logger.error(f"Error cleaning up file {source}: {str(e)}") | ||
| 29 | |||
| 30 | @staticmethod | ||
| 31 | async def validate_voice_channel(ctx): | ||
| 32 | """Validate user is in a voice channel. Returns channel or None.""" | ||
| 33 | if not ctx.author.voice or not ctx.author.voice.channel: | ||
| 34 | await ctx.respond( | ||
| 35 | "Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True | ||
| 36 | ) | ||
| 37 | return None | ||
| 38 | return ctx.author.voice.channel | ||
| 39 | |||
| 40 | @staticmethod | ||
| 41 | async def prepare_file_source(source): | ||
| 42 | """Prepare and validate local file source. Returns (display_name, absolute_path) or (None, None).""" | ||
| 43 | logger.info(f"Attempting to play local file: {source}") | ||
| 44 | |||
| 45 | # Get absolute path and verify file exists | ||
| 46 | if not os.path.isabs(source): | ||
| 47 | source = os.path.abspath(source) | ||
| 48 | logger.debug(f"Converted to absolute path: {source}") | ||
| 49 | |||
| 50 | if not os.path.exists(source): | ||
| 51 | logger.error(f"File not found: {source}") | ||
| 52 | return None, None | ||
| 53 | |||
| 54 | filename = os.path.basename(source) | ||
| 55 | display_name = os.path.splitext(filename)[0] | ||
| 56 | return display_name, source | ||
| 57 | |||
| 58 | @staticmethod | ||
| 59 | async def connect_to_voice_channel(ctx, vc, target_channel): | ||
| 60 | """Connect or move to voice channel. Returns voice client.""" | ||
| 61 | try: | ||
| 62 | # Check if vc is valid and actually connected | ||
| 63 | if vc is not None and vc.is_connected(): | ||
| 64 | # Already connected - check if need to move | ||
| 65 | if vc.channel != target_channel: | ||
| 66 | logger.info(f"Moving to voice channel: {target_channel.name}") | ||
| 67 | await vc.move_to(target_channel) | ||
| 68 | logger.info(f"Moved to voice channel: {target_channel.name}") | ||
| 69 | return vc | ||
| 70 | |||
| 71 | # Not connected - connect now | ||
| 72 | logger.info(f"Connecting to voice channel: {target_channel.name}") | ||
| 73 | vc = await target_channel.connect() | ||
| 74 | logger.info(f"Connected to voice channel: {target_channel.name}") | ||
| 75 | return vc | ||
| 76 | except Exception as e: | ||
| 77 | logger.error(f"Error connecting to voice channel: {str(e)}") | ||
| 78 | raise | ||
| 79 | |||
| 80 | @staticmethod | ||
| 81 | async def prepare_audio_source(source, is_url, display_name): | ||
| 82 | """Prepare audio source for playback. Returns (source, final_display_name).""" | ||
| 83 | if is_url: | ||
| 84 | # For URLs, use FFmpeg with the direct URL and extract clean filename | ||
| 85 | before_options = "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5" | ||
| 86 | audio_source = discord.FFmpegPCMAudio(source, before_options=before_options) | ||
| 87 | # Extract clean filename for URL (remove extension and decode URL encoding) | ||
| 88 | filename = os.path.basename(source.split("?")[0]) # Remove query parameters | ||
| 89 | filename = urllib.parse.unquote(filename) # Decode URL encoding | ||
| 90 | clean_filename = os.path.splitext(filename)[0] # Remove file extension | ||
| 91 | return audio_source, clean_filename | ||
| 92 | else: | ||
| 93 | # For local files, use default options | ||
| 94 | audio_source = discord.FFmpegPCMAudio(source=source) | ||
| 95 | |||
| 96 | # Try to get metadata for local files | ||
| 97 | try: | ||
| 98 | metadata = mutagen.open(source) # type: ignore[call-overload] | ||
| 99 | if metadata: | ||
| 100 | title = ( | ||
| 101 | metadata.get("title", [None])[0] | ||
| 102 | if hasattr(metadata, "get") | ||
| 103 | else None | ||
| 104 | ) | ||
| 105 | if title: | ||
| 106 | display_name = title | ||
| 107 | logger.debug(f"Read metadata - title: {title}") | ||
| 108 | else: | ||
| 109 | display_name = os.path.splitext(os.path.basename(source))[0] | ||
| 110 | except Exception as e: | ||
| 111 | logger.warning(f"Could not read metadata from {source}: {str(e)}") | ||
| 112 | display_name = os.path.splitext(os.path.basename(source))[0] | ||
| 113 | |||
| 114 | return audio_source, display_name | ||
| 115 | |||
| 116 | @staticmethod | ||
| 117 | async def cleanup_if_needed(source, delete_after_play, is_url): | ||
| 118 | """Clean up temporary file if needed.""" | ||
| 119 | if delete_after_play and not is_url: | ||
| 120 | await AudioPlaybackManager.cleanup_temp_file(source) | ||
| 121 | |||
| 122 | @staticmethod | ||
| 123 | async def prepare_source(ctx, source): | ||
| 124 | """Prepare source and get display name. Returns (source, display_name, is_url) or (None, None, None).""" | ||
| 125 | is_url = source.startswith(("http://", "https://")) | ||
| 126 | |||
| 127 | if is_url: | ||
| 128 | logger.info(f"Attempting to play stream from URL: {source}") | ||
| 129 | return source, source, is_url | ||
| 130 | |||
| 131 | display_name, absolute_source = await AudioPlaybackManager.prepare_file_source( | ||
| 132 | source | ||
| 133 | ) | ||
| 134 | if not display_name: | ||
| 135 | return None, None, None | ||
| 136 | |||
| 137 | return absolute_source, display_name, is_url | ||
| 138 | |||
| 139 | @staticmethod | ||
| 140 | async def execute_playback(ctx, source, display_name, is_url, interrupt=True): | ||
| 141 | """Execute the actual audio playback. | ||
| 142 | |||
| 143 | Args: | ||
| 144 | ctx: Discord context | ||
| 145 | source: Audio source path or URL | ||
| 146 | display_name: Name to display | ||
| 147 | is_url: Whether source is a URL | ||
| 148 | interrupt: If True, stop any currently playing audio. If False, skip if audio is playing. | ||
| 149 | """ | ||
| 150 | target_channel = await AudioPlaybackManager.validate_voice_channel(ctx) | ||
| 151 | if not target_channel: | ||
| 152 | return False | ||
| 153 | |||
| 154 | # Get existing voice client from bot or connect | ||
| 155 | guild = ctx.guild | ||
| 156 | vc = discord.utils.get(ctx.bot.voice_clients, guild=guild) | ||
| 157 | |||
| 158 | try: | ||
| 159 | # Handle various connection states | ||
| 160 | if vc is None: | ||
| 161 | logger.info("No existing voice client, connecting...") | ||
| 162 | try: | ||
| 163 | vc = await target_channel.connect() | ||
| 164 | except discord.ClientException: | ||
| 165 | vc = discord.utils.get(ctx.bot.voice_clients, guild=guild) | ||
| 166 | elif not vc.is_connected(): | ||
| 167 | logger.info("Voice client exists but disconnected, reconnecting...") | ||
| 168 | try: | ||
| 169 | await vc.disconnect() | ||
| 170 | except Exception: | ||
| 171 | pass | ||
| 172 | try: | ||
| 173 | vc = await target_channel.connect() | ||
| 174 | except discord.ClientException: | ||
| 175 | vc = discord.utils.get(ctx.bot.voice_clients, guild=guild) | ||
| 176 | elif vc.channel != target_channel: | ||
| 177 | logger.info(f"Moving to voice channel: {target_channel.name}") | ||
| 178 | await vc.move_to(target_channel) | ||
| 179 | |||
| 180 | # Stop any currently playing audio (if interrupt is True) | ||
| 181 | if interrupt and (vc.is_playing() or vc.is_paused()): # type: ignore[union-attr] | ||
| 182 | vc.stop() # type: ignore[union-attr] | ||
| 183 | elif not interrupt and vc.is_playing(): # type: ignore[union-attr] | ||
| 184 | logger.info("Audio already playing and interrupt=False, skipping playback") | ||
| 185 | return False | ||
| 186 | |||
| 187 | # Prepare audio source | ||
| 188 | ( | ||
| 189 | audio_source, | ||
| 190 | final_display_name, | ||
| 191 | ) = await AudioPlaybackManager.prepare_audio_source( | ||
| 192 | source, is_url, display_name | ||
| 193 | ) | ||
| 194 | |||
| 195 | # Track playback state | ||
| 196 | playback_state = {"error": None, "finished": False} | ||
| 197 | |||
| 198 | def after_playing(error): | ||
| 199 | if error: | ||
| 200 | playback_state["error"] = str(error) | ||
| 201 | logger.error(f"Playback error: {error}") | ||
| 202 | playback_state["finished"] = True | ||
| 203 | |||
| 204 | # Play the audio | ||
| 205 | vc.play(audio_source, after=after_playing) | ||
| 206 | logger.info(f"Playing: {final_display_name}") | ||
| 207 | await ctx.followup.send(f"grom: {final_display_name}") | ||
| 208 | |||
| 209 | # Wait for FFmpeg to attempt connection - errors usually happen within 5 seconds | ||
| 210 | await asyncio.sleep(5) | ||
| 211 | |||
| 212 | # Check if playback failed to start (not just finished quickly) | ||
| 213 | if playback_state["error"]: | ||
| 214 | logger.error(f"Playback failed: {playback_state['error']}") | ||
| 215 | await ctx.followup.send( | ||
| 216 | "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True | ||
| 217 | ) | ||
| 218 | return False | ||
| 219 | |||
| 220 | # If playback finished within 5s, it was a short file - not an error | ||
| 221 | if playback_state["finished"]: | ||
| 222 | logger.info(f"Playback completed quickly: {final_display_name}") | ||
| 223 | return True | ||
| 224 | |||
| 225 | if not vc.is_playing(): | ||
| 226 | logger.error(f"Stream failed - not playing after 5s: {source}") | ||
| 227 | await ctx.followup.send( | ||
| 228 | "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True | ||
| 229 | ) | ||
| 230 | return False | ||
| 231 | |||
| 232 | return True | ||
| 233 | |||
| 234 | except Exception as e: | ||
| 235 | logger.error(f"Error in play: {str(e)}", exc_info=True) | ||
| 236 | await ctx.followup.send(f"eror: {str(e)}", ephemeral=True) | ||
| 237 | return False | ||
| 238 | |||
| 239 | @staticmethod | ||
| 240 | async def validate_and_prepare_source(ctx, source): | ||
| 241 | """Validate and prepare source for playback. Returns (source, display_name, is_url) or (None, None, None).""" | ||
| 242 | ( | ||
| 243 | prepared_source, | ||
| 244 | display_name, | ||
| 245 | is_url, | ||
| 246 | ) = await AudioPlaybackManager.prepare_source(ctx, source) | ||
| 247 | if not prepared_source: | ||
| 248 | return None, None, None | ||
| 249 | return prepared_source, display_name, is_url | ||
| 250 | |||
| 251 | @staticmethod | ||
| 252 | async def play(ctx, source, delete_after_play=False, interrupt=True): | ||
| 253 | """Play audio from a local file or URL. | ||
| 254 | |||
| 255 | Args: | ||
| 256 | ctx: Discord context | ||
| 257 | source: Either a local file path or a URL to stream from | ||
| 258 | delete_after_play: If True, delete the file after playing (only for local files) | ||
| 259 | interrupt: If True, stop any currently playing audio. If False, skip if audio is playing. | ||
| 260 | """ | ||
| 261 | # Validate and prepare source | ||
| 262 | ( | ||
| 263 | prepared_source, | ||
| 264 | display_name, | ||
| 265 | is_url, | ||
| 266 | ) = await AudioPlaybackManager.validate_and_prepare_source(ctx, source) | ||
| 267 | if not prepared_source: | ||
| 268 | await AudioPlaybackManager.cleanup_if_needed( | ||
| 269 | source, delete_after_play, False | ||
| 270 | ) | ||
| 271 | return | ||
| 272 | |||
| 273 | # Execute playback | ||
| 274 | success = await AudioPlaybackManager.execute_playback( | ||
| 275 | ctx, prepared_source, display_name, is_url, interrupt | ||
| 276 | ) | ||
| 277 | |||
| 278 | # Cleanup if needed | ||
| 279 | if not success: | ||
| 280 | await AudioPlaybackManager.cleanup_if_needed( | ||
| 281 | prepared_source, delete_after_play, is_url | ||
| 282 | ) | ||
diff --git a/cogs/music/join.py b/cogs/music/join.py index c613b10..d68ca79 100644 --- a/cogs/music/join.py +++ b/cogs/music/join.py | |||
| @@ -6,6 +6,7 @@ | |||
| 6 | import logging | 6 | import logging |
| 7 | 7 | ||
| 8 | from discord.ext import commands | 8 | from discord.ext import commands |
| 9 | from cogs.voice import get_voice_manager | ||
| 9 | 10 | ||
| 10 | logger = logging.getLogger(__name__) | 11 | logger = logging.getLogger(__name__) |
| 11 | 12 | ||
| @@ -16,8 +17,9 @@ class Join(commands.Cog): | |||
| 16 | 17 | ||
| 17 | @commands.slash_command(name="join", description="dołącza do kanalu") | 18 | @commands.slash_command(name="join", description="dołącza do kanalu") |
| 18 | async def join(self, ctx): | 19 | async def join(self, ctx): |
| 20 | voice_mgr = get_voice_manager(self.bot) | ||
| 19 | channel = ctx.author.voice.channel | 21 | channel = ctx.author.voice.channel |
| 20 | await channel.connect() | 22 | await voice_mgr.connect(channel) |
| 21 | await ctx.respond(content="\u200b", ephemeral=True) | 23 | await ctx.respond(content="\u200b", ephemeral=True) |
| 22 | await ctx.interaction.delete_original_response() | 24 | await ctx.interaction.delete_original_response() |
| 23 | logger.info("Bot connected to voice channel") | 25 | logger.info("Bot connected to voice channel") |
diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py index 80a30ef..c6a55b0 100644 --- a/cogs/music/play_commands.py +++ b/cogs/music/play_commands.py | |||
| @@ -17,10 +17,11 @@ import discord | |||
| 17 | from discord.commands import OptionChoice | 17 | from discord.commands import OptionChoice |
| 18 | from discord.ext import commands | 18 | from discord.ext import commands |
| 19 | 19 | ||
| 20 | from cogs.music import audio_playback, media_processing | 20 | from cogs.music import media_processing |
| 21 | from cogs.utils.file_search import UniversalSearcher | 21 | from cogs.utils.file_search import UniversalSearcher |
| 22 | from cogs.utils.settings import get_uploadurl | 22 | from cogs.utils.settings import get_uploadurl |
| 23 | from cogs.utils.url_helper import clean_url_for_ytdlp | 23 | from cogs.utils.url_helper import clean_url_for_ytdlp |
| 24 | from cogs.voice import get_voice_manager | ||
| 24 | 25 | ||
| 25 | logger = logging.getLogger(__name__) | 26 | logger = logging.getLogger(__name__) |
| 26 | 27 | ||
| @@ -163,7 +164,7 @@ class PlayCommands(commands.Cog): | |||
| 163 | if not compressed_path: | 164 | if not compressed_path: |
| 164 | return | 165 | return |
| 165 | 166 | ||
| 166 | await audio_playback.AudioPlaybackManager.play( | 167 | await get_voice_manager(self.bot).play( |
| 167 | ctx, compressed_path, delete_after_play=True | 168 | ctx, compressed_path, delete_after_play=True |
| 168 | ) | 169 | ) |
| 169 | except Exception as e: | 170 | except Exception as e: |
| @@ -210,7 +211,7 @@ class PlayCommands(commands.Cog): | |||
| 210 | 211 | ||
| 211 | upload_task = asyncio.create_task(self._upload_to_copyparty_async(local_path)) | 212 | upload_task = asyncio.create_task(self._upload_to_copyparty_async(local_path)) |
| 212 | 213 | ||
| 213 | await audio_playback.AudioPlaybackManager.play( | 214 | await get_voice_manager(self.bot).play( |
| 214 | ctx, local_path, delete_after_play=True | 215 | ctx, local_path, delete_after_play=True |
| 215 | ) | 216 | ) |
| 216 | 217 | ||
| @@ -266,7 +267,7 @@ class PlayCommands(commands.Cog): | |||
| 266 | if compressed: | 267 | if compressed: |
| 267 | await self._handle_compressed_file_play(ctx, url) | 268 | await self._handle_compressed_file_play(ctx, url) |
| 268 | else: | 269 | else: |
| 269 | await audio_playback.AudioPlaybackManager.play(ctx, url) | 270 | await get_voice_manager(self.bot).play(ctx, url) |
| 270 | 271 | ||
| 271 | @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!") | 272 | @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!") |
| 272 | async def play( | 273 | async def play( |
| @@ -301,7 +302,7 @@ class PlayCommands(commands.Cog): | |||
| 301 | if file: | 302 | if file: |
| 302 | file_path: PathLike[str] | str = os.path.join(self.temp_dir, file.filename) | 303 | file_path: PathLike[str] | str = os.path.join(self.temp_dir, file.filename) |
| 303 | await file.save(file_path) | 304 | await file.save(file_path) |
| 304 | await audio_playback.AudioPlaybackManager.play(ctx, file_path) | 305 | await get_voice_manager(self.bot).play(ctx, file_path) |
| 305 | else: | 306 | else: |
| 306 | try: | 307 | try: |
| 307 | if EXTERNAL_URL_PATTERN.match(melodia.lower()): | 308 | if EXTERNAL_URL_PATTERN.match(melodia.lower()): |
diff --git a/cogs/music/stop.py b/cogs/music/stop.py index f06e7f5..9c8d5cf 100644 --- a/cogs/music/stop.py +++ b/cogs/music/stop.py | |||
| @@ -5,6 +5,8 @@ | |||
| 5 | 5 | ||
| 6 | from discord.ext import commands | 6 | from discord.ext import commands |
| 7 | 7 | ||
| 8 | from cogs.voice import get_voice_manager | ||
| 9 | |||
| 8 | import logging | 10 | import logging |
| 9 | 11 | ||
| 10 | logger = logging.getLogger(__name__) | 12 | logger = logging.getLogger(__name__) |
| @@ -18,7 +20,8 @@ class Stop(commands.Cog): | |||
| 18 | async def stop(self, ctx): | 20 | async def stop(self, ctx): |
| 19 | await ctx.respond(content="\u200b", ephemeral=True) | 21 | await ctx.respond(content="\u200b", ephemeral=True) |
| 20 | await ctx.interaction.delete_original_response() | 22 | await ctx.interaction.delete_original_response() |
| 21 | await ctx.voice_client.disconnect() | 23 | voice_mgr = get_voice_manager(self.bot) |
| 24 | await voice_mgr.disconnect(ctx.guild) | ||
| 22 | logger.info("Bot disconnected from voice channel") | 25 | logger.info("Bot disconnected from voice channel") |
| 23 | 26 | ||
| 24 | 27 | ||
diff --git a/cogs/tts.py b/cogs/tts.py index 68eb27f..ca81d3a 100644 --- a/cogs/tts.py +++ b/cogs/tts.py | |||
| @@ -42,28 +42,26 @@ class TTS(commands.Cog): | |||
| 42 | await ctx.respond(content="\u200b", ephemeral=True) | 42 | await ctx.respond(content="\u200b", ephemeral=True) |
| 43 | await ctx.interaction.delete_original_response() | 43 | await ctx.interaction.delete_original_response() |
| 44 | 44 | ||
| 45 | # Check if the user is in a voice channel | ||
| 46 | if not (ctx.author.voice and ctx.author.voice.channel): | 45 | if not (ctx.author.voice and ctx.author.voice.channel): |
| 47 | logger.info("User is not in a voice channel") | 46 | logger.info("User is not in a voice channel") |
| 48 | return | 47 | return |
| 49 | 48 | ||
| 50 | channel = ctx.author.voice.channel | 49 | channel = ctx.author.voice.channel |
| 51 | try: | 50 | try: |
| 52 | voice_client = await self._get_or_connect_voice_client(channel) | 51 | voice_mgr = self._get_voice_manager() |
| 53 | 52 | ||
| 54 | if not voice_client: | 53 | if voice_mgr.get_voice_client and await voice_mgr.get_client(channel.guild): |
| 55 | await ctx.respond("Failed to connect to voice channel", ephemeral=True) | 54 | vc = await voice_mgr.get_client(channel.guild) |
| 56 | return | 55 | if vc and vc.is_playing(): |
| 57 | 56 | logger.info("Audio already playing, skipping TTS") | |
| 58 | if voice_client.is_playing(): | 57 | return |
| 59 | await ctx.respond("Audio is already playing, try again later", ephemeral=True) | ||
| 60 | return | ||
| 61 | 58 | ||
| 62 | audio_source = discord.FFmpegPCMAudio( | 59 | audio_source = discord.FFmpegPCMAudio( |
| 63 | temp_path, | 60 | temp_path, |
| 64 | options=f'-filter:a "volume={volume}"' | 61 | options=f'-filter:a "volume={volume}"' |
| 65 | ) | 62 | ) |
| 66 | logger.info(f"Playing TTS: {text}") | 63 | logger.info(f"Playing TTS: {text}") |
| 64 | await voice_mgr.play_source(channel, audio_source) | ||
| 67 | 65 | ||
| 68 | except Exception as e: | 66 | except Exception as e: |
| 69 | logger.error(f"Error in voice channel handling: {e}") | 67 | logger.error(f"Error in voice channel handling: {e}") |
diff --git a/cogs/vclogs.py b/cogs/vclogs.py index e5ca202..35826d4 100644 --- a/cogs/vclogs.py +++ b/cogs/vclogs.py | |||
| @@ -116,7 +116,7 @@ class VcLogs(commands.Cog): | |||
| 116 | 116 | ||
| 117 | voice_mgr = self.get_voice_manager() | 117 | voice_mgr = self.get_voice_manager() |
| 118 | temp_dir = get_temp_dir() | 118 | temp_dir = get_temp_dir() |
| 119 | tts_file = os.path.join(temp_dir, f"temp_tts_{os.getpid()}.mp3") | 119 | tts_file = os.path.join(temp_dir, f"temp_tts_{os.getpid()}_{asyncio.get_event_loop().time()}.mp3") |
| 120 | tts = gTTS(text=text, lang='pl') | 120 | tts = gTTS(text=text, lang='pl') |
| 121 | logger.info("saving file") | 121 | logger.info("saving file") |
| 122 | 122 | ||
| @@ -129,6 +129,16 @@ class VcLogs(commands.Cog): | |||
| 129 | is_connected = voice_status[1] if voice_status else False | 129 | is_connected = voice_status[1] if voice_status else False |
| 130 | is_playing = voice_status[2] if voice_status else False | 130 | is_playing = voice_status[2] if voice_status else False |
| 131 | 131 | ||
| 132 | if is_connected: | ||
| 133 | from cogs.voice import is_voice_client_alive | ||
| 134 | if not is_voice_client_alive(voice_client, channel.guild): | ||
| 135 | logger.warning("Stale voice client in play_tts, reconnecting") | ||
| 136 | try: | ||
| 137 | await voice_client.disconnect() | ||
| 138 | except Exception: | ||
| 139 | pass | ||
| 140 | is_connected = False | ||
| 141 | |||
| 132 | if not is_connected: | 142 | if not is_connected: |
| 133 | voice_client = await voice_mgr.join_channel(channel) | 143 | voice_client = await voice_mgr.join_channel(channel) |
| 134 | voice_status = await voice_mgr.get_voice_status(channel.guild) | 144 | voice_status = await voice_mgr.get_voice_status(channel.guild) |
| @@ -150,12 +160,7 @@ class VcLogs(commands.Cog): | |||
| 150 | 160 | ||
| 151 | logger.info("preparing to play audio") | 161 | logger.info("preparing to play audio") |
| 152 | audio_source = discord.FFmpegPCMAudio(tts_file) | 162 | audio_source = discord.FFmpegPCMAudio(tts_file) |
| 153 | if volume != 1.0: | 163 | await voice_mgr.play_source(channel, audio_source, volume=volume) |
| 154 | audio_source = discord.PCMVolumeTransformer(audio_source) | ||
| 155 | audio_source.volume = volume | ||
| 156 | voice_client.play(audio_source) | ||
| 157 | while voice_client.is_playing(): | ||
| 158 | await asyncio.sleep(0.1) | ||
| 159 | except Exception as e: | 164 | except Exception as e: |
| 160 | logger.error(f"TTS playback error: {e}") | 165 | logger.error(f"TTS playback error: {e}") |
| 161 | finally: | 166 | finally: |
diff --git a/cogs/voice.py b/cogs/voice.py index 046c2a8..a224336 100644 --- a/cogs/voice.py +++ b/cogs/voice.py | |||
| @@ -4,12 +4,15 @@ | |||
| 4 | # https://codeberg.org/salmonik/senator-sperma | 4 | # https://codeberg.org/salmonik/senator-sperma |
| 5 | 5 | ||
| 6 | import discord | 6 | import discord |
| 7 | from discord.ext import commands | ||
| 8 | import logging | 7 | import logging |
| 9 | import asyncio | 8 | import asyncio |
| 9 | import os | ||
| 10 | import urllib.parse | ||
| 10 | from dataclasses import dataclass, field | 11 | from dataclasses import dataclass, field |
| 11 | from typing import Any | 12 | from typing import Any |
| 12 | 13 | ||
| 14 | import mutagen | ||
| 15 | |||
| 13 | logger = logging.getLogger(__name__) | 16 | logger = logging.getLogger(__name__) |
| 14 | 17 | ||
| 15 | 18 | ||
| @@ -24,60 +27,171 @@ def get_voice_manager(bot) -> "VoiceManager": | |||
| 24 | return _voice_managers[bot_id] | 27 | return _voice_managers[bot_id] |
| 25 | 28 | ||
| 26 | 29 | ||
| 30 | def is_voice_client_alive(voice_client, guild): | ||
| 31 | """Check if a voice client connection is healthy.""" | ||
| 32 | if voice_client is None: | ||
| 33 | return False | ||
| 34 | if not voice_client.is_connected(): | ||
| 35 | return False | ||
| 36 | if voice_client.channel is None: | ||
| 37 | return False | ||
| 38 | if guild.get_channel(voice_client.channel.id) is None: | ||
| 39 | return False | ||
| 40 | |||
| 41 | try: | ||
| 42 | ws = voice_client._connection | ||
| 43 | s = ws.socket | ||
| 44 | if s is None or s.fileno() == -1: | ||
| 45 | return False | ||
| 46 | except Exception: | ||
| 47 | return False | ||
| 48 | |||
| 49 | return True | ||
| 50 | |||
| 51 | |||
| 27 | @dataclass | 52 | @dataclass |
| 28 | class VoiceManager: | 53 | class VoiceManager: |
| 29 | bot: Any # type: ignore[no-any-import] | 54 | bot: Any |
| 30 | _voice_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False) | 55 | _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False) |
| 56 | _connecting: set[int] = field(default_factory=set, init=False) | ||
| 31 | 57 | ||
| 32 | async def join_channel(self, channel): | 58 | # --- Connection ----------------------------------------------------------- |
| 33 | """Join a voice channel by channel object.""" | 59 | |
| 34 | async with self._voice_lock: | 60 | async def _ensure_voice_client(self, channel): |
| 35 | guild = channel.guild | 61 | """Unified connect/move/reconnect logic. |
| 36 | voice_client = discord.utils.get(self.bot.voice_clients, guild=guild) | 62 | |
| 37 | 63 | Returns a healthy VoiceClient connected to *channel*, or None on failure. | |
| 38 | if voice_client and voice_client.is_connected(): | 64 | """ |
| 39 | if voice_client.channel == channel: | 65 | guild = channel.guild |
| 40 | logger.info(f"Already connected to {channel}") | 66 | guild_id = guild.id |
| 41 | return voice_client | 67 | |
| 42 | try: | 68 | async with self._lock: |
| 43 | await voice_client.move_to(channel) | 69 | if guild_id in self._connecting: |
| 44 | logger.info(f"Moved to voice channel: {channel}") | 70 | logger.info(f"Connection already in progress for guild {guild_id}, skipping") |
| 45 | return voice_client | 71 | return None |
| 46 | except Exception as e: | 72 | |
| 47 | logger.error(f"Failed to move voice client: {e}") | 73 | vc = discord.utils.get(self.bot.voice_clients, guild=guild) |
| 48 | logger.info("Disconnecting after failed move attempt") | 74 | |
| 75 | # --- resolve existing client -------------------------------------- | ||
| 76 | if vc is not None: | ||
| 77 | if vc.is_connected(): | ||
| 78 | if vc.channel == channel: | ||
| 79 | if is_voice_client_alive(vc, guild): | ||
| 80 | logger.info(f"Already connected to {channel}") | ||
| 81 | return vc | ||
| 82 | logger.warning(f"Stale connection to {channel}, reconnecting") | ||
| 83 | # Disconnect resets the state machine to 'disconnected' | ||
| 84 | # but keeps the VoiceClient alive. Reusing it avoids a | ||
| 85 | # race where Discord sends VOICE_SERVER_UPDATE between | ||
| 86 | # the destroy and the fresh-connect below, causing the | ||
| 87 | # new client to timeout waiting for the event (since the | ||
| 88 | # old one already consumed it). | ||
| 89 | try: | ||
| 90 | await vc.disconnect() | ||
| 91 | except Exception: | ||
| 92 | pass | ||
| 93 | try: | ||
| 94 | await vc.connect( | ||
| 95 | reconnect=False, timeout=10.0, self_deaf=False, self_mute=False | ||
| 96 | ) | ||
| 97 | logger.info(f"Reconnected to voice channel: {channel}") | ||
| 98 | return vc | ||
| 99 | except Exception as e: | ||
| 100 | logger.error(f"Failed to reconnect stale voice client: {e}") | ||
| 101 | try: | ||
| 102 | channel._state._remove_voice_client(guild.id) | ||
| 103 | except Exception: | ||
| 104 | pass | ||
| 105 | vc = None | ||
| 106 | else: | ||
| 107 | try: | ||
| 108 | await vc.move_to(channel) | ||
| 109 | logger.info(f"Moved to voice channel: {channel}") | ||
| 110 | return vc | ||
| 111 | except Exception as e: | ||
| 112 | logger.error(f"Failed to move voice client: {e}") | ||
| 113 | try: | ||
| 114 | await vc.disconnect() | ||
| 115 | except Exception: | ||
| 116 | pass | ||
| 117 | try: | ||
| 118 | await vc.connect( | ||
| 119 | reconnect=False, timeout=10.0, self_deaf=False, self_mute=False | ||
| 120 | ) | ||
| 121 | logger.info(f"Reconnected after failed move: {channel}") | ||
| 122 | return vc | ||
| 123 | except Exception: | ||
| 124 | pass | ||
| 125 | try: | ||
| 126 | channel._state._remove_voice_client(guild.id) | ||
| 127 | except Exception: | ||
| 128 | pass | ||
| 129 | vc = None | ||
| 130 | else: | ||
| 131 | logger.info("Voice client exists but disconnected, reconnecting") | ||
| 49 | try: | 132 | try: |
| 50 | await voice_client.disconnect() | 133 | await vc.disconnect() |
| 51 | except Exception: | 134 | except Exception: |
| 52 | pass | 135 | pass |
| 53 | 136 | try: | |
| 54 | await asyncio.sleep(0.3) | 137 | await vc.connect( |
| 55 | try: | 138 | reconnect=False, timeout=10.0, self_deaf=False, self_mute=False |
| 56 | voice_client = await channel.connect(timeout=30.0, reconnect=False) | 139 | ) |
| 57 | logger.info(f"Connected to voice channel: {channel}") | 140 | logger.info(f"Reconnected disconnected client: {channel}") |
| 58 | return voice_client | 141 | return vc |
| 59 | except discord.ClientException as e: | 142 | except Exception as e: |
| 60 | if "Already connected" in str(e): | 143 | logger.error(f"Failed to reconnect disconnected client: {e}") |
| 61 | logger.warning(f"Stale voice state detected, forcing disconnect and reconnect: {e}") | ||
| 62 | voice_client = discord.utils.get(self.bot.voice_clients, guild=guild) | ||
| 63 | if voice_client: | ||
| 64 | try: | 144 | try: |
| 65 | await voice_client.disconnect() | 145 | channel._state._remove_voice_client(guild.id) |
| 66 | except Exception: | 146 | except Exception: |
| 67 | pass | 147 | pass |
| 68 | await asyncio.sleep(0.3) | 148 | vc = None |
| 69 | try: | 149 | |
| 70 | voice_client = await channel.connect(timeout=30.0, reconnect=False) | 150 | # --- create fresh connection -------------------------------------- |
| 71 | logger.info(f"Reconnected to voice channel: {channel}") | 151 | self._connecting.add(guild_id) |
| 72 | return voice_client | 152 | try: |
| 73 | except Exception as e2: | 153 | await asyncio.sleep(0.3) |
| 74 | logger.error(f"Failed to reconnect after stale cleanup: {e2}") | 154 | try: |
| 75 | return None | 155 | vc = await channel.connect(timeout=10.0, reconnect=False) |
| 76 | logger.error(f"Failed to connect to voice channel: {e}") | 156 | logger.info(f"Connected to voice channel: {channel}") |
| 77 | return None | 157 | return vc |
| 78 | except Exception as e: | 158 | except discord.ClientException as e: |
| 79 | logger.error(f"Failed to connect to voice channel: {e}") | 159 | if "Already connected" in str(e): |
| 80 | return None | 160 | logger.warning(f"Stale voice state detected, forcing reconnect: {e}") |
| 161 | vc = discord.utils.get(self.bot.voice_clients, guild=guild) | ||
| 162 | if vc: | ||
| 163 | try: | ||
| 164 | await vc.disconnect() | ||
| 165 | except Exception: | ||
| 166 | pass | ||
| 167 | else: | ||
| 168 | try: | ||
| 169 | channel._state._remove_voice_client(guild.id) | ||
| 170 | except Exception: | ||
| 171 | pass | ||
| 172 | await asyncio.sleep(0.3) | ||
| 173 | try: | ||
| 174 | vc = await channel.connect(timeout=10.0, reconnect=False) | ||
| 175 | logger.info(f"Reconnected to voice channel: {channel}") | ||
| 176 | return vc | ||
| 177 | except Exception as e2: | ||
| 178 | logger.error(f"Failed to reconnect after stale cleanup: {e2}") | ||
| 179 | return None | ||
| 180 | logger.error(f"Failed to connect to voice channel: {e}") | ||
| 181 | return None | ||
| 182 | except Exception as e: | ||
| 183 | logger.error(f"Failed to connect to voice channel: {e}") | ||
| 184 | return None | ||
| 185 | finally: | ||
| 186 | self._connecting.discard(guild_id) | ||
| 187 | |||
| 188 | async def connect(self, channel): | ||
| 189 | """Join a voice channel (public alias).""" | ||
| 190 | return await self._ensure_voice_client(channel) | ||
| 191 | |||
| 192 | async def join_channel(self, channel): | ||
| 193 | """Backward-compatible alias for connect().""" | ||
| 194 | return await self.connect(channel) | ||
| 81 | 195 | ||
| 82 | async def join_by_channel_id(self, channel_id): | 196 | async def join_by_channel_id(self, channel_id): |
| 83 | """Join a voice channel by channel ID.""" | 197 | """Join a voice channel by channel ID.""" |
| @@ -88,135 +202,243 @@ class VoiceManager: | |||
| 88 | if not isinstance(channel, discord.VoiceChannel): | 202 | if not isinstance(channel, discord.VoiceChannel): |
| 89 | logger.error(f"Channel {channel_id} is not a voice channel") | 203 | logger.error(f"Channel {channel_id} is not a voice channel") |
| 90 | return None | 204 | return None |
| 91 | return await self.join_channel(channel) | 205 | return await self.connect(channel) |
| 92 | 206 | ||
| 93 | async def get_voice_client(self, guild): | 207 | async def get_client(self, guild): |
| 94 | """Get existing voice client for a guild.""" | 208 | """Get existing voice client for a guild.""" |
| 95 | return discord.utils.get(self.bot.voice_clients, guild=guild) | 209 | return discord.utils.get(self.bot.voice_clients, guild=guild) |
| 96 | 210 | ||
| 97 | async def get_voice_status(self, guild): | 211 | async def get_voice_client(self, guild): |
| 98 | """Get voice client status including connection and playback state. | 212 | """Backward-compatible alias for get_client().""" |
| 213 | return await self.get_client(guild) | ||
| 214 | |||
| 215 | async def get_status(self, guild): | ||
| 216 | """Get voice client status. | ||
| 99 | 217 | ||
| 100 | Returns: | 218 | Returns: |
| 101 | tuple: (voice_client, is_connected, is_playing) or (None, False, False) if no client | 219 | tuple: (voice_client, is_connected, is_playing) or (None, False, False) |
| 102 | """ | 220 | """ |
| 103 | voice_client = await self.get_voice_client(guild) | 221 | vc = await self.get_client(guild) |
| 104 | if voice_client is None: | 222 | if vc is None: |
| 105 | return None, False, False | 223 | return None, False, False |
| 224 | return vc, vc.is_connected(), vc.is_playing() | ||
| 106 | 225 | ||
| 107 | is_connected = voice_client.is_connected() | 226 | async def get_voice_status(self, guild): |
| 108 | is_playing = voice_client.is_playing() if is_connected else False | 227 | """Backward-compatible alias for get_status().""" |
| 228 | return await self.get_status(guild) | ||
| 229 | |||
| 230 | async def disconnect(self, guild): | ||
| 231 | """Stop playback and disconnect from voice.""" | ||
| 232 | vc = await self.get_client(guild) | ||
| 233 | if vc: | ||
| 234 | vc.stop() | ||
| 235 | logger.info("Disconnecting from voice") | ||
| 236 | await vc.disconnect() | ||
| 237 | logger.info(f"Disconnected from guild {guild}") | ||
| 109 | 238 | ||
| 110 | return voice_client, is_connected, is_playing | 239 | async def stop(self, guild): |
| 240 | """Backward-compatible alias for disconnect().""" | ||
| 241 | await self.disconnect(guild) | ||
| 111 | 242 | ||
| 112 | async def play_audio(self, channel, audio_source, volume=1.0): | 243 | # --- Playback ------------------------------------------------------------ |
| 113 | """Play audio in a voice channel.""" | ||
| 114 | voice_client = await self.join_channel(channel) | ||
| 115 | if not voice_client: | ||
| 116 | return False | ||
| 117 | 244 | ||
| 118 | if abs(volume - 1.0) > 1e-9: | 245 | async def _play_await(self, vc, audio_source): |
| 119 | audio_source = discord.PCMVolumeTransformer(audio_source) | 246 | """Play an audio source and wait for completion using an asyncio.Future. |
| 120 | audio_source.volume = volume | 247 | |
| 248 | Returns True on success, False on error. No polling, no magic sleeps. | ||
| 249 | """ | ||
| 250 | loop = asyncio.get_event_loop() | ||
| 251 | future = loop.create_future() | ||
| 252 | |||
| 253 | def after(error): | ||
| 254 | if not future.done(): | ||
| 255 | if error: | ||
| 256 | future.set_exception(Exception(str(error))) | ||
| 257 | else: | ||
| 258 | future.set_result(True) | ||
| 259 | |||
| 260 | vc.play(audio_source, after=after) | ||
| 121 | 261 | ||
| 122 | try: | 262 | try: |
| 123 | voice_client.play(audio_source) | 263 | await future |
| 124 | while voice_client.is_playing(): | ||
| 125 | await asyncio.sleep(0.1) | ||
| 126 | return True | 264 | return True |
| 127 | except Exception as e: | 265 | except Exception as e: |
| 128 | logger.error(f"Error playing audio: {e}") | 266 | logger.error(f"Playback error: {e}") |
| 129 | return False | 267 | return False |
| 130 | 268 | ||
| 131 | async def stop(self, guild): | 269 | async def play_source(self, channel, audio_source, interrupt=True, volume=1.0): |
| 132 | """Stop playing and disconnect from voice.""" | 270 | """Connect to *channel* and play a pre-made audio source. |
| 133 | voice_client = await self.get_voice_client(guild) | ||
| 134 | if voice_client: | ||
| 135 | voice_client.stop() | ||
| 136 | logger.info("stop() called - disconnecting from voice") | ||
| 137 | await voice_client.disconnect() | ||
| 138 | logger.info(f"Stopped and disconnected from guild {guild}") | ||
| 139 | 271 | ||
| 272 | Returns True on success, False on failure. | ||
| 273 | """ | ||
| 274 | vc = await self.connect(channel) | ||
| 275 | if not vc: | ||
| 276 | logger.error("play_source: could not connect to voice channel") | ||
| 277 | return False | ||
| 140 | 278 | ||
| 141 | class Voice(commands.Cog): | 279 | if interrupt and (vc.is_playing() or vc.is_paused()): |
| 142 | def __init__(self, bot): | 280 | vc.stop() |
| 143 | self.bot = bot | ||
| 144 | self._voice_lock = asyncio.Lock() | ||
| 145 | 281 | ||
| 146 | async def _validate_user_voice_state(self, ctx): | 282 | if abs(volume - 1.0) > 1e-9: |
| 147 | if not ctx.author.voice or not ctx.author.voice.channel: | 283 | audio_source = discord.PCMVolumeTransformer(audio_source) |
| 148 | await ctx.send("You need to be in a voice channel first!") | 284 | audio_source.volume = volume |
| 149 | return False | ||
| 150 | return True | ||
| 151 | 285 | ||
| 152 | async def _create_voice_client(self, ctx): | 286 | logger.info("Playing via play_source") |
| 153 | async with self._voice_lock: | 287 | return await self._play_await(vc, audio_source) |
| 154 | try: | ||
| 155 | await asyncio.sleep(0.5) | ||
| 156 | voice_client = await ctx.author.voice.channel.connect(timeout=30.0, reconnect=False) | ||
| 157 | logger.info(f"Connected to voice channel: {ctx.author.voice.channel}") | ||
| 158 | return voice_client | ||
| 159 | except asyncio.TimeoutError: | ||
| 160 | await ctx.send("Connection to voice channel timed out.") | ||
| 161 | return None | ||
| 162 | except discord.ClientException as e: | ||
| 163 | logger.error(f"Failed to connect to voice channel: {e}") | ||
| 164 | await ctx.send("Failed to connect to voice channel. Please try again.", ephemeral=True) | ||
| 165 | return None | ||
| 166 | 288 | ||
| 167 | async def _move_voice_client(self, voice_client, ctx): | 289 | # --- Source preparation (folded from AudioPlaybackManager) --------------- |
| 168 | try: | 290 | |
| 169 | await voice_client.move_to(ctx.author.voice.channel) | 291 | @staticmethod |
| 170 | logger.info(f"Moved to voice channel: {ctx.author.voice.channel}") | 292 | async def validate_voice_channel(ctx): |
| 171 | return voice_client | 293 | """Validate user is in a voice channel. Returns the channel or None.""" |
| 172 | except asyncio.TimeoutError: | 294 | if not ctx.author.voice or not ctx.author.voice.channel: |
| 173 | await ctx.send("Connection to voice channel timed out.") | 295 | await ctx.respond( |
| 174 | return None | 296 | "Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True |
| 175 | except discord.ClientException as e: | 297 | ) |
| 176 | logger.error(f"Failed to move to voice channel: {e}") | ||
| 177 | await ctx.send("Failed to move to your voice channel. Please try again.", ephemeral=True) | ||
| 178 | return None | 298 | return None |
| 299 | return ctx.author.voice.channel | ||
| 179 | 300 | ||
| 180 | async def _verify_connection(self, voice_client): | 301 | @staticmethod |
| 181 | if not voice_client.is_connected(): | 302 | async def prepare_file_source(source): |
| 303 | """Validate a local file and return (display_name, absolute_path) or (None, None).""" | ||
| 304 | logger.info(f"Attempting to play local file: {source}") | ||
| 305 | |||
| 306 | if not os.path.isabs(source): | ||
| 307 | source = os.path.abspath(source) | ||
| 308 | logger.debug(f"Converted to absolute path: {source}") | ||
| 309 | |||
| 310 | if not os.path.exists(source): | ||
| 311 | logger.error(f"File not found: {source}") | ||
| 312 | return None, None | ||
| 313 | |||
| 314 | filename = os.path.basename(source) | ||
| 315 | display_name = os.path.splitext(filename)[0] | ||
| 316 | return display_name, source | ||
| 317 | |||
| 318 | @staticmethod | ||
| 319 | async def prepare_audio_source(source, is_url, display_name): | ||
| 320 | """Prepare FFmpegPCMAudio from a URL or local file. | ||
| 321 | |||
| 322 | Returns (audio_source, final_display_name). | ||
| 323 | """ | ||
| 324 | if is_url: | ||
| 325 | before_options = "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5" | ||
| 326 | audio_source = discord.FFmpegPCMAudio(source, before_options=before_options) | ||
| 327 | filename = os.path.basename(source.split("?")[0]) | ||
| 328 | filename = urllib.parse.unquote(filename) | ||
| 329 | clean_filename = os.path.splitext(filename)[0] | ||
| 330 | return audio_source, clean_filename | ||
| 331 | else: | ||
| 332 | audio_source = discord.FFmpegPCMAudio(source=source) | ||
| 182 | try: | 333 | try: |
| 183 | await voice_client.connect(reconnect=False, timeout=10.0) | 334 | metadata = mutagen.open(source) |
| 184 | return voice_client | 335 | if metadata: |
| 336 | title = ( | ||
| 337 | metadata.get("title", [None])[0] | ||
| 338 | if hasattr(metadata, "get") | ||
| 339 | else None | ||
| 340 | ) | ||
| 341 | if title: | ||
| 342 | display_name = title | ||
| 343 | logger.debug(f"Read metadata - title: {title}") | ||
| 344 | else: | ||
| 345 | display_name = os.path.splitext(os.path.basename(source))[0] | ||
| 185 | except Exception as e: | 346 | except Exception as e: |
| 186 | logger.error(f"Failed to reconnect to voice: {e}") | 347 | logger.warning(f"Could not read metadata from {source}: {str(e)}") |
| 187 | return None | 348 | display_name = os.path.splitext(os.path.basename(source))[0] |
| 188 | return voice_client | ||
| 189 | 349 | ||
| 190 | async def ensure_voice_client(self, ctx): | 350 | return audio_source, display_name |
| 191 | if not await self._validate_user_voice_state(ctx): | 351 | |
| 192 | return None | 352 | @staticmethod |
| 353 | async def prepare_source(source): | ||
| 354 | """Determine whether *source* is a URL or local file. | ||
| 355 | |||
| 356 | Returns (source_path, display_name, is_url) or (None, None, None) on failure. | ||
| 357 | """ | ||
| 358 | is_url = source.startswith(("http://", "https://")) | ||
| 359 | |||
| 360 | if is_url: | ||
| 361 | logger.info(f"Attempting to play stream from URL: {source}") | ||
| 362 | return source, source, is_url | ||
| 193 | 363 | ||
| 194 | voice_client = discord.utils.get(self.bot.voice_clients, guild=ctx.guild) | 364 | display_name, absolute_source = await VoiceManager.prepare_file_source(source) |
| 365 | if not display_name: | ||
| 366 | return None, None, None | ||
| 195 | 367 | ||
| 196 | if voice_client and not voice_client.is_connected(): | 368 | return absolute_source, display_name, is_url |
| 369 | |||
| 370 | @staticmethod | ||
| 371 | async def cleanup_temp_file(source): | ||
| 372 | """Remove a temporary file if it exists.""" | ||
| 373 | if source and os.path.exists(source): | ||
| 197 | try: | 374 | try: |
| 198 | logger.info("Voice client exists but not connected - disconnecting stale client") | 375 | os.remove(source) |
| 199 | await voice_client.disconnect() | 376 | logger.info(f"Cleaned up temporary file: {source}") |
| 200 | except Exception: | 377 | except Exception as e: |
| 201 | pass | 378 | logger.error(f"Error cleaning up file {source}: {str(e)}") |
| 202 | voice_client = None | ||
| 203 | 379 | ||
| 204 | if not voice_client or not voice_client.is_connected(): | 380 | @staticmethod |
| 205 | if not await self._validate_user_voice_state(ctx): | 381 | async def cleanup_if_needed(source, delete_after_play, is_url): |
| 206 | return None | 382 | """Conditionally clean up a file after playback.""" |
| 207 | return await self._create_voice_client(ctx) | 383 | if delete_after_play and not is_url: |
| 384 | await VoiceManager.cleanup_temp_file(source) | ||
| 208 | 385 | ||
| 209 | if voice_client.channel != ctx.author.voice.channel: | 386 | # --- High-level play (full flow) ----------------------------------------- |
| 210 | return await self._move_voice_client(voice_client, ctx) | ||
| 211 | 387 | ||
| 212 | return await self._verify_connection(voice_client) | 388 | async def play(self, ctx, source, delete_after_play=False, interrupt=True): |
| 389 | """High-level playback: validate context, prepare source, connect, play, cleanup. | ||
| 213 | 390 | ||
| 214 | async def connect(self, ctx): | 391 | Sends ``grom: {name}`` immediately on start, and an error follow-up |
| 215 | voice_client = await self.ensure_voice_client(ctx) | 392 | if playback fails. |
| 216 | if not voice_client: | 393 | |
| 217 | return None | 394 | Args: |
| 218 | return voice_client | 395 | ctx: Discord application context (used for channel resolution & error messages). |
| 396 | source: Local file path or URL to stream. | ||
| 397 | delete_after_play: Remove local file after playback (no-op for URLs). | ||
| 398 | interrupt: Stop currently playing audio before starting. | ||
| 399 | |||
| 400 | Returns: | ||
| 401 | bool: True if playback completed successfully, False otherwise. | ||
| 402 | """ | ||
| 403 | prepared_source, display_name, is_url = await VoiceManager.prepare_source(source) | ||
| 404 | if not prepared_source: | ||
| 405 | await VoiceManager.cleanup_if_needed(source, delete_after_play, False) | ||
| 406 | return False | ||
| 407 | |||
| 408 | target_channel = await VoiceManager.validate_voice_channel(ctx) | ||
| 409 | if not target_channel: | ||
| 410 | await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) | ||
| 411 | return False | ||
| 412 | |||
| 413 | vc = await self.connect(target_channel) | ||
| 414 | if not vc: | ||
| 415 | await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) | ||
| 416 | return False | ||
| 417 | |||
| 418 | if interrupt and (vc.is_playing() or vc.is_paused()): | ||
| 419 | vc.stop() | ||
| 420 | |||
| 421 | audio_source, final_display_name = await VoiceManager.prepare_audio_source( | ||
| 422 | prepared_source, is_url, display_name | ||
| 423 | ) | ||
| 424 | |||
| 425 | try: | ||
| 426 | await ctx.followup.send(f"grom: {final_display_name}") | ||
| 427 | except Exception as e: | ||
| 428 | logger.error(e) | ||
| 429 | |||
| 430 | success = await self._play_await(vc, audio_source) | ||
| 219 | 431 | ||
| 432 | if success: | ||
| 433 | logger.info(f"Playback completed: {final_display_name}") | ||
| 434 | else: | ||
| 435 | logger.error(f"Playback failed: {prepared_source}") | ||
| 436 | try: | ||
| 437 | await ctx.followup.send( | ||
| 438 | "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True | ||
| 439 | ) | ||
| 440 | except Exception: | ||
| 441 | pass | ||
| 442 | await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) | ||
| 220 | 443 | ||
| 221 | def setup(bot): | 444 | return success |
| 222 | bot.add_cog(Voice(bot)) | ||
