fix(telegram): respect reply_to_mode for DM topic reply fallback

The DM topic reply fallback code in send() hardcoded should_thread=True
when telegram_dm_topic_reply_fallback metadata was present, bypassing
_should_thread_reply() and ignoring reply_to_mode config. This caused
quote bubbles on every response even with reply_to_mode: 'off'.

Fix:
- Add reply_to_mode param to _reply_to_message_id_for_send() and
  _thread_kwargs_for_send() classmethods
- In send(), check self._reply_to_mode != 'off' for DM topic fallback
- Suppress reply anchor and reply_to_message_id when mode is 'off'
  while preserving message_thread_id for correct topic routing
- Thread reply_to_mode through all 29 call sites

Regression coverage: 10 new tests in test_telegram_reply_mode.py
covering classmethod behavior, send() integration, and backward
compatibility.

Fixes reply_to_mode: 'off' ignored by Telegram DM topic reply fallback code #23994
This commit is contained in:
liuhao1024 2026-05-12 04:33:34 +08:00 committed by Teknium
parent 7fad501f08
commit 21a15b6711
2 changed files with 150 additions and 13 deletions

View file

@ -304,3 +304,110 @@ class TestTelegramYamlConfigLoading:
load_gateway_config()
assert os.environ.get("TELEGRAM_REPLY_TO_MODE") == "all"
class TestDMTopicFallbackReplyToMode:
"""Tests for reply_to_mode enforcement on DM topic fallback paths.
Regression tests for https://github.com/NousResearch/hermes-agent/issues/23994:
reply_to_mode 'off' was ignored when sending via Hermes-created DM topic
lanes (telegram_dm_topic_reply_fallback metadata), causing quote bubbles
despite the user setting reply_to_mode: 'off'.
"""
DM_TOPIC_METADATA = {
"thread_id": "42",
"telegram_dm_topic_reply_fallback": True,
"telegram_reply_to_message_id": "12345",
}
# -- _reply_to_message_id_for_send classmethod --
def test_reply_to_id_suppressed_when_off(self):
"""reply_to_mode='off' suppresses reply anchor for DM topic fallback."""
result = TelegramAdapter._reply_to_message_id_for_send(
None, self.DM_TOPIC_METADATA, reply_to_mode="off",
)
assert result is None
def test_reply_to_id_returned_when_first(self):
"""reply_to_mode='first' still returns reply anchor for DM topic fallback."""
result = TelegramAdapter._reply_to_message_id_for_send(
None, self.DM_TOPIC_METADATA, reply_to_mode="first",
)
assert result == 12345
def test_reply_to_id_returned_when_all(self):
"""reply_to_mode='all' still returns reply anchor for DM topic fallback."""
result = TelegramAdapter._reply_to_message_id_for_send(
None, self.DM_TOPIC_METADATA, reply_to_mode="all",
)
assert result == 12345
def test_reply_to_id_returned_when_no_mode(self):
"""Without reply_to_mode, behavior is unchanged (backward compat)."""
result = TelegramAdapter._reply_to_message_id_for_send(
None, self.DM_TOPIC_METADATA,
)
assert result == 12345
def test_explicit_reply_to_overrides_mode(self):
"""Explicit reply_to param always wins, regardless of mode."""
result = TelegramAdapter._reply_to_message_id_for_send(
"999", self.DM_TOPIC_METADATA, reply_to_mode="off",
)
assert result == 999
# -- _thread_kwargs_for_send classmethod --
def test_thread_kwargs_suppressed_reply_anchor_when_off(self):
"""reply_to_mode='off' returns thread_id without reply anchor."""
result = TelegramAdapter._thread_kwargs_for_send(
"100", "42", self.DM_TOPIC_METADATA,
reply_to_message_id=None, reply_to_mode="off",
)
assert result == {"message_thread_id": 42}
def test_thread_kwargs_returns_full_when_first(self):
"""reply_to_mode='first' returns thread_id (reply anchor in send kwargs)."""
result = TelegramAdapter._thread_kwargs_for_send(
"100", "42", self.DM_TOPIC_METADATA,
reply_to_message_id=12345, reply_to_mode="first",
)
assert result == {"message_thread_id": 42}
def test_thread_kwargs_no_mode_backward_compat(self):
"""Without reply_to_mode, behavior is unchanged."""
result = TelegramAdapter._thread_kwargs_for_send(
"100", "42", self.DM_TOPIC_METADATA,
reply_to_message_id=12345,
)
assert result == {"message_thread_id": 42}
# -- send() integration test --
@pytest.mark.asyncio
async def test_send_dm_topic_off_no_quote(self, adapter_factory):
"""send() with DM topic fallback and reply_to_mode='off' skips reply."""
adapter = adapter_factory(reply_to_mode="off")
adapter._bot = MagicMock()
adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1"]
await adapter.send("12345", "test content", metadata=self.DM_TOPIC_METADATA)
call = adapter._bot.send_message.call_args_list[0]
assert call.kwargs.get("reply_to_message_id") is None
@pytest.mark.asyncio
async def test_send_dm_topic_first_still_quotes(self, adapter_factory):
"""send() with DM topic fallback and reply_to_mode='first' still quotes."""
adapter = adapter_factory(reply_to_mode="first")
adapter._bot = MagicMock()
adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1"]
await adapter.send("12345", "test content", metadata=self.DM_TOPIC_METADATA)
call = adapter._bot.send_message.call_args_list[0]
assert call.kwargs.get("reply_to_message_id") == 12345