fix(matrix): accept the media dispatch's is_voice kwarg in send_voice
The base media dispatch calls send_voice(..., is_voice=is_voice) for every audio MEDIA attachment (gateway/platforms/base.py _send_one). MatrixAdapter .send_voice() accepted neither is_voice nor **kwargs, so every non-image MEDIA delivery raised TypeError and the file was silently dropped — the failure is visible in rotated logs since 2026-09-08 (never worked). Accept the flag explicitly: is_voice=False -> plain m.audio in the original format (no transcode); True or omitted (play_audio legacy callers) -> the existing MSC3245 voice-bubble path with best-effort Ogg/Opus transcode. Fixes #116776 (cherry picked from commit d4f89a725498e29a2ee0fe016f8bb98db08f358c)
This commit is contained in:
@@ -1603,9 +1603,16 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
|
||||
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:
|
||||
"""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."""
|
||||
metadata: Optional[Dict[str, Any]] = None, is_voice: Optional[bool] = None) -> SendResult:
|
||||
"""Upload audio. The base media dispatch calls this with ``is_voice``: True for a voice-tagged
|
||||
attachment → MSC3245 voice bubble; False for an audio-ext MEDIA attachment → plain ``m.audio``
|
||||
in the original format. Voice bubbles need Ogg/Opus but callers pass any format (e.g. TTS
|
||||
output), so transcode there — best-effort: without ffmpeg the original is sent. Callers that
|
||||
don't pass the flag (``play_audio``) keep the voice-bubble behavior this method was written
|
||||
for (#116776: the dispatch always passes ``is_voice``, and rejecting it dropped the file)."""
|
||||
if is_voice is False:
|
||||
return await self._send_local_file(
|
||||
chat_id, audio_path, "m.audio", caption, reply_to, metadata=metadata, is_voice=False)
|
||||
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.
|
||||
|
||||
86
tests/gateway/test_matrix_send_voice_dispatch.py
Normal file
86
tests/gateway/test_matrix_send_voice_dispatch.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Regression tests for Matrix ``send_voice`` dispatch compatibility (#116776).
|
||||
|
||||
The base media dispatch calls ``send_voice(..., is_voice=...)`` for every audio
|
||||
MEDIA attachment. The Matrix adapter used to reject that kwarg, so all non-image
|
||||
MEDIA delivery raised ``TypeError: MatrixAdapter.send_voice() got an unexpected
|
||||
keyword argument 'is_voice'`` and the attachment was silently dropped.
|
||||
"""
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
|
||||
def _make_adapter(**extra):
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
token="syt_test_token",
|
||||
extra={
|
||||
"homeserver": "https://matrix.example.org",
|
||||
"user_id": "@bot:example.org",
|
||||
**extra,
|
||||
},
|
||||
)
|
||||
return MatrixAdapter(config)
|
||||
|
||||
|
||||
def _spy_send_local_file(adapter):
|
||||
adapter._send_local_file = AsyncMock(return_value=MagicMock(success=True))
|
||||
return adapter._send_local_file
|
||||
|
||||
|
||||
def test_dispatch_call_with_is_voice_false_sends_plain_audio():
|
||||
"""The exact dispatcher call shape (base.py ``_send_one``) must not raise
|
||||
TypeError, and an audio-ext attachment must keep its original format."""
|
||||
adapter = _make_adapter()
|
||||
spy = _spy_send_local_file(adapter)
|
||||
|
||||
result = asyncio.run(adapter.send_voice(
|
||||
chat_id="!room:example.org", audio_path="/tmp/clip.mp3", metadata=None, is_voice=False))
|
||||
|
||||
assert result.success is True
|
||||
spy.assert_awaited_once()
|
||||
args, kwargs = spy.call_args
|
||||
assert args[0] == "!room:example.org"
|
||||
assert args[1] == "/tmp/clip.mp3" # original file, no transcode
|
||||
assert args[2] == "m.audio"
|
||||
assert kwargs["is_voice"] is False
|
||||
|
||||
|
||||
def test_voice_tagged_attachment_gets_voice_bubble():
|
||||
adapter = _make_adapter()
|
||||
spy = _spy_send_local_file(adapter)
|
||||
|
||||
asyncio.run(adapter.send_voice(
|
||||
chat_id="!room:example.org", audio_path="/tmp/clip.ogg", metadata=None, is_voice=True))
|
||||
|
||||
args, kwargs = spy.call_args
|
||||
assert args[1] == "/tmp/clip.ogg"
|
||||
assert kwargs["is_voice"] is True # MSC3245 voice metadata path
|
||||
|
||||
|
||||
def test_legacy_caller_without_flag_keeps_voice_bubble():
|
||||
"""``play_audio`` (base default) calls send_voice without the flag; TTS
|
||||
playback must stay a voice bubble."""
|
||||
adapter = _make_adapter()
|
||||
spy = _spy_send_local_file(adapter)
|
||||
|
||||
asyncio.run(adapter.send_voice(chat_id="!room:example.org", audio_path="/tmp/tts.ogg"))
|
||||
|
||||
args, kwargs = spy.call_args
|
||||
assert kwargs["is_voice"] is True
|
||||
|
||||
|
||||
def test_voice_bubble_transcodes_non_ogg():
|
||||
adapter = _make_adapter()
|
||||
spy = _spy_send_local_file(adapter)
|
||||
with patch("plugins.platforms.matrix.adapter.transcode_to_ogg_opus",
|
||||
MagicMock(return_value="/tmp/clip_converted.ogg")):
|
||||
asyncio.run(adapter.send_voice(
|
||||
chat_id="!room:example.org", audio_path="/tmp/clip.mp3", is_voice=True))
|
||||
|
||||
args, kwargs = spy.call_args
|
||||
assert args[1] == "/tmp/clip_converted.ogg"
|
||||
assert kwargs["file_name"] == "clip.ogg" # keep the caller's basename
|
||||
Reference in New Issue
Block a user