From d69f0c1a9928b7079d2eb96fe5f9d72844fc0871 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Mon, 18 May 2026 10:18:42 -0700 Subject: [PATCH] fix(gateway): mark final voice reply as notify-worthy so Telegram delivers it audibly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Telegram "important" notifications mode (default), TelegramPlatformAdapter sets ``disable_notification=True`` on every send unless metadata carries ``notify=True``. GatewayRunner._send_voice_reply already passes thread metadata through to ``adapter.send_voice``, but never marks the final auto-TTS voice reply as notify-worthy — so users with the default mode get the final voice note delivered silently with no push notification. Mirror the final-text path in gateway/platforms/base.py (the existing text-response final send already adds ``metadata["notify"] = True``). Issue #27970 Bug 2. Bug 1 (MP3 vs. native OGG voice-note) is being addressed by existing PRs #20182 / #20878 — this PR is intentionally scoped to the silent-delivery bug only. Co-Authored-By: Claude Opus 4.7 (1M context) --- gateway/run.py | 15 ++- tests/gateway/test_send_voice_reply_notify.py | 116 ++++++++++++++++++ tests/gateway/test_voice_command.py | 3 + 3 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 tests/gateway/test_send_voice_reply_notify.py diff --git a/gateway/run.py b/gateway/run.py index 4f6a45d1e6f..94170565033 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -10762,13 +10762,24 @@ class GatewayRunner: elif adapter and hasattr(adapter, "send_voice"): reply_anchor = self._reply_anchor_for_event(event) thread_meta = self._thread_metadata_for_source(event.source, reply_anchor) + # Mark the auto voice reply as notify-worthy. Mirrors the + # final-text path in gateway/platforms/base.py which sets + # ``notify=True`` so platform adapters that gate push + # notifications (Telegram "important" mode) deliver the + # final voice reply as a normal notification instead of a + # silent message. Clone first so we don't mutate metadata + # shared with concurrent typing-indicator state. + if thread_meta is not None: + thread_meta = dict(thread_meta) + thread_meta["notify"] = True + else: + thread_meta = {"notify": True} send_kwargs: Dict[str, Any] = { "chat_id": event.source.chat_id, "audio_path": actual_path, "reply_to": reply_anchor, + "metadata": thread_meta, } - if thread_meta: - send_kwargs["metadata"] = thread_meta await adapter.send_voice(**send_kwargs) except Exception as e: logger.warning("Auto voice reply failed: %s", e, exc_info=True) diff --git a/tests/gateway/test_send_voice_reply_notify.py b/tests/gateway/test_send_voice_reply_notify.py new file mode 100644 index 00000000000..ef4cb8ff2f8 --- /dev/null +++ b/tests/gateway/test_send_voice_reply_notify.py @@ -0,0 +1,116 @@ +"""Regression test for issue #27970 Bug 2. + +The auto Telegram voice reply (``GatewayRunner._send_voice_reply``) is the +final response of a turn. It must mark its metadata as ``notify=True`` so +adapters that gate push notifications (Telegram's "important" mode) deliver +it as a normal push instead of a silent message — mirroring the existing +final-text path in ``gateway/platforms/base.py``. +""" + +import json +import os +import tempfile +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from gateway.config import Platform +from gateway.platforms.base import MessageEvent, MessageType +from gateway.run import GatewayRunner +from gateway.session import SessionSource + + +def _make_event(thread_id=None): + source = SessionSource( + platform=Platform.TELEGRAM, + chat_id="208214988", + user_id="208214988", + chat_type="dm", + thread_id=thread_id, + ) + return MessageEvent( + text="hi", + message_type=MessageType.TEXT, + source=source, + message_id="m1", + ) + + +def _runner_with_adapter(send_voice_mock): + runner = object.__new__(GatewayRunner) + adapter = SimpleNamespace( + send_voice=send_voice_mock, + is_in_voice_channel=lambda *_a, **_k: False, + ) + runner.adapters = {Platform.TELEGRAM: adapter} + return runner + + +def _fake_tts_call(monkeypatch, audio_bytes=b"\x00" * 32): + """Patch the TTS tool so it writes a real file at the requested path.""" + + def _fake_text_to_speech_tool(*, text, output_path, **_kwargs): + os.makedirs(os.path.dirname(output_path), exist_ok=True) + with open(output_path, "wb") as fh: + fh.write(audio_bytes) + return json.dumps({"success": True, "file_path": output_path}) + + monkeypatch.setattr( + "tools.tts_tool.text_to_speech_tool", + _fake_text_to_speech_tool, + ) + monkeypatch.setattr( + "tools.tts_tool._strip_markdown_for_tts", + lambda text: text, + ) + + +@pytest.mark.asyncio +async def test_voice_reply_marks_metadata_notify_true_for_dm(monkeypatch, tmp_path): + """Final voice reply with no thread metadata gets a fresh notify=True dict.""" + monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path)) + _fake_tts_call(monkeypatch) + + send_voice = AsyncMock() + runner = _runner_with_adapter(send_voice) + event = _make_event() + + await runner._send_voice_reply(event, "Hello there.") + + send_voice.assert_awaited_once() + kwargs = send_voice.await_args.kwargs + assert kwargs["metadata"] is not None, "metadata must be set so notify flag reaches adapter" + assert kwargs["metadata"].get("notify") is True + + +@pytest.mark.asyncio +async def test_voice_reply_marks_existing_thread_metadata_without_mutation(monkeypatch, tmp_path): + """When thread metadata exists (Telegram DM-topic), notify=True is added without mutating the source dict.""" + monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path)) + _fake_tts_call(monkeypatch) + + send_voice = AsyncMock() + runner = _runner_with_adapter(send_voice) + # Use a DM topic source so _thread_metadata_for_source returns a non-None dict. + event = _make_event(thread_id="17585") + source_meta_snapshot = runner._thread_metadata_for_source( + event.source, runner._reply_anchor_for_event(event) + ) + assert source_meta_snapshot is not None + snapshot_copy = dict(source_meta_snapshot) + + await runner._send_voice_reply(event, "Hello there.") + + send_voice.assert_awaited_once() + kwargs = send_voice.await_args.kwargs + assert kwargs["metadata"].get("notify") is True + # All pre-existing thread keys are preserved. + for k, v in snapshot_copy.items(): + assert kwargs["metadata"].get(k) == v + # The freshly-computed source-side metadata must NOT have been mutated + # (would otherwise leak notify=True into the typing-indicator state). + fresh = runner._thread_metadata_for_source( + event.source, runner._reply_anchor_for_event(event) + ) + assert "notify" not in fresh diff --git a/tests/gateway/test_voice_command.py b/tests/gateway/test_voice_command.py index d792a48e0cf..b02b7f72ff5 100644 --- a/tests/gateway/test_voice_command.py +++ b/tests/gateway/test_voice_command.py @@ -463,6 +463,9 @@ class TestSendVoiceReply: "telegram_dm_topic_reply_fallback": True, "direct_messages_topic_id": "20197", "telegram_reply_to_message_id": "462", + # Final voice reply is notify-worthy (issue #27970 Bug 2): + # mirrors the final-text path in gateway/platforms/base.py. + "notify": True, } @pytest.mark.asyncio