Skip to content
Merged
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
18 changes: 10 additions & 8 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,20 @@ def _thread_metadata_for_source(source, reply_to_message_id: str | None = None)

Most platforms route threaded sends with a generic ``thread_id`` metadata
value. Telegram private-chat topics created through Hermes' DM-topic helper
are exposed in updates as ``message_thread_id`` plus a reply anchor, but
outbound sends only render in the correct Telegram lane when the adapter
supplies both ``message_thread_id`` and ``reply_to_message_id``. Mark those
lanes so the Telegram adapter can avoid the known-bad partial routes.
are exposed in updates as ``message_thread_id`` plus a reply anchor. Live
user-message replies route with ``message_thread_id`` + ``reply_to_message_id``;
synthetic/resumed sends that have no reply anchor fall back to Telegram's
``direct_messages_topic_id`` when the Bot API supports it.
"""
thread_id = getattr(source, "thread_id", None)
if thread_id is None:
return None
metadata = {"thread_id": thread_id}
if _platform_name(getattr(source, "platform", None)) == "telegram" and getattr(source, "chat_type", None) == "dm":
metadata["telegram_dm_topic_reply_fallback"] = True
tid = str(thread_id)
if tid and tid not in {"", "1"}:
metadata["direct_messages_topic_id"] = tid
anchor = reply_to_message_id or getattr(source, "message_id", None)
if anchor is not None:
metadata["telegram_reply_to_message_id"] = str(anchor)
Expand All @@ -67,10 +70,9 @@ def _reply_anchor_for_event(event) -> str | None:

Telegram forum/supergroup topics should be routed by topic metadata, not by
replying to the triggering message. Hermes-created Telegram private-chat
topic lanes are different: Bot API sends reject their ``message_thread_id``
and do not route with ``direct_messages_topic_id``. Those lanes only remain
visible when sent with both the private topic thread id and a reply to the
triggering user message.
topic lanes prefer replying to the triggering user message so the answer
stays attached to the active lane; synthetic/resumed sends fall back to
``direct_messages_topic_id`` metadata when no message id is available.
"""
source = getattr(event, "source", None)
platform = _platform_name(getattr(source, "platform", None))
Expand Down
13 changes: 10 additions & 3 deletions gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -560,9 +560,10 @@ def _thread_kwargs_for_send(
Supergroup/forum topics use ``message_thread_id``. True Bot API Direct
Messages topics can opt in with explicit ``direct_messages_topic_id``
metadata. Hermes-created private-chat topic lanes are marked with
``telegram_dm_topic_reply_fallback`` and must send the private topic
thread id together with a reply anchor. Live testing showed that either
parameter alone can render outside the visible lane.
``telegram_dm_topic_reply_fallback``. Live replies send the private
topic thread id together with a reply anchor; synthetic/resumed sends
without an anchor use ``direct_messages_topic_id`` when metadata has it.
``message_thread_id`` alone can render outside the visible lane.

When ``reply_to_mode`` is ``"off"``, the reply anchor is suppressed for
DM topic fallback sends while preserving the ``message_thread_id`` so
Expand All @@ -574,6 +575,12 @@ def _thread_kwargs_for_send(
if reply_to_message_id is None:
reply_to_message_id = cls._metadata_reply_to_message_id(metadata)
if reply_to_message_id is None:
direct_topic_id = cls._metadata_direct_messages_topic_id(metadata)
if direct_topic_id is not None:
return {
"message_thread_id": None,
"direct_messages_topic_id": int(direct_topic_id),
}
return {}
return {"message_thread_id": cls._message_thread_id_for_send(thread_id)}
direct_topic_id = cls._metadata_direct_messages_topic_id(metadata)
Expand Down
25 changes: 22 additions & 3 deletions tests/gateway/test_telegram_thread_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,10 +331,28 @@ def test_base_gateway_metadata_marks_telegram_dm_topics_as_reply_fallback():
assert metadata == {
"thread_id": "20189",
"telegram_dm_topic_reply_fallback": True,
"direct_messages_topic_id": "20189",
"telegram_reply_to_message_id": "462",
}


def test_base_gateway_metadata_for_resumed_telegram_dm_topic_uses_direct_topic():
"""Resumed/synthetic DM-topic events may have no reply anchor."""
source = SimpleNamespace(
platform=Platform.TELEGRAM,
chat_type="dm",
thread_id="20189",
)

metadata = _thread_metadata_for_source(source)

assert metadata == {
"thread_id": "20189",
"telegram_dm_topic_reply_fallback": True,
"direct_messages_topic_id": "20189",
}


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(
Expand Down Expand Up @@ -533,7 +551,7 @@ async def mock_send_message(**kwargs):

@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."""
"""DM-topic fallback without an anchor uses direct topic routing."""
adapter = _make_adapter()
call_log = []

Expand All @@ -549,13 +567,14 @@ async def mock_send_message(**kwargs):
metadata={
"thread_id": "20197",
"telegram_dm_topic_reply_fallback": True,
"direct_messages_topic_id": "20197",
},
)

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]
assert call_log[0]["message_thread_id"] is None
assert call_log[0]["direct_messages_topic_id"] == 20197


@pytest.mark.asyncio
Expand Down
Loading