Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -9195,6 +9195,65 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
_pending_clarify = None
if _pending_clarify is not None and _clarify_mod is not None:
_raw_clarify_reply = (event.text or "").strip()
_attempted_voice_clarify = False
if not _raw_clarify_reply.startswith("/"):
_media_urls = getattr(event, "media_urls", None) or []
_media_types = getattr(event, "media_types", None) or []
_audio_paths = []
for _i, _path in enumerate(_media_urls):
_mtype = _media_types[_i] if _i < len(_media_types) else ""
_is_voice = (
getattr(event, "message_type", None) == MessageType.VOICE
or (
_mtype.startswith("audio/")
and getattr(event, "message_type", None)
not in {MessageType.AUDIO, MessageType.DOCUMENT}
)
)
if _is_voice:
_audio_paths.append(_path)
if _audio_paths:
_attempted_voice_clarify = True
# Voice events may expose a cached filename in ``text``.
# Never let that filename resolve the pending clarify if
# transcription fails or returns no usable transcript.
_raw_clarify_reply = ""
# Call _enrich_message_with_transcription directly rather than
# the canonical _prepare_inbound_message_text: the clarify
# answer must be the RAW transcript, not the agent-facing
# "voice message" wrapper that method emits, and we must avoid
# its native-image-buffer side effect for a reply we only read
# as text.
try:
_, _transcripts = await self._enrich_message_with_transcription(
"", _audio_paths,
)
_clean_transcripts = [
tx.strip() for tx in _transcripts if tx and tx.strip()
]
_raw_clarify_reply = "\n".join(_clean_transcripts)
if _clean_transcripts:
_echo_adapter = self.adapters.get(source.platform)
_echo_meta = self._thread_metadata_for_source(
source, self._reply_anchor_for_event(event),
)
if _echo_adapter and self._should_echo_stt_transcripts():
for _tx in _clean_transcripts:
try:
await _echo_adapter.send(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please gate this echo with _should_echo_stt_transcripts(). stt.echo_transcripts: false is documented to suppress raw transcript posts, and existing gateway echo paths all preserve that setting.

source.chat_id,
f'πŸŽ™οΈ "{_tx}"',
metadata=_echo_meta,
)
except Exception as _echo_exc:
logger.debug(
"Clarify voice echo failed (non-fatal): %s",
_echo_exc,
)
except Exception as _trans_exc:
logger.warning(
"Clarify voice transcription failed: %s", _trans_exc,
)
# Skip slash commands β€” the user clearly wanted to issue a
# command, not answer the clarify. Leave the clarify pending
# so the user can retry; if it times out, the agent unblocks
Expand Down Expand Up @@ -9227,6 +9286,24 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
# the agent's response don't double-post. The agent
# itself will produce the next user-facing message.
return ""
if not _raw_clarify_reply and not (event.text or "").strip().startswith("/"):
logger.info(
"Ignoring non-text clarify response for session=%s id=%s "
"(voice_attempted=%s); keeping clarify pending",
_quick_key,
_pending_clarify.clarify_id,
_attempted_voice_clarify,
)
if _attempted_voice_clarify:
return (
"I couldn't transcribe that voice reply for the pending "
"question. Please answer in text, or send a shorter voice "
"message."
)
return (
"I'm still waiting for an answer to the question above. "
"Please reply in text to continue."
)

# Intercept messages that are responses to a pending /reload-mcp
# (or future) slash-confirm prompt. Recognized confirm replies are
Expand Down
216 changes: 213 additions & 3 deletions tests/gateway/test_telegram_audio_vs_voice.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
3. Mixed media lists (voice + audio) split correctly.
"""

from unittest.mock import patch
from unittest.mock import AsyncMock, patch

import pytest

Expand All @@ -21,11 +21,11 @@
from gateway.session import SessionSource


def _make_runner(stt_enabled: bool = True) -> "GatewayRunner": # type: ignore[name-defined]
def _make_runner(stt_enabled: bool = True, **config_kwargs) -> "GatewayRunner": # type: ignore[name-defined]
from gateway.run import GatewayRunner

runner = GatewayRunner.__new__(GatewayRunner)
runner.config = GatewayConfig(stt_enabled=stt_enabled)
runner.config = GatewayConfig(stt_enabled=stt_enabled, **config_kwargs)
runner.adapters = {}
runner._model = "test-model"
runner._base_url = ""
Expand Down Expand Up @@ -187,3 +187,213 @@ def test_telegram_media_type_detection_audio_vs_voice():
assert MessageType.VOICE.value == "voice"
# Sanity: they are distinct
assert MessageType.AUDIO != MessageType.VOICE


# ---------------------------------------------------------------------------
# 5. Voice reply to a PENDING CLARIFY resolves it with the raw transcript
# (#50925 β€” voice answers to clarify prompts were silently dropped)
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
@pytest.mark.parametrize(
("platform", "event_text"),
[
(Platform.TELEGRAM, ""),
(Platform.MATRIX, "voice_message_123.ogg"),
],
)
async def test_voice_reply_resolves_pending_clarify_with_transcript(
platform, event_text
):
"""A voice answer resolves clarify with the raw transcript, not its filename."""
from gateway.run import GatewayRunner
from gateway.session import build_session_key
from tools import clarify_gateway as cm

with cm._lock:
cm._entries.clear()
cm._session_index.clear()

runner = _make_runner(stt_enabled=True)
runner.session_store = None

source = SessionSource(
platform=platform, chat_id="1", chat_type="dm", user_id="user1",
)
session_key = build_session_key(source)
cm.register("cid-voice", session_key, "Which option?", choices=None)

event = MessageEvent(
text=event_text,
message_type=MessageType.VOICE,
source=source,
media_urls=["/tmp/voice.ogg"],
media_types=["audio/ogg"],
internal=True,
)

with patch(
"tools.transcription_tools.transcribe_audio",
return_value={"success": True, "transcript": "the blue one", "provider": "whisper"},
) as mock_transcribe:
result = await GatewayRunner._handle_message(runner, event)

mock_transcribe.assert_called_once_with("/tmp/voice.ogg")
# Acknowledged with an empty string so adapters don't double-post.
assert result == ""
# Resolves with the RAW transcript β€” not a wrapped "voice message ..." note.
assert cm.wait_for_response("cid-voice", timeout=0.01) == "the blue one"


@pytest.mark.asyncio
@pytest.mark.parametrize(
("platform", "event_text"),
[
(Platform.TELEGRAM, ""),
(Platform.MATRIX, "voice_message_456.ogg"),
],
)
async def test_voice_reply_failed_transcription_keeps_clarify_pending(
platform, event_text
):
"""If STT yields no text, clarify stays pending even when text is a filename."""
from gateway.run import GatewayRunner
from gateway.session import build_session_key
from tools import clarify_gateway as cm

with cm._lock:
cm._entries.clear()
cm._session_index.clear()

runner = _make_runner(stt_enabled=True)
runner.session_store = None

source = SessionSource(
platform=platform, chat_id="1", chat_type="dm", user_id="user1",
)
session_key = build_session_key(source)
cm.register("cid-voice-fail", session_key, "Which option?", choices=None)

event = MessageEvent(
text=event_text,
message_type=MessageType.VOICE,
source=source,
media_urls=["/tmp/voice.ogg"],
media_types=["audio/ogg"],
internal=True,
)

# No usable transcript -> the clarify must NOT resolve with garbage.
with patch.object(
runner, "_enrich_message_with_transcription",
new=AsyncMock(return_value=("", [])),
):
result = await GatewayRunner._handle_message(runner, event)

assert "text" in result.lower()
assert cm.get_pending_for_session(session_key) is not None


# ---------------------------------------------------------------------------
# 6. Clarify transcript echo honors stt.echo_transcripts and thread metadata
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_voice_clarify_echo_disabled_still_resolves_without_send():
"""stt_echo_transcripts=False: clarify resolves, but no πŸŽ™οΈ echo is sent."""
from unittest.mock import MagicMock

from gateway.run import GatewayRunner
from gateway.session import build_session_key
from tools import clarify_gateway as cm

with cm._lock:
cm._entries.clear()
cm._session_index.clear()

runner = _make_runner(stt_enabled=True, stt_echo_transcripts=False)
runner.session_store = None
adapter = MagicMock()
adapter.send = AsyncMock()
runner.adapters = {Platform.TELEGRAM: adapter}

source = SessionSource(
platform=Platform.TELEGRAM, chat_id="1", chat_type="dm", user_id="user1",
)
session_key = build_session_key(source)
cm.register("cid-no-echo", session_key, "Which option?", choices=None)

event = MessageEvent(
text="",
message_type=MessageType.VOICE,
source=source,
media_urls=["/tmp/voice.ogg"],
media_types=["audio/ogg"],
internal=True,
)

with patch(
"tools.transcription_tools.transcribe_audio",
return_value={"success": True, "transcript": "use the local model", "provider": "whisper"},
):
result = await GatewayRunner._handle_message(runner, event)

assert result == ""
assert cm.wait_for_response("cid-no-echo", timeout=0.01) == "use the local model"
adapter.send.assert_not_awaited()


@pytest.mark.asyncio
async def test_voice_clarify_echo_preserves_telegram_dm_topic_metadata():
"""The πŸŽ™οΈ echo in a Telegram DM topic keeps topic routing + reply anchor."""
from unittest.mock import MagicMock

from gateway.run import GatewayRunner
from gateway.session import build_session_key
from tools import clarify_gateway as cm

with cm._lock:
cm._entries.clear()
cm._session_index.clear()

runner = _make_runner(stt_enabled=True)
runner.session_store = None
adapter = MagicMock()
adapter.send = AsyncMock()
runner.adapters = {Platform.TELEGRAM: adapter}

source = SessionSource(
platform=Platform.TELEGRAM, chat_id="1", chat_type="dm",
user_id="user1", thread_id="42",
)
session_key = build_session_key(source)
cm.register("cid-topic", session_key, "Which option?", choices=None)

event = MessageEvent(
text="",
message_type=MessageType.VOICE,
source=source,
message_id="777",
media_urls=["/tmp/voice.ogg"],
media_types=["audio/ogg"],
internal=True,
)

with patch(
"tools.transcription_tools.transcribe_audio",
return_value={"success": True, "transcript": "the blue one", "provider": "whisper"},
):
result = await GatewayRunner._handle_message(runner, event)

assert result == ""
assert cm.wait_for_response("cid-topic", timeout=0.01) == "the blue one"
adapter.send.assert_awaited_once_with(
"1",
'πŸŽ™οΈ "the blue one"',
metadata={
"thread_id": "42",
"telegram_dm_topic_reply_fallback": True,
"direct_messages_topic_id": "42",
"telegram_reply_to_message_id": "777",
},
)