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.
This commit is contained in:
Alexander Russell 2026-07-16 19:21:51 +12:00 committed by Teknium
parent a9a9005f31
commit ef274a4829
2 changed files with 49 additions and 6 deletions

View file

@ -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,

View file

@ -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()