fix(gateway): bind Telegram handoffs to DM topics

This commit is contained in:
Hoang V. Pham 2026-05-21 18:39:28 +07:00 committed by Teknium
parent bcfc7458fa
commit 8341b72122
2 changed files with 69 additions and 13 deletions

View file

@ -6814,26 +6814,39 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
str(home.thread_id) if home.thread_id else None
)
# Determine chat_type for the destination source. If we created a
# thread, key the session_key as a thread (build_session_key sets
# thread sessions to user-shared by default, which is what we
# want — the synthetic turn and any later real-user message both
# land on the same key without needing a user_id).
if new_thread_id:
# Determine chat_type/user_id for the destination source.
#
# Telegram private-chat DM topics are represented differently from
# group/forum threads by the inbound adapter. A handoff-created topic
# in a positive Telegram chat_id must therefore use the same DM-topic
# source shape as the user's next real message; otherwise the synthetic
# handoff turn binds a generic `thread` session key while real replies
# arrive on a `dm` session key.
home_chat_id = str(home.chat_id)
is_telegram_private_chat = False
if platform == Platform.TELEGRAM:
try:
is_telegram_private_chat = int(home_chat_id) > 0
except (TypeError, ValueError):
is_telegram_private_chat = False
if new_thread_id and not is_telegram_private_chat:
dest_chat_type = "thread"
dest_user_id = "system:handoff"
else:
# No thread — assume DM-style for the home channel. For
# group/channel home channels without thread support
# (Matrix/WhatsApp/Signal), the platform's own keying makes
# the synthetic turn shared anyway (single-DM platforms).
# No thread — assume DM-style for the home channel. For Telegram
# private-chat topics, use the real user id (same as chat_id) so
# topic-mode checks and binding persistence see the same identity as
# subsequent inbound user messages.
dest_chat_type = "dm"
dest_user_id = home_chat_id if is_telegram_private_chat else "system:handoff"
dest_source = SessionSource(
platform=platform,
chat_id=str(home.chat_id),
chat_id=home_chat_id,
chat_name=home.name,
chat_type=dest_chat_type,
user_id="system:handoff",
user_id=dest_user_id,
user_name="Handoff",
thread_id=effective_thread_id,
)

View file

@ -11,7 +11,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from hermes_state import SessionDB
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.config import GatewayConfig, HomeChannel, Platform, PlatformConfig
from gateway.platforms.base import MessageEvent
from gateway.session import SessionEntry, SessionSource, build_session_key
@ -800,6 +800,49 @@ async def test_first_message_inside_topic_records_topic_binding(tmp_path, monkey
@pytest.mark.asyncio
async def test_handoff_to_telegram_dm_topic_uses_dm_lane_not_generic_thread(tmp_path):
"""Handoff-created Telegram DM topics must use the real DM-topic lane.
A positive Telegram chat_id is a private chat. If handoff treats the new
topic as generic chat_type="thread" with user_id="system:handoff", the
synthetic turn lands under agent:...:thread:chat:topic while real user
replies arrive as chat_type="dm" with user_id=chat_id. Recovery then sees
the topic as unbound and can rewrite it to another recent topic.
"""
session_db = SessionDB(db_path=tmp_path / "state.db")
session_db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988")
runner = _make_runner(session_db=session_db)
runner.config.platforms[Platform.TELEGRAM].home_channel = HomeChannel(
platform=Platform.TELEGRAM,
chat_id="208214988",
name="Tester DM",
)
adapter = runner.adapters[Platform.TELEGRAM]
adapter.create_handoff_thread = AsyncMock(return_value="17585")
adapter.send.return_value = SimpleNamespace(success=True)
captured = {}
async def fake_handle_message(event):
captured["source"] = event.source
return "handoff ok"
runner._handle_message = AsyncMock(side_effect=fake_handle_message)
await runner._process_handoff({
"id": "cli-session",
"title": "CLI work",
"handoff_platform": "telegram",
})
expected_source = _make_source(thread_id="17585")
expected_key = build_session_key(expected_source)
runner.session_store.switch_session.assert_called_once_with(expected_key, "cli-session")
assert captured["source"].chat_type == "dm"
assert captured["source"].user_id == "208214988"
assert captured["source"].thread_id == "17585"
@pytest.mark.asyncio
async def test_topic_root_command_creates_and_pins_system_topic(tmp_path, monkeypatch):
import gateway.run as gateway_run