From ef274a482906cfd0eb1f2bf0e754b5ebd7bcc701 Mon Sep 17 00:00:00 2001 From: Alexander Russell Date: Thu, 16 Jul 2026 19:21:51 +1200 Subject: [PATCH] fix(tts): keep Telegram caption on the original reply text Review follow-up: the spoken script is for synthesis only. Caption eligibility and payload stay on the original reply, so a long reply whose normalized script fits the 1024 char limit is still delivered in full as its own message. Adds the long-original/short-normalized regression case to the auto-TTS caption tests. --- gateway/platforms/base.py | 15 +++++---- tests/gateway/test_base_topic_sessions.py | 40 +++++++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index e816ba19f4ac..60f3344c38c8 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -5545,7 +5545,6 @@ class BasePlatformAdapter(ABC): # an explicit ``/voice on|tts`` opt-in OR when ``voice.auto_tts`` is # True globally and no ``/voice off`` has been issued. _tts_path = None - _tts_speech_text = None if (self._should_auto_tts_for_chat(event.source.chat_id) and event.message_type == MessageType.VOICE and text_content @@ -5557,7 +5556,6 @@ class BasePlatformAdapter(ABC): speech_text = self.prepare_tts_text(text_content) if not speech_text: raise ValueError("Empty text after markdown cleanup") - _tts_speech_text = speech_text tts_result_str = await asyncio.to_thread( text_to_speech_tool, text=speech_text ) @@ -5570,14 +5568,19 @@ class BasePlatformAdapter(ABC): _tts_caption_delivered = False if _tts_path and Path(_tts_path).exists(): try: + # Caption eligibility and payload stay on the ORIGINAL + # reply text. The spoken script is for synthesis only: + # normalization can shrink a long reply below the + # 1024-char caption limit, and captioning that spoken + # form would suppress the full formatted reply the + # user is meant to receive as a separate message. telegram_tts_caption = None - caption_text = _tts_speech_text or self.prepare_tts_text(text_content) if ( self.platform == Platform.TELEGRAM - and caption_text - and caption_text[:1024] == caption_text + and text_content + and text_content[:1024] == text_content ): - telegram_tts_caption = caption_text + telegram_tts_caption = text_content tts_result = await self.play_tts( chat_id=event.source.chat_id, audio_path=_tts_path, diff --git a/tests/gateway/test_base_topic_sessions.py b/tests/gateway/test_base_topic_sessions.py index 03934d69d629..e7ce6c1f52b2 100644 --- a/tests/gateway/test_base_topic_sessions.py +++ b/tests/gateway/test_base_topic_sessions.py @@ -333,6 +333,46 @@ class TestTelegramAutoTtsCaptionDelivery: } ] + @pytest.mark.asyncio + async def test_long_original_with_short_spoken_script_still_sends_full_reply(self, tmp_path): + adapter = DummyTelegramAdapter() + adapter._keep_typing = self._hold_typing() + adapter._should_auto_tts_for_chat = lambda _chat_id: True + adapter.play_tts = AsyncMock(return_value=SendResult(success=True, message_id="tts-1")) + # Markdown-heavy reply: over the 1024-char caption limit as written, + # but the normalized spoken script (markdown and URLs removed) is far + # below it. Caption eligibility must follow the ORIGINAL reply, so the + # full formatted text is still delivered as its own message instead of + # being swallowed into a lossy caption. + long_reply = "\n".join( + f"- **item {i}** [details](https://example.com/some/very/long/path/{i:04d})" + for i in range(20) + ) + assert len(long_reply) > 1024 + assert len(adapter.prepare_tts_text(long_reply)) <= 1024 + adapter.set_message_handler(lambda _event: asyncio.sleep(0, result=long_reply)) + + tts_path = tmp_path / "reply.ogg" + tts_path.write_text("audio", encoding="utf-8") + event = self._make_voice_event() + + with patch("tools.tts_tool.check_tts_requirements", return_value=True), patch( + "tools.tts_tool.text_to_speech_tool", + return_value=json.dumps({"file_path": str(tts_path)}), + ): + await adapter._process_message_background(event, build_session_key(event.source)) + + adapter.play_tts.assert_awaited_once() + assert adapter.play_tts.await_args.kwargs["caption"] is None + assert adapter.sent == [ + { + "chat_id": "-1001", + "content": long_reply, + "reply_to": None, + "metadata": {"thread_id": "17585", "notify": True}, + } + ] + @pytest.mark.asyncio async def test_telegram_auto_tts_send_failure_keeps_followup_text(self, tmp_path): adapter = DummyTelegramAdapter()