diff options
| author | salmonik <raisersoft@proton.me> | 2026-07-08 02:13:35 +0200 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-07-08 02:13:35 +0200 |
| commit | cd8ed8bc5793985df2c1692072a932ceee517b95 (patch) | |
| tree | 42d18a4140259b5c68b4dd05b7fcb398f78b8d7f /cogs/voice.py | |
| parent | Merge pull request 'Voice related fixes, alongside simplifying assistant' (#5... (diff) | |
| download | senator-sperma-cd8ed8bc5793985df2c1692072a932ceee517b95.tar.gz senator-sperma-cd8ed8bc5793985df2c1692072a932ceee517b95.zip | |
OPEN THE RUST GATES
Signed-off-by: salmonik <raisersoft@proton.me>
Diffstat (limited to '')
| -rw-r--r-- | cogs/voice.py | 444 |
1 files changed, 0 insertions, 444 deletions
diff --git a/cogs/voice.py b/cogs/voice.py deleted file mode 100644 index a224336..0000000 --- a/cogs/voice.py +++ /dev/null | |||
| @@ -1,444 +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 discord | ||
| 7 | import logging | ||
| 8 | import asyncio | ||
| 9 | import os | ||
| 10 | import urllib.parse | ||
| 11 | from dataclasses import dataclass, field | ||
| 12 | from typing import Any | ||
| 13 | |||
| 14 | import mutagen | ||
| 15 | |||
| 16 | logger = logging.getLogger(__name__) | ||
| 17 | |||
| 18 | |||
| 19 | _voice_managers: dict[int, "VoiceManager"] = {} | ||
| 20 | |||
| 21 | |||
| 22 | def get_voice_manager(bot) -> "VoiceManager": | ||
| 23 | """Get or create VoiceManager for a specific bot instance.""" | ||
| 24 | bot_id = bot.user.id if bot.user else id(bot) | ||
| 25 | if bot_id not in _voice_managers: | ||
| 26 | _voice_managers[bot_id] = VoiceManager(bot) | ||
| 27 | return _voice_managers[bot_id] | ||
| 28 | |||
| 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 | |||
| 52 | @dataclass | ||
| 53 | class VoiceManager: | ||
| 54 | bot: Any | ||
| 55 | _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False) | ||
| 56 | _connecting: set[int] = field(default_factory=set, init=False) | ||
| 57 | |||
| 58 | # --- Connection ----------------------------------------------------------- | ||
| 59 | |||
| 60 | async def _ensure_voice_client(self, channel): | ||
| 61 | """Unified connect/move/reconnect logic. | ||
| 62 | |||
| 63 | Returns a healthy VoiceClient connected to *channel*, or None on failure. | ||
| 64 | """ | ||
| 65 | guild = channel.guild | ||
| 66 | guild_id = guild.id | ||
| 67 | |||
| 68 | async with self._lock: | ||
| 69 | if guild_id in self._connecting: | ||
| 70 | logger.info(f"Connection already in progress for guild {guild_id}, skipping") | ||
| 71 | return None | ||
| 72 | |||
| 73 | vc = discord.utils.get(self.bot.voice_clients, guild=guild) | ||
| 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") | ||
| 132 | try: | ||
| 133 | await vc.disconnect() | ||
| 134 | except Exception: | ||
| 135 | pass | ||
| 136 | try: | ||
| 137 | await vc.connect( | ||
| 138 | reconnect=False, timeout=10.0, self_deaf=False, self_mute=False | ||
| 139 | ) | ||
| 140 | logger.info(f"Reconnected disconnected client: {channel}") | ||
| 141 | return vc | ||
| 142 | except Exception as e: | ||
| 143 | logger.error(f"Failed to reconnect disconnected client: {e}") | ||
| 144 | try: | ||
| 145 | channel._state._remove_voice_client(guild.id) | ||
| 146 | except Exception: | ||
| 147 | pass | ||
| 148 | vc = None | ||
| 149 | |||
| 150 | # --- create fresh connection -------------------------------------- | ||
| 151 | self._connecting.add(guild_id) | ||
| 152 | try: | ||
| 153 | await asyncio.sleep(0.3) | ||
| 154 | try: | ||
| 155 | vc = await channel.connect(timeout=10.0, reconnect=False) | ||
| 156 | logger.info(f"Connected to voice channel: {channel}") | ||
| 157 | return vc | ||
| 158 | except discord.ClientException as e: | ||
| 159 | if "Already connected" in str(e): | ||
| 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) | ||
| 195 | |||
| 196 | async def join_by_channel_id(self, channel_id): | ||
| 197 | """Join a voice channel by channel ID.""" | ||
| 198 | channel = self.bot.get_channel(channel_id) | ||
| 199 | if not channel: | ||
| 200 | logger.error(f"Channel {channel_id} not found") | ||
| 201 | return None | ||
| 202 | if not isinstance(channel, discord.VoiceChannel): | ||
| 203 | logger.error(f"Channel {channel_id} is not a voice channel") | ||
| 204 | return None | ||
| 205 | return await self.connect(channel) | ||
| 206 | |||
| 207 | async def get_client(self, guild): | ||
| 208 | """Get existing voice client for a guild.""" | ||
| 209 | return discord.utils.get(self.bot.voice_clients, guild=guild) | ||
| 210 | |||
| 211 | async def get_voice_client(self, guild): | ||
| 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. | ||
| 217 | |||
| 218 | Returns: | ||
| 219 | tuple: (voice_client, is_connected, is_playing) or (None, False, False) | ||
| 220 | """ | ||
| 221 | vc = await self.get_client(guild) | ||
| 222 | if vc is None: | ||
| 223 | return None, False, False | ||
| 224 | return vc, vc.is_connected(), vc.is_playing() | ||
| 225 | |||
| 226 | async def get_voice_status(self, guild): | ||
| 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}") | ||
| 238 | |||
| 239 | async def stop(self, guild): | ||
| 240 | """Backward-compatible alias for disconnect().""" | ||
| 241 | await self.disconnect(guild) | ||
| 242 | |||
| 243 | # --- Playback ------------------------------------------------------------ | ||
| 244 | |||
| 245 | async def _play_await(self, vc, audio_source): | ||
| 246 | """Play an audio source and wait for completion using an asyncio.Future. | ||
| 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) | ||
| 261 | |||
| 262 | try: | ||
| 263 | await future | ||
| 264 | return True | ||
| 265 | except Exception as e: | ||
| 266 | logger.error(f"Playback error: {e}") | ||
| 267 | return False | ||
| 268 | |||
| 269 | async def play_source(self, channel, audio_source, interrupt=True, volume=1.0): | ||
| 270 | """Connect to *channel* and play a pre-made audio source. | ||
| 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 | ||
| 278 | |||
| 279 | if interrupt and (vc.is_playing() or vc.is_paused()): | ||
| 280 | vc.stop() | ||
| 281 | |||
| 282 | if abs(volume - 1.0) > 1e-9: | ||
| 283 | audio_source = discord.PCMVolumeTransformer(audio_source) | ||
| 284 | audio_source.volume = volume | ||
| 285 | |||
| 286 | logger.info("Playing via play_source") | ||
| 287 | return await self._play_await(vc, audio_source) | ||
| 288 | |||
| 289 | # --- Source preparation (folded from AudioPlaybackManager) --------------- | ||
| 290 | |||
| 291 | @staticmethod | ||
| 292 | async def validate_voice_channel(ctx): | ||
| 293 | """Validate user is in a voice channel. Returns the channel or None.""" | ||
| 294 | if not ctx.author.voice or not ctx.author.voice.channel: | ||
| 295 | await ctx.respond( | ||
| 296 | "Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True | ||
| 297 | ) | ||
| 298 | return None | ||
| 299 | return ctx.author.voice.channel | ||
| 300 | |||
| 301 | @staticmethod | ||
| 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) | ||
| 333 | try: | ||
| 334 | metadata = mutagen.open(source) | ||
| 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] | ||
| 346 | except Exception as e: | ||
| 347 | logger.warning(f"Could not read metadata from {source}: {str(e)}") | ||
| 348 | display_name = os.path.splitext(os.path.basename(source))[0] | ||
| 349 | |||
| 350 | return audio_source, display_name | ||
| 351 | |||
| 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 | ||
| 363 | |||
| 364 | display_name, absolute_source = await VoiceManager.prepare_file_source(source) | ||
| 365 | if not display_name: | ||
| 366 | return None, None, None | ||
| 367 | |||
| 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): | ||
| 374 | try: | ||
| 375 | os.remove(source) | ||
| 376 | logger.info(f"Cleaned up temporary file: {source}") | ||
| 377 | except Exception as e: | ||
| 378 | logger.error(f"Error cleaning up file {source}: {str(e)}") | ||
| 379 | |||
| 380 | @staticmethod | ||
| 381 | async def cleanup_if_needed(source, delete_after_play, is_url): | ||
| 382 | """Conditionally clean up a file after playback.""" | ||
| 383 | if delete_after_play and not is_url: | ||
| 384 | await VoiceManager.cleanup_temp_file(source) | ||
| 385 | |||
| 386 | # --- High-level play (full flow) ----------------------------------------- | ||
| 387 | |||
| 388 | async def play(self, ctx, source, delete_after_play=False, interrupt=True): | ||
| 389 | """High-level playback: validate context, prepare source, connect, play, cleanup. | ||
| 390 | |||
| 391 | Sends ``grom: {name}`` immediately on start, and an error follow-up | ||
| 392 | if playback fails. | ||
| 393 | |||
| 394 | Args: | ||
| 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) | ||
| 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) | ||
| 443 | |||
| 444 | return success | ||
