fix(telegram): preserve DM topic routing via reply fallback

This commit is contained in:
Jhin Lee 2026-05-08 21:33:20 -04:00 committed by kshitij
parent 28b5bd7e93
commit b3239572f0
6 changed files with 1331 additions and 152 deletions

View file

@ -108,6 +108,38 @@ class TestHandleBackgroundCommand:
assert "Summarize the top HN stories" in result
assert len(created_tasks) == 1 # background task was created
@pytest.mark.asyncio
async def test_telegram_dm_topic_passes_trigger_anchor_to_task(self):
"""Telegram private-topic completion sends need the original command message id."""
runner = _make_runner()
runner._run_background_task = AsyncMock()
def capture_task(coro, *args, **kwargs):
coro.close()
mock_task = MagicMock()
return mock_task
source = SessionSource(
platform=Platform.TELEGRAM,
user_id="12345",
chat_id="67890",
chat_type="dm",
thread_id="20197",
)
event = MessageEvent(
text="/background summarize",
source=source,
message_id="463",
reply_to_message_id="462",
)
with patch("gateway.run.asyncio.create_task", side_effect=capture_task):
result = await runner._handle_background_command(event)
assert "Background task started" in result
runner._run_background_task.assert_called_once()
assert runner._run_background_task.call_args.kwargs["event_message_id"] == "463"
@pytest.mark.asyncio
async def test_prompt_truncated_in_preview(self):
"""Long prompts are truncated to 60 chars in the confirmation message."""
@ -236,6 +268,57 @@ class TestRunBackgroundTask:
mock_agent_instance.shutdown_memory_provider.assert_called_once()
mock_agent_instance.close.assert_called_once()
@pytest.mark.asyncio
async def test_telegram_dm_topic_completion_preserves_reply_anchor_metadata(self, monkeypatch):
"""Background completion metadata must let Telegram send thread id plus reply id."""
from gateway import run as gateway_run
runner = _make_runner()
runner._resolve_session_agent_runtime = MagicMock(
return_value=("test-model", {"api_key": "test-key"})
)
runner._resolve_session_reasoning_config = MagicMock(return_value=None)
runner._load_service_tier = MagicMock(return_value=None)
runner._resolve_turn_agent_config = MagicMock(
return_value={
"model": "test-model",
"runtime": {"api_key": "test-key"},
"request_overrides": None,
}
)
runner._run_in_executor_with_context = AsyncMock(
return_value={"final_response": "done", "messages": []}
)
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
mock_adapter = AsyncMock()
mock_adapter.send = AsyncMock()
mock_adapter.extract_media = MagicMock(return_value=([], "done"))
mock_adapter.extract_images = MagicMock(return_value=([], "done"))
runner.adapters[Platform.TELEGRAM] = mock_adapter
source = SessionSource(
platform=Platform.TELEGRAM,
user_id="12345",
chat_id="67890",
chat_type="dm",
thread_id="20197",
)
await runner._run_background_task(
"say hello",
source,
"bg_test",
event_message_id="463",
)
mock_adapter.send.assert_called_once()
assert mock_adapter.send.call_args.kwargs["metadata"] == {
"thread_id": "20197",
"telegram_dm_topic_reply_fallback": True,
"telegram_reply_to_message_id": "463",
}
@pytest.mark.asyncio
async def test_agent_cleanup_runs_when_background_agent_raises(self):
"""Temporary background agents must be cleaned up on error paths too."""

View file

@ -1,13 +1,11 @@
"""Tests for Telegram send() thread_id fallback.
"""Tests for Telegram topic/thread routing fallbacks.
When message_thread_id points to a non-existent thread, Telegram returns
BadRequest('Message thread not found'). Since BadRequest is a subclass of
NetworkError in python-telegram-bot, the old retry loop treated this as a
transient error and retried 3 times before silently failing killing all
tool progress messages, streaming responses, and typing indicators.
The fix detects "thread not found" BadRequest errors and retries the send
WITHOUT message_thread_id so the message still reaches the chat.
Supergroup forum topics route with ``message_thread_id``. Hermes-created
private DM topic lanes are different: live Telegram testing showed they only
stay in the expected lane when sends include both the private topic
``message_thread_id`` and a ``reply_to_message_id`` anchor to the triggering
user message. If either anchor is unavailable or rejected, the adapter must
avoid retrying with a partial topic route that can render outside the lane.
"""
import sys
@ -17,7 +15,14 @@ from types import SimpleNamespace
import pytest
from gateway.config import PlatformConfig, Platform
from gateway.platforms.base import SendResult
from gateway.platforms.base import (
MessageEvent,
MessageType,
SendResult,
_reply_anchor_for_event,
_thread_metadata_for_source,
)
from gateway.session import build_session_key
# ── Fake telegram.error hierarchy ──────────────────────────────────────
@ -44,23 +49,48 @@ class FakeRetryAfter(Exception):
# Build a fake telegram module tree so the adapter's internal imports work
class _FakeInlineKeyboardButton:
def __init__(self, text, callback_data=None, **kwargs):
self.text = text
self.callback_data = callback_data
self.kwargs = kwargs
class _FakeInlineKeyboardMarkup:
def __init__(self, inline_keyboard):
self.inline_keyboard = inline_keyboard
class _FakeInputMediaPhoto:
def __init__(self, media, caption=None, **kwargs):
self.media = media
self.caption = caption
self.kwargs = kwargs
_fake_telegram = types.ModuleType("telegram")
_fake_telegram.Update = object
_fake_telegram.Bot = object
_fake_telegram.Message = object
_fake_telegram.InlineKeyboardButton = object
_fake_telegram.InlineKeyboardMarkup = object
_fake_telegram.InlineKeyboardButton = _FakeInlineKeyboardButton
_fake_telegram.InlineKeyboardMarkup = _FakeInlineKeyboardMarkup
_fake_telegram.InputMediaPhoto = _FakeInputMediaPhoto
_fake_telegram_error = types.ModuleType("telegram.error")
_fake_telegram_error.NetworkError = FakeNetworkError
_fake_telegram_error.BadRequest = FakeBadRequest
_fake_telegram_error.TimedOut = FakeTimedOut
_fake_telegram.error = _fake_telegram_error
_fake_telegram_constants = types.ModuleType("telegram.constants")
_fake_telegram_constants.ParseMode = SimpleNamespace(MARKDOWN_V2="MarkdownV2")
_fake_telegram_constants.ParseMode = SimpleNamespace(
MARKDOWN_V2="MarkdownV2",
MARKDOWN="Markdown",
HTML="HTML",
)
_fake_telegram_constants.ChatType = SimpleNamespace(
GROUP="group",
SUPERGROUP="supergroup",
CHANNEL="channel",
PRIVATE="private",
)
_fake_telegram.constants = _fake_telegram_constants
_fake_telegram_ext = types.ModuleType("telegram.ext")
@ -235,6 +265,626 @@ async def test_send_retries_without_thread_on_thread_not_found():
assert call_log[1]["message_thread_id"] is None
@pytest.mark.asyncio
async def test_send_private_dm_topic_uses_direct_messages_topic_id():
"""Private Telegram topics route sends via direct_messages_topic_id."""
adapter = _make_adapter()
call_log = []
async def mock_send_message(**kwargs):
call_log.append(dict(kwargs))
return SimpleNamespace(message_id=42)
adapter._bot = SimpleNamespace(send_message=mock_send_message)
result = await adapter.send(
chat_id="123",
content="test message",
metadata={"thread_id": "99999", "direct_messages_topic_id": "99999"},
)
assert result.success is True
assert call_log[0]["message_thread_id"] is None
assert call_log[0]["direct_messages_topic_id"] == 99999
def test_base_gateway_metadata_marks_telegram_dm_topics_as_reply_fallback():
source = SimpleNamespace(
platform=Platform.TELEGRAM,
chat_type="dm",
thread_id="20189",
)
metadata = _thread_metadata_for_source(source, "462")
assert metadata == {
"thread_id": "20189",
"telegram_dm_topic_reply_fallback": True,
"telegram_reply_to_message_id": "462",
}
def test_base_gateway_replies_to_triggering_message_for_telegram_dm_topic():
"""Private DM topic lanes should anchor replies to the active user message."""
event = SimpleNamespace(
message_id="463",
reply_to_message_id="462",
source=SimpleNamespace(
platform=Platform.TELEGRAM,
chat_type="dm",
thread_id="20189",
),
)
assert _reply_anchor_for_event(event) == "463"
@pytest.mark.asyncio
async def test_gateway_runner_busy_ack_replies_to_triggering_message_for_telegram_dm_topic(monkeypatch, tmp_path):
"""GatewayRunner's duplicate thread metadata must match the base helper."""
from gateway import run as gateway_run
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
GatewayRunner = gateway_run.GatewayRunner
class BusyAdapter:
def __init__(self):
self._pending_messages = {}
self.calls = []
async def _send_with_retry(self, **kwargs):
self.calls.append(kwargs)
return SendResult(success=True, message_id="ack-1")
class BusyAgent:
def interrupt(self, _text):
return None
def get_activity_summary(self):
return {}
source = SimpleNamespace(
platform=Platform.TELEGRAM,
chat_id="12345",
chat_type="dm",
thread_id="20197",
user_id="user-1",
)
event = MessageEvent(
text="busy follow-up",
message_type=MessageType.TEXT,
source=source,
message_id="463",
reply_to_message_id="462",
)
session_key = build_session_key(source)
adapter = BusyAdapter()
runner = object.__new__(GatewayRunner)
runner.adapters = {Platform.TELEGRAM: adapter}
runner._running_agents = {session_key: BusyAgent()}
runner._running_agents_ts = {}
runner._pending_messages = {}
runner._busy_ack_ts = {}
runner._draining = False
runner._busy_input_mode = "interrupt"
runner._is_user_authorized = lambda _source: True
assert await runner._handle_active_session_busy_message(event, session_key) is True
assert adapter.calls
assert adapter.calls[0]["reply_to"] == "463"
assert adapter.calls[0]["metadata"] == {
"thread_id": "20197",
"telegram_dm_topic_reply_fallback": True,
"telegram_reply_to_message_id": "463",
}
@pytest.mark.asyncio
async def test_send_uses_reply_fallback_for_hermes_dm_topics():
"""Hermes-created Telegram DM topics route with thread id plus reply anchor."""
adapter = _make_adapter()
call_log = []
async def mock_send_message(**kwargs):
call_log.append(kwargs)
return SimpleNamespace(message_id=777)
adapter._bot = SimpleNamespace(send_message=mock_send_message)
result = await adapter.send(
chat_id="123",
content="test message",
reply_to="462",
metadata={
"thread_id": "20197",
"telegram_dm_topic_reply_fallback": True,
},
)
assert result.success is True
assert call_log[0]["reply_to_message_id"] == 462
assert call_log[0]["message_thread_id"] == 20197
assert "direct_messages_topic_id" not in call_log[0]
@pytest.mark.asyncio
async def test_send_uses_metadata_reply_fallback_for_streaming_dm_topics():
"""Metadata-only sends still stay in Hermes-created Telegram DM topics."""
adapter = _make_adapter()
call_log = []
async def mock_send_message(**kwargs):
call_log.append(kwargs)
return SimpleNamespace(message_id=778)
adapter._bot = SimpleNamespace(send_message=mock_send_message)
result = await adapter.send(
chat_id="123",
content="streamed text",
metadata={
"thread_id": "20197",
"telegram_dm_topic_reply_fallback": True,
"telegram_reply_to_message_id": "462",
},
)
assert result.success is True
assert call_log[0]["reply_to_message_id"] == 462
assert call_log[0]["message_thread_id"] == 20197
assert "direct_messages_topic_id" not in call_log[0]
@pytest.mark.asyncio
async def test_send_reply_fallback_applies_to_every_chunk_for_dm_topics():
"""Long Telegram DM-topic fallback sends must anchor every chunk."""
adapter = _make_adapter()
call_log = []
async def mock_send_message(**kwargs):
call_log.append(dict(kwargs))
return SimpleNamespace(message_id=len(call_log))
adapter._bot = SimpleNamespace(send_message=mock_send_message)
result = await adapter.send(
chat_id="123",
content="A" * 5000,
metadata={
"thread_id": "20197",
"telegram_dm_topic_reply_fallback": True,
"telegram_reply_to_message_id": "462",
},
)
assert result.success is True
assert len(call_log) > 1
assert all(call["reply_to_message_id"] == 462 for call in call_log)
assert all(call["message_thread_id"] == 20197 for call in call_log)
assert all("direct_messages_topic_id" not in call for call in call_log)
@pytest.mark.asyncio
async def test_send_model_picker_uses_metadata_reply_fallback_for_dm_topics():
"""Inline keyboard sends also consume the metadata reply fallback."""
adapter = _make_adapter()
adapter._model_picker_state = {}
call_log = []
async def mock_send_message(**kwargs):
call_log.append(kwargs)
return SimpleNamespace(message_id=779)
adapter._bot = SimpleNamespace(send_message=mock_send_message)
result = await adapter.send_model_picker(
chat_id="123",
providers=[{"name": "OpenAI", "slug": "openai", "models": [], "total_models": 0}],
current_model="gpt-test",
current_provider="openai",
session_key="telegram:123:20197",
on_model_selected=lambda *_: None,
metadata={
"thread_id": "20197",
"telegram_dm_topic_reply_fallback": True,
"telegram_reply_to_message_id": "462",
},
)
assert result.success is True
assert call_log[0]["reply_to_message_id"] == 462
assert call_log[0]["message_thread_id"] == 20197
assert "direct_messages_topic_id" not in call_log[0]
@pytest.mark.asyncio
async def test_send_dm_topic_fallback_without_anchor_does_not_crash():
"""DM-topic fallback without an anchor must not use message_thread_id alone."""
adapter = _make_adapter()
call_log = []
async def mock_send_message(**kwargs):
call_log.append(dict(kwargs))
return SimpleNamespace(message_id=780)
adapter._bot = SimpleNamespace(send_message=mock_send_message)
result = await adapter.send(
chat_id="123",
content="source-only send",
metadata={
"thread_id": "20197",
"telegram_dm_topic_reply_fallback": True,
},
)
assert result.success is True
assert call_log[0]["reply_to_message_id"] is None
assert "message_thread_id" not in call_log[0]
assert "direct_messages_topic_id" not in call_log[0]
@pytest.mark.asyncio
async def test_send_dm_topic_reply_not_found_retry_drops_thread_id():
"""If Telegram deletes the reply anchor, private-topic retry must drop thread id too."""
adapter = _make_adapter()
call_log = []
async def mock_send_message(**kwargs):
call_log.append(dict(kwargs))
if len(call_log) == 1:
raise FakeBadRequest("Message to be replied not found")
return SimpleNamespace(message_id=781)
adapter._bot = SimpleNamespace(send_message=mock_send_message)
result = await adapter.send(
chat_id="123",
content="anchor disappeared",
metadata={
"thread_id": "20197",
"telegram_dm_topic_reply_fallback": True,
"telegram_reply_to_message_id": "462",
},
)
assert result.success is True
assert call_log[0]["reply_to_message_id"] == 462
assert call_log[0]["message_thread_id"] == 20197
assert call_log[1]["reply_to_message_id"] is None
assert "message_thread_id" not in call_log[1]
assert "direct_messages_topic_id" not in call_log[1]
@pytest.mark.asyncio
@pytest.mark.parametrize(
("method_name", "bot_method_name", "path_kw", "filename", "payload"),
[
("send_image_file", "send_photo", "image_path", "photo.png", b"png-data"),
("send_document", "send_document", "file_path", "report.txt", b"report-data"),
("send_video", "send_video", "video_path", "clip.mp4", b"video-data"),
("send_voice", "send_voice", "audio_path", "clip.ogg", b"ogg-data"),
("send_voice", "send_audio", "audio_path", "clip.mp3", b"mp3-data"),
],
)
async def test_native_media_dm_topic_reply_not_found_retry_drops_thread_id(
tmp_path,
method_name,
bot_method_name,
path_kw,
filename,
payload,
):
adapter = _make_adapter()
media_path = tmp_path / filename
media_path.write_bytes(payload)
call_log = []
async def mock_send_media(**kwargs):
call_log.append(dict(kwargs))
if len(call_log) == 1:
raise FakeBadRequest("Message to be replied not found")
return SimpleNamespace(message_id=782)
adapter._bot = SimpleNamespace(**{bot_method_name: mock_send_media})
result = await getattr(adapter, method_name)(
chat_id="123",
**{path_kw: str(media_path)},
metadata={
"thread_id": "20197",
"telegram_dm_topic_reply_fallback": True,
"telegram_reply_to_message_id": "462",
},
)
assert result.success is True
assert call_log[0]["reply_to_message_id"] == 462
assert call_log[0]["message_thread_id"] == 20197
assert call_log[1]["reply_to_message_id"] is None
assert "message_thread_id" not in call_log[1]
assert "direct_messages_topic_id" not in call_log[1]
@pytest.mark.asyncio
async def test_animation_dm_topic_reply_not_found_retry_drops_thread_id():
adapter = _make_adapter()
call_log = []
async def mock_send_animation(**kwargs):
call_log.append(dict(kwargs))
if len(call_log) == 1:
raise FakeBadRequest("Message to be replied not found")
return SimpleNamespace(message_id=786)
adapter._bot = SimpleNamespace(send_animation=mock_send_animation)
result = await adapter.send_animation(
chat_id="123",
animation_url="https://example.com/anim.gif",
metadata={
"thread_id": "20197",
"telegram_dm_topic_reply_fallback": True,
"telegram_reply_to_message_id": "462",
},
)
assert result.success is True
assert call_log[0]["reply_to_message_id"] == 462
assert call_log[0]["message_thread_id"] == 20197
assert call_log[1]["reply_to_message_id"] is None
assert "message_thread_id" not in call_log[1]
assert "direct_messages_topic_id" not in call_log[1]
@pytest.mark.asyncio
async def test_media_group_dm_topic_reply_not_found_retry_drops_thread_id(tmp_path):
adapter = _make_adapter()
image_path = tmp_path / "photo.png"
image_path.write_bytes(b"png-data")
call_log = []
async def mock_send_media_group(**kwargs):
call_log.append(dict(kwargs))
if len(call_log) == 1:
raise FakeBadRequest("Message to be replied not found")
return [SimpleNamespace(message_id=783)]
adapter._bot = SimpleNamespace(send_media_group=mock_send_media_group)
await adapter.send_multiple_images(
chat_id="123",
images=[(f"file://{image_path}", "caption")],
metadata={
"thread_id": "20197",
"telegram_dm_topic_reply_fallback": True,
"telegram_reply_to_message_id": "462",
},
)
assert call_log[0]["reply_to_message_id"] == 462
assert call_log[0]["message_thread_id"] == 20197
assert call_log[1]["reply_to_message_id"] is None
assert "message_thread_id" not in call_log[1]
assert "direct_messages_topic_id" not in call_log[1]
@pytest.mark.asyncio
async def test_send_image_url_dm_topic_reply_not_found_retry_drops_thread_id(monkeypatch):
adapter = _make_adapter()
call_log = []
async def mock_send_photo(**kwargs):
call_log.append(dict(kwargs))
if len(call_log) == 1:
raise FakeBadRequest("Message to be replied not found")
return SimpleNamespace(message_id=784)
adapter._bot = SimpleNamespace(send_photo=mock_send_photo)
import tools.url_safety as url_safety
monkeypatch.setattr(url_safety, "is_safe_url", lambda _url: True)
result = await adapter.send_image(
chat_id="123",
image_url="https://example.com/photo.png",
metadata={
"thread_id": "20197",
"telegram_dm_topic_reply_fallback": True,
"telegram_reply_to_message_id": "462",
},
)
assert result.success is True
assert call_log[0]["reply_to_message_id"] == 462
assert call_log[0]["message_thread_id"] == 20197
assert call_log[1]["reply_to_message_id"] is None
assert "message_thread_id" not in call_log[1]
assert "direct_messages_topic_id" not in call_log[1]
@pytest.mark.asyncio
async def test_send_image_upload_dm_topic_reply_not_found_retry_drops_thread_id(monkeypatch):
adapter = _make_adapter()
call_log = []
async def mock_send_photo(**kwargs):
call_log.append(dict(kwargs))
if len(call_log) == 1:
raise RuntimeError("URL is too large")
if len(call_log) == 2:
raise FakeBadRequest("Message to be replied not found")
return SimpleNamespace(message_id=785)
class _FakeResponse:
content = b"image-data"
def raise_for_status(self):
return None
class _FakeAsyncClient:
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return None
async def get(self, _url):
return _FakeResponse()
monkeypatch.setitem(
sys.modules,
"httpx",
SimpleNamespace(AsyncClient=_FakeAsyncClient),
)
adapter._bot = SimpleNamespace(send_photo=mock_send_photo)
import tools.url_safety as url_safety
monkeypatch.setattr(url_safety, "is_safe_url", lambda _url: True)
result = await adapter.send_image(
chat_id="123",
image_url="https://example.com/photo.png",
metadata={
"thread_id": "20197",
"telegram_dm_topic_reply_fallback": True,
"telegram_reply_to_message_id": "462",
},
)
assert result.success is True
assert call_log[0]["reply_to_message_id"] == 462
assert call_log[0]["message_thread_id"] == 20197
assert call_log[1]["reply_to_message_id"] == 462
assert call_log[1]["message_thread_id"] == 20197
assert call_log[2]["reply_to_message_id"] is None
assert "message_thread_id" not in call_log[2]
assert "direct_messages_topic_id" not in call_log[2]
@pytest.mark.asyncio
async def test_slash_confirm_private_topic_callback_followup_sends_thread_and_reply(monkeypatch):
adapter = _make_adapter()
adapter._slash_confirm_state = {"confirm-1": "session-1"}
adapter._is_callback_user_authorized = lambda *args, **kwargs: True
call_log = []
async def mock_send_message(**kwargs):
call_log.append(dict(kwargs))
return SimpleNamespace(message_id=9001)
async def resolve(_session_key, _confirm_id, _choice):
return "done"
from tools import slash_confirm
monkeypatch.setattr(slash_confirm, "resolve", resolve)
adapter._bot = SimpleNamespace(send_message=mock_send_message)
class Query:
data = "sc:once:confirm-1"
from_user = SimpleNamespace(id=42, first_name="Alice")
message = SimpleNamespace(
chat_id=12345,
chat=SimpleNamespace(type=_fake_telegram_constants.ChatType.PRIVATE),
message_thread_id=20197,
message_id=462,
)
async def answer(self, **kwargs):
return None
async def edit_message_text(self, **kwargs):
return None
await adapter._handle_callback_query(SimpleNamespace(callback_query=Query()), SimpleNamespace())
assert call_log
assert call_log[0]["message_thread_id"] == 20197
assert call_log[0]["reply_to_message_id"] == 462
@pytest.mark.asyncio
async def test_slash_confirm_forum_callback_followup_keeps_existing_thread_behavior(monkeypatch):
adapter = _make_adapter()
adapter._slash_confirm_state = {"confirm-1": "session-1"}
adapter._is_callback_user_authorized = lambda *args, **kwargs: True
call_log = []
async def mock_send_message(**kwargs):
call_log.append(dict(kwargs))
return SimpleNamespace(message_id=9001)
async def resolve(_session_key, _confirm_id, _choice):
return "done"
from tools import slash_confirm
monkeypatch.setattr(slash_confirm, "resolve", resolve)
adapter._bot = SimpleNamespace(send_message=mock_send_message)
class Query:
data = "sc:once:confirm-1"
from_user = SimpleNamespace(id=42, first_name="Alice")
message = SimpleNamespace(
chat_id=-100123,
chat=SimpleNamespace(type=_fake_telegram_constants.ChatType.SUPERGROUP),
message_thread_id=20197,
message_id=462,
)
async def answer(self, **kwargs):
return None
async def edit_message_text(self, **kwargs):
return None
await adapter._handle_callback_query(SimpleNamespace(callback_query=Query()), SimpleNamespace())
assert call_log
assert call_log[0]["message_thread_id"] == 20197
assert "reply_to_message_id" not in call_log[0]
assert "direct_messages_topic_id" not in call_log[0]
@pytest.mark.asyncio
async def test_base_send_image_fallback_preserves_metadata():
"""Base image fallback should pass metadata through instead of referencing kwargs."""
from gateway.platforms.base import BasePlatformAdapter
class _ConcreteBaseAdapter(BasePlatformAdapter):
async def connect(self):
return True
async def disconnect(self):
return None
async def send(self, **kwargs):
call_log.append(kwargs)
return SendResult(success=True, message_id="781")
async def get_chat_info(self, chat_id):
return None
call_log = []
adapter = _ConcreteBaseAdapter(Platform.TELEGRAM, None)
metadata = {"thread_id": "20197"}
result = await adapter.send_image(
chat_id="123",
image_url="https://example.invalid/image.png",
metadata=metadata,
)
assert result.success is True
assert call_log[0]["metadata"] is metadata
@pytest.mark.asyncio
async def test_send_raises_on_other_bad_request():
"""Non-thread BadRequest errors should NOT be retried — they fail immediately."""

View file

@ -433,6 +433,37 @@ class TestSendVoiceReply:
call_args = mock_adapter.send_voice.call_args
assert call_args.kwargs.get("chat_id") == "123"
@pytest.mark.asyncio
async def test_auto_voice_reply_uses_thread_metadata_helper(self, runner):
from gateway.config import Platform
mock_adapter = AsyncMock()
mock_adapter.send_voice = AsyncMock()
event = _make_event()
event.source.platform = Platform.TELEGRAM
event.source.chat_type = "dm"
event.source.thread_id = "20197"
event.message_id = "462"
runner.adapters[event.source.platform] = mock_adapter
tts_result = json.dumps({"success": True, "file_path": "/tmp/test.ogg"})
with patch("tools.tts_tool.text_to_speech_tool", return_value=tts_result), \
patch("tools.tts_tool._strip_markdown_for_tts", side_effect=lambda t: t), \
patch("os.path.isfile", return_value=True), \
patch("os.unlink"), \
patch("os.makedirs"):
await runner._send_voice_reply(event, "Hello world")
mock_adapter.send_voice.assert_called_once()
call_kwargs = mock_adapter.send_voice.call_args.kwargs
assert call_kwargs["reply_to"] == "462"
assert call_kwargs["metadata"] == {
"thread_id": "20197",
"telegram_dm_topic_reply_fallback": True,
"telegram_reply_to_message_id": "462",
}
@pytest.mark.asyncio
async def test_empty_text_after_strip_skips(self, runner):
event = _make_event()