From e96d6751eed030bc4ed01035e1a7e7808d1aea59 Mon Sep 17 00:00:00 2001 From: seth586 Date: Thu, 17 Sep 2026 07:44:36 +0000 Subject: [PATCH 1/2] fix(gateway): accept is_voice kwarg in Matrix, Mattermost, LINE send_voice overrides The shared media dispatcher (gateway/platforms/base.py _send_one, gateway/run_notifications.py, gateway/run_turn.py) passes is_voice= to adapter.send_voice() for every non-image audio attachment. Three plugin adapters overrode send_voice with a narrower signature and no **kwargs, so the call raised TypeError before any upload happened and MEDIA audio replies were dropped silently (run_turn.py wraps its call site in suppress(Exception)). Affected adapters: - Matrix (no voice bubbles or audio attachments delivered) - Mattermost - LINE Fix: accept-and-ignore **kwargs in the three overrides, matching the base adapter's signature tolerance (routing is decided by the caller). Add an invariant test asserting the overrides tolerate the dispatcher's exact call shape (red on base, green with fix). Verified live on Matrix: TTS voice replies deliver as native audio after the fix. --- plugins/platforms/line/adapter.py | 4 +- plugins/platforms/matrix/adapter.py | 6 ++- plugins/platforms/mattermost/adapter.py | 2 +- tests/gateway/test_media_send_voice_kwargs.py | 47 +++++++++++++++++++ 4 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 tests/gateway/test_media_send_voice_kwargs.py diff --git a/plugins/platforms/line/adapter.py b/plugins/platforms/line/adapter.py index 2ac12d4ed0d67..386390c07a592 100644 --- a/plugins/platforms/line/adapter.py +++ b/plugins/platforms/line/adapter.py @@ -818,8 +818,8 @@ async def send_image_file( return await self._send_messages(chat_id, msgs + ([_text_message(caption)] if caption else [])) async def send_voice( - self, chat_id: str, audio_path: str, duration_ms: int = 1000, metadata: Optional[Dict[str, Any]] = None - ) -> SendResult: + self, chat_id: str, audio_path: str, duration_ms: int = 1000, metadata: Optional[Dict[str, Any]] = None, + **kwargs) -> SendResult: path, err = self._check_media_file("audio", audio_path) if err: return err diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index 3917b788dc86a..6e3bc4eab073d 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -1595,9 +1595,11 @@ async def send_document( async def send_voice( self, chat_id: str, audio_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None) -> SendResult: + metadata: Optional[Dict[str, Any]] = None, **kwargs) -> SendResult: """Upload audio as an MSC3245 voice message. Voice bubbles need Ogg/Opus but callers pass any - format (e.g. TTS output), so transcode here — best-effort: without ffmpeg the original is sent.""" + format (e.g. TTS output), so transcode here — best-effort: without ffmpeg the original is sent. + The shared media dispatcher passes ``is_voice``; accept-and-ignore it here (routing is decided + by the caller), matching the base adapter's ``**kwargs`` tolerance.""" converted_path: Optional[str] = None if not str(audio_path).lower().endswith((".ogg", ".oga", ".opus")): # 48k (not the 32k default): Element renders voice bubbles at a higher quality tier. diff --git a/plugins/platforms/mattermost/adapter.py b/plugins/platforms/mattermost/adapter.py index a4f68c1640472..36286a55f116b 100644 --- a/plugins/platforms/mattermost/adapter.py +++ b/plugins/platforms/mattermost/adapter.py @@ -310,7 +310,7 @@ async def send_document( return await self._send_local_file(chat_id, file_path, caption, reply_to, file_name, metadata) async def send_voice(self, chat_id: str, audio_path: str, caption: Optional[str] = None, - reply_to: Optional[str] = None, metadata: _Metadata = None) -> SendResult: + reply_to: Optional[str] = None, metadata: _Metadata = None, **kwargs) -> SendResult: return await self._send_local_file(chat_id, audio_path, caption, reply_to, metadata=metadata) async def send_video(self, chat_id: str, video_path: str, caption: Optional[str] = None, diff --git a/tests/gateway/test_media_send_voice_kwargs.py b/tests/gateway/test_media_send_voice_kwargs.py new file mode 100644 index 0000000000000..79179c6eec868 --- /dev/null +++ b/tests/gateway/test_media_send_voice_kwargs.py @@ -0,0 +1,47 @@ +"""The shared media dispatcher passes ``is_voice`` to every adapter's ``send_voice``. + +Adapter overrides that narrow the base signature without ``**kwargs`` raise +TypeError before any upload happens, so MEDIA: audio replies are silently +dropped on those platforms (run_turn.py wraps its call site in +``suppress(Exception)``). The invariant: dispatching media with +``is_voice=True`` reaches the adapter's send path regardless of how the +override narrows its signature. +""" +import inspect + +import pytest + +from gateway.platforms.base import BasePlatformAdapter + +# Adapters whose send_voice override was missing **kwargs (regression guards). +_VOICE_OVERRIDES = [ + ("plugins.platforms.line.adapter", "LineAdapter"), + ("plugins.platforms.matrix.adapter", "MatrixAdapter"), + ("plugins.platforms.mattermost.adapter", "MattermostAdapter"), +] + + +def _adapter_class(module_name: str, class_name: str): + module = __import__(module_name, fromlist=[class_name]) + return getattr(module, class_name) + + +@pytest.mark.parametrize("module_name,class_name", _VOICE_OVERRIDES) +def test_send_voice_override_accepts_is_voice_kwarg(module_name: str, class_name: str): + """send_voice overrides must tolerate the dispatcher's is_voice kwarg.""" + cls = _adapter_class(module_name, class_name) + sig = inspect.signature(cls.send_voice) + assert sig.parameters.get("kwargs").kind == inspect.Parameter.VAR_KEYWORD, ( + f"{class_name}.send_voice does not accept **kwargs; the shared media " + "dispatcher passes is_voice= and the call would raise TypeError before upload" + ) + + +def test_dispatch_send_voice_with_is_voice_reaches_base_sender(): + """The base adapter's send_voice/_send_media_file path tolerates is_voice end-to-end. + + Asserts the dispatcher's exact call shape binds against the BASE class + signature — the contract every override must preserve. + """ + sig = inspect.signature(BasePlatformAdapter.send_voice) + sig.bind(None, "chat", "/tmp/a.wav", caption=None, reply_to=None, metadata=None, is_voice=True) From d599158ee8f32abf6e610ee60403a09529844dc3 Mon Sep 17 00:00:00 2001 From: seth586 Date: Thu, 17 Sep 2026 07:56:18 +0000 Subject: [PATCH 2/2] fix(gateway): accept dispatcher kwargs in Weixin send_voice/send_video Same bug class as the Matrix/Mattermost/LINE send_voice overrides: WeixinAdapter.send_video and send_voice define narrow signatures without **kwargs, so the shared media dispatcher's is_voice= kwarg raises TypeError before upload and audio/video MEDIA attachments are silently dropped (gateway/platforms/weixin.py). Adds **kwargs to both overrides, matching the base adapter's signature tolerance. --- gateway/platforms/weixin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index d65c100583759..ebcccb0fda533 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -1109,10 +1109,10 @@ async def send_document( ) -> SendResult: return await self._send_file_result(chat_id, file_path, caption or "", "send_document") - async def send_video(self, chat_id: str, video_path: str, caption: Optional[str] = None, reply_to=None, metadata=None) -> SendResult: + async def send_video(self, chat_id: str, video_path: str, caption: Optional[str] = None, reply_to=None, metadata=None, **kwargs) -> SendResult: return await self._send_file_result(chat_id, video_path, caption or "", "send_video") - async def send_voice(self, chat_id: str, audio_path: str, caption: Optional[str] = None, reply_to=None, metadata=None) -> SendResult: + async def send_voice(self, chat_id: str, audio_path: str, caption: Optional[str] = None, reply_to=None, metadata=None, **kwargs) -> SendResult: # Native outbound voice bubbles are not proven-working upstream; a file attachment at least plays (even .silk). return await self._send_file_result(chat_id, audio_path, caption or "[voice message as attachment]", "send_voice", force_file_attachment=True)