diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 58e1c223889..0ffe1abac7a 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -68,6 +68,26 @@ from gateway.platforms.base import ( from tools.url_safety import is_safe_url +def _find_discord_windows_bundled_opus(discord_module: Any = None) -> Optional[str]: + """Return discord.py's bundled Windows opus DLL path when present.""" + if sys.platform != "win32": + return None + discord_module = discord if discord_module is None else discord_module + if discord_module is None: + return None + + opus_module = getattr(discord_module, "opus", None) + opus_file = getattr(opus_module, "__file__", None) + if not opus_file: + return None + + target = "x64" if struct.calcsize("P") * 8 > 32 else "x86" + bundled = _Path(opus_file).resolve().parent / "bin" / f"libopus-0.{target}.dll" + if bundled.is_file(): + return str(bundled) + return None + + def _clean_discord_id(entry: str) -> str: """Strip common prefixes from a Discord user ID or username entry. @@ -403,7 +423,13 @@ class VoiceReceiver: self._buffers[ssrc].extend(pcm) self._last_packet_time[ssrc] = time.monotonic() except Exception as e: - logger.debug("Opus decode error for SSRC %s: %s", ssrc, e) + with self._lock: + self._decoders.pop(ssrc, None) + logger.debug( + "Opus decode error for SSRC %s; reset decoder: %s", + ssrc, + e, + ) return # ------------------------------------------------------------------ @@ -604,7 +630,13 @@ class DiscordAdapter(BasePlatformAdapter): # Load opus codec for voice channel support if not discord.opus.is_loaded(): import ctypes.util + opus_candidates = [] + bundled_opus = _find_discord_windows_bundled_opus(discord) + if bundled_opus: + opus_candidates.append(bundled_opus) opus_path = ctypes.util.find_library("opus") + if opus_path: + opus_candidates.append(opus_path) # ctypes.util.find_library fails on macOS with Homebrew-installed libs, # so fall back to known Homebrew paths if needed. if not opus_path: @@ -615,11 +647,13 @@ class DiscordAdapter(BasePlatformAdapter): if sys.platform == "darwin": for _hp in _homebrew_paths: if os.path.isfile(_hp): - opus_path = _hp + opus_candidates.append(_hp) break - if opus_path: + for opus_path in opus_candidates: try: discord.opus.load_opus(opus_path) + if discord.opus.is_loaded(): + break except Exception: logger.warning("Opus codec found at %s but failed to load", opus_path) if not discord.opus.is_loaded(): diff --git a/tests/gateway/test_discord_opus.py b/tests/gateway/test_discord_opus.py index 63bef5acaf5..fc94517824d 100644 --- a/tests/gateway/test_discord_opus.py +++ b/tests/gateway/test_discord_opus.py @@ -1,6 +1,7 @@ """Tests for Discord Opus codec loading — must use ctypes.util.find_library.""" import inspect +import types class TestOpusFindLibrary: @@ -29,12 +30,34 @@ class TestOpusFindLibrary: assert "sys.platform" in source or "darwin" in source, \ "Homebrew fallback must be guarded by macOS platform check" + def test_windows_bundled_discord_opus_dll_is_discovered(self, monkeypatch, tmp_path): + """Native Windows installs should try discord.py's bundled opus DLL.""" + import plugins.platforms.discord.adapter as adapter + + opus_py = tmp_path / "discord" / "opus.py" + bundled = opus_py.parent / "bin" / "libopus-0.x64.dll" + bundled.parent.mkdir(parents=True) + opus_py.write_text("# fake discord.opus module\n") + bundled.write_bytes(b"fake dll") + + discord_stub = types.SimpleNamespace( + opus=types.SimpleNamespace(__file__=str(opus_py)) + ) + monkeypatch.setattr(adapter.sys, "platform", "win32") + monkeypatch.setattr(adapter.struct, "calcsize", lambda _fmt: 8) + + assert adapter._find_discord_windows_bundled_opus(discord_stub) == str( + bundled.resolve() + ) + def test_opus_decode_error_logged(self): """Opus decode failure must log the error, not silently return.""" from plugins.platforms.discord.adapter import VoiceReceiver source = inspect.getsource(VoiceReceiver._on_packet) assert "logger" in source, \ "_on_packet must log Opus decode errors" + assert "self._decoders.pop" in source, \ + "_on_packet must reset the Opus decoder after decode failures" # Must not have bare `except Exception:\n return` lines = source.split("\n") for i, line in enumerate(lines):