diff options
Diffstat (limited to '')
| -rw-r--r-- | AGENTS.md | 2 | ||||
| -rw-r--r-- | README.md | 2 | ||||
| -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 | ||||
| -rw-r--r-- | main.py | 1 | ||||
| -rw-r--r-- | requirements-dev.txt | 55 | ||||
| -rw-r--r-- | requirements.txt | 49 | ||||
| -rw-r--r-- | scripts/bump_reqs.py | 50 | ||||
| -rw-r--r-- | tests/cogs/test_music_audio.py | 20 | ||||
| -rw-r--r-- | tests/cogs/test_music_integration.py | 26 | ||||
| -rw-r--r-- | tests/cogs/test_music_join.py | 25 | ||||
| -rw-r--r-- | tests/cogs/test_voice.py | 4 |
20 files changed, 532 insertions, 688 deletions
| @@ -1,7 +1,7 @@ | |||
| 1 | # Agent Instructions for Senator Sperma | 1 | # Agent Instructions for Senator Sperma |
| 2 | 2 | ||
| 3 | ## Required Setup | 3 | ## Required Setup |
| 4 | - Requires **Python 3.13** (3.14 not supported - py-cord incompatibility) | 4 | - Requires **Python 3.14** |
| 5 | - System dependencies: `ffmpeg`, `zbar` library (installed system-wide) | 5 | - System dependencies: `ffmpeg`, `zbar` library (installed system-wide) |
| 6 | - Bot token in `.env` file (copy from `.env.example`) | 6 | - Bot token in `.env` file (copy from `.env.example`) |
| 7 | 7 | ||
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | A Discord bot with music playback, TTS, web search, and utility commands. | 3 | A Discord bot with music playback, TTS, web search, and utility commands. |
| 4 | 4 | ||
| 5 | Requires Python 3.13 (py-cord doesn't support 3.14 yet). | 5 | Requires Python 3.14. |
| 6 | 6 | ||
| 7 | ## Setup | 7 | ## Setup |
| 8 | 8 | ||
| @@ -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)) | ||
| @@ -75,7 +75,6 @@ if __name__ == "__main__": | |||
| 75 | "cogs.trigger", | 75 | "cogs.trigger", |
| 76 | "cogs.cron", | 76 | "cogs.cron", |
| 77 | "cogs.qr", | 77 | "cogs.qr", |
| 78 | "cogs.voice", | ||
| 79 | "cogs.uploader", | 78 | "cogs.uploader", |
| 80 | ] | 79 | ] |
| 81 | 80 | ||
diff --git a/requirements-dev.txt b/requirements-dev.txt index abba086..ed5f5cc 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt | |||
| @@ -1,59 +1,18 @@ | |||
| 1 | pytest==8.3.4 | 1 | pytest==9.0.3 |
| 2 | pytest-asyncio==0.25.2 | 2 | pytest-asyncio==1.3.0 |
| 3 | pytest-mock==3.14.0 | 3 | basedpyright==1.39.5 |
| 4 | aiofiles==25.1.0 | 4 | aiofiles==25.1.0 |
| 5 | aiohappyeyeballs==2.6.1 | 5 | aiohttp==3.13.5 |
| 6 | aiohttp==3.13.3 | ||
| 7 | aiosignal==1.4.0 | ||
| 8 | anyio==4.12.1 | ||
| 9 | attrs==25.4.0 | ||
| 10 | basedpyright==1.39.2 | ||
| 11 | beautifulsoup4==4.14.3 | 6 | beautifulsoup4==4.14.3 |
| 12 | brotli==1.2.0 | ||
| 13 | bs4==0.0.2 | ||
| 14 | certifi==2026.2.25 | ||
| 15 | cffi==2.0.0 | ||
| 16 | charset-normalizer==3.4.6 | ||
| 17 | click==8.1.8 | ||
| 18 | contourpy==1.3.3 | ||
| 19 | croniter==6.2.2 | 7 | croniter==6.2.2 |
| 20 | cycler==0.12.1 | ||
| 21 | davey==0.1.4 | ||
| 22 | dotenv==0.9.9 | ||
| 23 | fonttools==4.62.1 | ||
| 24 | frozenlist==1.8.0 | ||
| 25 | gTTS==2.5.4 | 8 | gTTS==2.5.4 |
| 26 | h11==0.16.0 | ||
| 27 | httpcore==1.0.9 | ||
| 28 | httpx==0.28.1 | 9 | httpx==0.28.1 |
| 29 | idna==3.11 | ||
| 30 | kiwisolver==1.5.0 | ||
| 31 | matplotlib==3.10.8 | ||
| 32 | multidict==6.7.1 | ||
| 33 | mutagen==1.47.0 | 10 | mutagen==1.47.0 |
| 34 | nodejs-wheel-binaries==24.14.1 | ||
| 35 | numpy==2.4.4 | ||
| 36 | packaging==26.1 | ||
| 37 | pillow==12.2.0 | 11 | pillow==12.2.0 |
| 38 | propcache==0.4.1 | 12 | py-cord==2.8.0 |
| 39 | py-cord @ file:///home/salmon/Documents/botcode/senator-sperma/pycord | ||
| 40 | pycparser==3.0 | ||
| 41 | pycryptodomex==3.23.0 | ||
| 42 | PyNaCl==1.6.2 | ||
| 43 | pyparsing==3.3.2 | ||
| 44 | python-dateutil==2.9.0.post0 | ||
| 45 | python-dotenv==1.2.2 | 13 | python-dotenv==1.2.2 |
| 46 | pytube==15.0.0 | ||
| 47 | pyzbar==0.1.9 | 14 | pyzbar==0.1.9 |
| 48 | qrcode==8.2 | 15 | qrcode==8.2 |
| 49 | RapidFuzz==3.14.3 | 16 | requests==2.34.2 |
| 50 | requests==2.32.5 | ||
| 51 | six==1.17.0 | ||
| 52 | soupsieve==2.8.3 | ||
| 53 | typing_extensions==4.15.0 | ||
| 54 | urllib3==2.6.3 | ||
| 55 | websockets==16.0 | ||
| 56 | yarl==1.23.0 | ||
| 57 | youtube-search-python==1.6.6 | ||
| 58 | yt-dlp==2026.3.17 | 17 | yt-dlp==2026.3.17 |
| 59 | yt-dlp-ejs==0.8.0 | 18 | youtube-search-python==1.6.6 |
diff --git a/requirements.txt b/requirements.txt index 81f00ef..5fb918e 100644 --- a/requirements.txt +++ b/requirements.txt | |||
| @@ -1,56 +1,15 @@ | |||
| 1 | aiofiles==25.1.0 | 1 | aiofiles==25.1.0 |
| 2 | aiohappyeyeballs==2.6.1 | 2 | aiohttp==3.13.5 |
| 3 | aiohttp==3.13.3 | ||
| 4 | aiosignal==1.4.0 | ||
| 5 | anyio==4.12.1 | ||
| 6 | attrs==25.4.0 | ||
| 7 | basedpyright==1.39.2 | ||
| 8 | beautifulsoup4==4.14.3 | 3 | beautifulsoup4==4.14.3 |
| 9 | brotli==1.2.0 | ||
| 10 | bs4==0.0.2 | ||
| 11 | certifi==2026.2.25 | ||
| 12 | cffi==2.0.0 | ||
| 13 | charset-normalizer==3.4.6 | ||
| 14 | click==8.1.8 | ||
| 15 | contourpy==1.3.3 | ||
| 16 | croniter==6.2.2 | 4 | croniter==6.2.2 |
| 17 | cycler==0.12.1 | ||
| 18 | davey==0.1.4 | ||
| 19 | dotenv==0.9.9 | ||
| 20 | fonttools==4.62.1 | ||
| 21 | frozenlist==1.8.0 | ||
| 22 | gTTS==2.5.4 | 5 | gTTS==2.5.4 |
| 23 | h11==0.16.0 | ||
| 24 | httpcore==1.0.9 | ||
| 25 | httpx==0.28.1 | 6 | httpx==0.28.1 |
| 26 | idna==3.11 | ||
| 27 | kiwisolver==1.5.0 | ||
| 28 | matplotlib==3.10.8 | ||
| 29 | multidict==6.7.1 | ||
| 30 | mutagen==1.47.0 | 7 | mutagen==1.47.0 |
| 31 | nodejs-wheel-binaries==24.14.1 | ||
| 32 | numpy==2.4.4 | ||
| 33 | packaging==26.1 | ||
| 34 | pillow==12.2.0 | 8 | pillow==12.2.0 |
| 35 | propcache==0.4.1 | 9 | py-cord==2.8.0 |
| 36 | py-cord @ file:///home/salmon/Documents/botcode/senator-sperma/pycord | ||
| 37 | pycparser==3.0 | ||
| 38 | pycryptodomex==3.23.0 | ||
| 39 | PyNaCl==1.6.2 | ||
| 40 | pyparsing==3.3.2 | ||
| 41 | python-dateutil==2.9.0.post0 | ||
| 42 | python-dotenv==1.2.2 | 10 | python-dotenv==1.2.2 |
| 43 | pytube==15.0.0 | ||
| 44 | pyzbar==0.1.9 | 11 | pyzbar==0.1.9 |
| 45 | qrcode==8.2 | 12 | qrcode==8.2 |
| 46 | RapidFuzz==3.14.3 | 13 | requests==2.34.2 |
| 47 | requests==2.32.5 | ||
| 48 | six==1.17.0 | ||
| 49 | soupsieve==2.8.3 | ||
| 50 | typing_extensions==4.15.0 | ||
| 51 | urllib3==2.6.3 | ||
| 52 | websockets==16.0 | ||
| 53 | yarl==1.23.0 | ||
| 54 | youtube-search-python==1.6.6 | ||
| 55 | yt-dlp==2026.3.17 | 14 | yt-dlp==2026.3.17 |
| 56 | yt-dlp-ejs==0.8.0 | 15 | youtube-search-python==1.6.6 |
diff --git a/scripts/bump_reqs.py b/scripts/bump_reqs.py new file mode 100644 index 0000000..5d253e7 --- /dev/null +++ b/scripts/bump_reqs.py | |||
| @@ -0,0 +1,50 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Bump all pinned versions in requirements*.txt to latest from PyPI.""" | ||
| 3 | |||
| 4 | import json | ||
| 5 | import re | ||
| 6 | import urllib.request | ||
| 7 | from pathlib import Path | ||
| 8 | |||
| 9 | REQS = sorted(Path().glob("requirements*.txt")) | ||
| 10 | PYPI = "https://pypi.org/pypi/{}/json" | ||
| 11 | |||
| 12 | |||
| 13 | def latest_version(package: str) -> str | None: | ||
| 14 | url = PYPI.format(package) | ||
| 15 | try: | ||
| 16 | with urllib.request.urlopen(url, timeout=10) as resp: | ||
| 17 | data = json.load(resp) | ||
| 18 | return data["info"]["version"] | ||
| 19 | except Exception: | ||
| 20 | return None | ||
| 21 | |||
| 22 | |||
| 23 | def bump_file(path: Path): | ||
| 24 | lines = path.read_text().splitlines() | ||
| 25 | changed = [] | ||
| 26 | for line in lines: | ||
| 27 | m = re.match(r"^([\w-]+(?:\[[\w-]+])?)==[\w.]+$", line) | ||
| 28 | if m: | ||
| 29 | pkg = m.group(1) | ||
| 30 | latest = latest_version(pkg) | ||
| 31 | if latest: | ||
| 32 | changed.append(f"{pkg}=={latest}") | ||
| 33 | if f"{pkg}=={latest}" != line: | ||
| 34 | print(f" {pkg}: {line.split('==')[1]} -> {latest}") | ||
| 35 | else: | ||
| 36 | changed.append(line) | ||
| 37 | print(f" {pkg}: failed to fetch, keeping current") | ||
| 38 | else: | ||
| 39 | changed.append(line) | ||
| 40 | path.write_text("\n".join(changed) + "\n") | ||
| 41 | |||
| 42 | |||
| 43 | def main(): | ||
| 44 | for req in REQS: | ||
| 45 | print(f"\n{req}:") | ||
| 46 | bump_file(req) | ||
| 47 | |||
| 48 | |||
| 49 | if __name__ == "__main__": | ||
| 50 | main() | ||
diff --git a/tests/cogs/test_music_audio.py b/tests/cogs/test_music_audio.py index 705f1b3..98c6f6b 100644 --- a/tests/cogs/test_music_audio.py +++ b/tests/cogs/test_music_audio.py | |||
| @@ -6,7 +6,7 @@ from conftest import MockApplicationContext | |||
| 6 | 6 | ||
| 7 | @pytest.mark.asyncio | 7 | @pytest.mark.asyncio |
| 8 | async def test_validate_voice_channel_not_in_voice(): | 8 | async def test_validate_voice_channel_not_in_voice(): |
| 9 | from cogs.music.audio_playback import AudioPlaybackManager | 9 | from cogs.voice import VoiceManager as AudioPlaybackManager |
| 10 | 10 | ||
| 11 | mock_ctx = MagicMock() | 11 | mock_ctx = MagicMock() |
| 12 | mock_ctx.author.voice = None | 12 | mock_ctx.author.voice = None |
| @@ -20,7 +20,7 @@ async def test_validate_voice_channel_not_in_voice(): | |||
| 20 | 20 | ||
| 21 | @pytest.mark.asyncio | 21 | @pytest.mark.asyncio |
| 22 | async def test_validate_voice_channel_in_voice(): | 22 | async def test_validate_voice_channel_in_voice(): |
| 23 | from cogs.music.audio_playback import AudioPlaybackManager | 23 | from cogs.voice import VoiceManager as AudioPlaybackManager |
| 24 | 24 | ||
| 25 | mock_channel = MagicMock() | 25 | mock_channel = MagicMock() |
| 26 | mock_ctx = MagicMock() | 26 | mock_ctx = MagicMock() |
| @@ -35,7 +35,7 @@ async def test_validate_voice_channel_in_voice(): | |||
| 35 | 35 | ||
| 36 | @pytest.mark.asyncio | 36 | @pytest.mark.asyncio |
| 37 | async def test_prepare_file_source_file_not_found(): | 37 | async def test_prepare_file_source_file_not_found(): |
| 38 | from cogs.music.audio_playback import AudioPlaybackManager | 38 | from cogs.voice import VoiceManager as AudioPlaybackManager |
| 39 | 39 | ||
| 40 | display_name, absolute_path = await AudioPlaybackManager.prepare_file_source("/nonexistent/file.mp3") | 40 | display_name, absolute_path = await AudioPlaybackManager.prepare_file_source("/nonexistent/file.mp3") |
| 41 | 41 | ||
| @@ -45,7 +45,7 @@ async def test_prepare_file_source_file_not_found(): | |||
| 45 | 45 | ||
| 46 | @pytest.mark.asyncio | 46 | @pytest.mark.asyncio |
| 47 | async def test_prepare_file_source_absolute_path(): | 47 | async def test_prepare_file_source_absolute_path(): |
| 48 | from cogs.music.audio_playback import AudioPlaybackManager | 48 | from cogs.voice import VoiceManager as AudioPlaybackManager |
| 49 | 49 | ||
| 50 | with patch('os.path.exists', return_value=True): | 50 | with patch('os.path.exists', return_value=True): |
| 51 | with patch('os.path.isabs', return_value=True): | 51 | with patch('os.path.isabs', return_value=True): |
| @@ -57,9 +57,9 @@ async def test_prepare_file_source_absolute_path(): | |||
| 57 | 57 | ||
| 58 | @pytest.mark.asyncio | 58 | @pytest.mark.asyncio |
| 59 | async def test_prepare_source_url(): | 59 | async def test_prepare_source_url(): |
| 60 | from cogs.music.audio_playback import AudioPlaybackManager | 60 | from cogs.voice import VoiceManager as AudioPlaybackManager |
| 61 | 61 | ||
| 62 | source, _, is_url = await AudioPlaybackManager.prepare_source(MagicMock(), "https://example.com/stream") | 62 | source, _, is_url = await AudioPlaybackManager.prepare_source("https://example.com/stream") |
| 63 | 63 | ||
| 64 | assert is_url == True | 64 | assert is_url == True |
| 65 | assert source == "https://example.com/stream" | 65 | assert source == "https://example.com/stream" |
| @@ -67,10 +67,10 @@ async def test_prepare_source_url(): | |||
| 67 | 67 | ||
| 68 | @pytest.mark.asyncio | 68 | @pytest.mark.asyncio |
| 69 | async def test_prepare_source_local_file(): | 69 | async def test_prepare_source_local_file(): |
| 70 | from cogs.music.audio_playback import AudioPlaybackManager | 70 | from cogs.voice import VoiceManager as AudioPlaybackManager |
| 71 | 71 | ||
| 72 | with patch('os.path.exists', return_value=True): | 72 | with patch('os.path.exists', return_value=True): |
| 73 | source, display_name, is_url = await AudioPlaybackManager.prepare_source(MagicMock(), "/tmp/test.mp3") | 73 | source, display_name, is_url = await AudioPlaybackManager.prepare_source("/tmp/test.mp3") |
| 74 | 74 | ||
| 75 | assert is_url == False | 75 | assert is_url == False |
| 76 | assert display_name == "test" | 76 | assert display_name == "test" |
| @@ -78,7 +78,7 @@ async def test_prepare_source_local_file(): | |||
| 78 | 78 | ||
| 79 | @pytest.mark.asyncio | 79 | @pytest.mark.asyncio |
| 80 | async def test_cleanup_temp_file(): | 80 | async def test_cleanup_temp_file(): |
| 81 | from cogs.music.audio_playback import AudioPlaybackManager | 81 | from cogs.voice import VoiceManager as AudioPlaybackManager |
| 82 | 82 | ||
| 83 | with patch('os.path.exists', return_value=True): | 83 | with patch('os.path.exists', return_value=True): |
| 84 | with patch('os.remove'): | 84 | with patch('os.remove'): |
| @@ -87,7 +87,7 @@ async def test_cleanup_temp_file(): | |||
| 87 | 87 | ||
| 88 | @pytest.mark.asyncio | 88 | @pytest.mark.asyncio |
| 89 | async def test_cleanup_temp_file_not_exists(): | 89 | async def test_cleanup_temp_file_not_exists(): |
| 90 | from cogs.music.audio_playback import AudioPlaybackManager | 90 | from cogs.voice import VoiceManager as AudioPlaybackManager |
| 91 | 91 | ||
| 92 | with patch('os.path.exists', return_value=False): | 92 | with patch('os.path.exists', return_value=False): |
| 93 | await AudioPlaybackManager.cleanup_temp_file("/nonexistent/file.mp3") | 93 | await AudioPlaybackManager.cleanup_temp_file("/nonexistent/file.mp3") |
diff --git a/tests/cogs/test_music_integration.py b/tests/cogs/test_music_integration.py index 1c0260a..692e1c1 100644 --- a/tests/cogs/test_music_integration.py +++ b/tests/cogs/test_music_integration.py | |||
| @@ -27,18 +27,20 @@ async def test_music_join_stop(music_cog, bot): | |||
| 27 | ctx.author.voice.channel = MagicMock() | 27 | ctx.author.voice.channel = MagicMock() |
| 28 | ctx.guild = MagicMock() | 28 | ctx.guild = MagicMock() |
| 29 | 29 | ||
| 30 | voice_client = AsyncMock() | ||
| 31 | ctx.voice_client = voice_client | ||
| 32 | |||
| 33 | # Test join | 30 | # Test join |
| 34 | ctx.author.voice.channel.connect = AsyncMock(return_value=voice_client) | 31 | mock_vm = MagicMock() |
| 35 | with patch.object(ctx, 'respond', new_callable=AsyncMock): | 32 | mock_vm.connect = AsyncMock() |
| 36 | with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): | 33 | with patch('cogs.music.join.get_voice_manager', return_value=mock_vm): |
| 37 | await join_cog.join.callback(join_cog, ctx) | 34 | with patch.object(ctx, 'respond', new_callable=AsyncMock): |
| 38 | ctx.author.voice.channel.connect.assert_called_once() | 35 | with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): |
| 36 | await join_cog.join.callback(join_cog, ctx) | ||
| 37 | mock_vm.connect.assert_called_once_with(ctx.author.voice.channel) | ||
| 39 | 38 | ||
| 40 | # Test stop | 39 | # Test stop |
| 41 | with patch.object(ctx, 'respond', new_callable=AsyncMock): | 40 | mock_vm_stop = MagicMock() |
| 42 | with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): | 41 | mock_vm_stop.disconnect = AsyncMock() |
| 43 | await stop_cog.stop.callback(stop_cog, ctx) | 42 | with patch('cogs.music.stop.get_voice_manager', return_value=mock_vm_stop): |
| 44 | voice_client.disconnect.assert_called_once() | 43 | with patch.object(ctx, 'respond', new_callable=AsyncMock): |
| 44 | with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): | ||
| 45 | await stop_cog.stop.callback(stop_cog, ctx) | ||
| 46 | mock_vm_stop.disconnect.assert_called_once_with(ctx.guild) | ||
diff --git a/tests/cogs/test_music_join.py b/tests/cogs/test_music_join.py index 0176759..b40d93a 100644 --- a/tests/cogs/test_music_join.py +++ b/tests/cogs/test_music_join.py | |||
| @@ -24,16 +24,18 @@ async def test_join_command_success(join_cog, mock_channel, mock_guild, bot, moc | |||
| 24 | ctx.author = mock_user | 24 | ctx.author = mock_user |
| 25 | 25 | ||
| 26 | mock_voice_channel = MagicMock() | 26 | mock_voice_channel = MagicMock() |
| 27 | mock_voice = MagicMock() | ||
| 28 | mock_voice_channel.connect = AsyncMock(return_value=mock_voice) | ||
| 29 | |||
| 30 | ctx.author.voice = MagicMock() | 27 | ctx.author.voice = MagicMock() |
| 31 | ctx.author.voice.channel = mock_voice_channel | 28 | ctx.author.voice.channel = mock_voice_channel |
| 32 | 29 | ||
| 33 | with patch.object(mock_voice_channel, 'connect', new_callable=AsyncMock, return_value=mock_voice): | 30 | with patch('cogs.music.join.get_voice_manager') as mock_get_vm: |
| 31 | mock_vm = MagicMock() | ||
| 32 | mock_vm.connect = AsyncMock() | ||
| 33 | mock_get_vm.return_value = mock_vm | ||
| 34 | |||
| 34 | with patch.object(ctx, 'respond', new_callable=AsyncMock): | 35 | with patch.object(ctx, 'respond', new_callable=AsyncMock): |
| 35 | with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): | 36 | with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): |
| 36 | await join_cog.join.callback(join_cog, ctx) | 37 | await join_cog.join.callback(join_cog, ctx) |
| 38 | mock_vm.connect.assert_called_once_with(mock_voice_channel) | ||
| 37 | 39 | ||
| 38 | 40 | ||
| 39 | @pytest.mark.asyncio | 41 | @pytest.mark.asyncio |
| @@ -42,12 +44,13 @@ async def test_stop_command_success(stop_cog, mock_channel, mock_guild, bot, moc | |||
| 42 | ctx.user = mock_user | 44 | ctx.user = mock_user |
| 43 | ctx.author = mock_user | 45 | ctx.author = mock_user |
| 44 | 46 | ||
| 45 | mock_voice_client = MagicMock() | 47 | with patch('cogs.music.stop.get_voice_manager') as mock_get_vm: |
| 46 | mock_voice_client.disconnect = AsyncMock() | 48 | mock_vm = MagicMock() |
| 47 | ctx.voice_client = mock_voice_client | 49 | mock_vm.disconnect = AsyncMock() |
| 50 | mock_get_vm.return_value = mock_vm | ||
| 48 | 51 | ||
| 49 | with patch.object(ctx, 'respond', new_callable=AsyncMock): | 52 | with patch.object(ctx, 'respond', new_callable=AsyncMock): |
| 50 | with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): | 53 | with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): |
| 51 | await stop_cog.stop.callback(stop_cog, ctx) | 54 | await stop_cog.stop.callback(stop_cog, ctx) |
| 52 | 55 | ||
| 53 | mock_voice_client.disconnect.assert_called_once() | 56 | mock_vm.disconnect.assert_called_once_with(mock_guild) |
diff --git a/tests/cogs/test_voice.py b/tests/cogs/test_voice.py index 104eea6..6eb226b 100644 --- a/tests/cogs/test_voice.py +++ b/tests/cogs/test_voice.py | |||
| @@ -53,7 +53,7 @@ async def test_voice_manager_get_voice_status_not_connected(): | |||
| 53 | mock_bot = MagicMock() | 53 | mock_bot = MagicMock() |
| 54 | mock_guild = MagicMock() | 54 | mock_guild = MagicMock() |
| 55 | 55 | ||
| 56 | with patch('cogs.voice.VoiceManager.get_voice_client', return_value=None): | 56 | with patch('cogs.voice.VoiceManager.get_client', return_value=None): |
| 57 | manager = VoiceManager(mock_bot) | 57 | manager = VoiceManager(mock_bot) |
| 58 | voice_client, is_connected, is_playing = await manager.get_voice_status(mock_guild) | 58 | voice_client, is_connected, is_playing = await manager.get_voice_status(mock_guild) |
| 59 | 59 | ||
| @@ -71,7 +71,7 @@ async def test_voice_manager_stop(): | |||
| 71 | mock_voice_client = MagicMock() | 71 | mock_voice_client = MagicMock() |
| 72 | mock_voice_client.disconnect = AsyncMock() | 72 | mock_voice_client.disconnect = AsyncMock() |
| 73 | 73 | ||
| 74 | with patch('cogs.voice.VoiceManager.get_voice_client', return_value=mock_voice_client): | 74 | with patch('cogs.voice.VoiceManager.get_client', return_value=mock_voice_client): |
| 75 | manager = VoiceManager(mock_bot) | 75 | manager = VoiceManager(mock_bot) |
| 76 | await manager.stop(mock_guild) | 76 | await manager.stop(mock_guild) |
| 77 | 77 | ||
