diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 1d04176c294b..338859ed2599 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -296,6 +296,9 @@ class MessageEvent: reply_to_message_id: Optional[str] = None reply_to_text: Optional[str] = None # Text of the replied-to message (for context injection) + # Auto-loaded skill for topic/channel bindings (e.g., Telegram DM Topics) + auto_skill: Optional[str] = None + # Timestamps timestamp: datetime = field(default_factory=datetime.now) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index b64ac2b3a5df..bff21982a208 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -1791,19 +1791,17 @@ class TelegramAdapter(BasePlatformAdapter): elif chat.type == ChatType.CHANNEL: chat_type = "channel" - # Resolve DM topic name for chat_topic context injection + # Resolve DM topic name and skill binding thread_id_raw = message.message_thread_id thread_id_str = str(thread_id_raw) if thread_id_raw else None chat_topic = None + topic_skill = None if chat_type == "dm" and thread_id_str: topic_info = self._get_dm_topic_info(str(chat.id), thread_id_str) if topic_info: chat_topic = topic_info.get("name") - # If topic has a skill, inject it as context hint topic_skill = topic_info.get("skill") - if topic_skill: - chat_topic = f"{chat_topic} [skill: {topic_skill}]" # Also check forum_topic_created service message for topic discovery if hasattr(message, "forum_topic_created") and message.forum_topic_created: @@ -1839,5 +1837,6 @@ class TelegramAdapter(BasePlatformAdapter): message_id=str(message.message_id), reply_to_message_id=reply_to_id, reply_to_text=reply_to_text, + auto_skill=topic_skill, timestamp=message.date, ) diff --git a/gateway/run.py b/gateway/run.py index f1823126f542..159c7a0cc923 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1957,7 +1957,39 @@ class GatewayRunner: session_entry.was_auto_reset = False session_entry.auto_reset_reason = None - + + # Auto-load skill for DM topic bindings (e.g., Telegram Private Chat Topics) + # Only inject on NEW sessions — for ongoing conversations the skill content + # is already in the conversation history from the first message. + if _is_new_session and getattr(event, "auto_skill", None): + try: + from agent.skill_commands import _load_skill_payload, _build_skill_message + _skill_name = event.auto_skill + _loaded = _load_skill_payload(_skill_name, task_id=_quick_key) + if _loaded: + _loaded_skill, _skill_dir, _display_name = _loaded + _activation_note = ( + f'[SYSTEM: This conversation is in a topic with the "{_display_name}" skill ' + f"auto-loaded. Follow its instructions for the duration of this session.]" + ) + _skill_msg = _build_skill_message( + _loaded_skill, _skill_dir, _activation_note, + user_instruction=event.text, + ) + if _skill_msg: + event.text = _skill_msg + logger.info( + "[Gateway] Auto-loaded skill '%s' for DM topic session %s", + _skill_name, session_key, + ) + else: + logger.warning( + "[Gateway] DM topic skill '%s' not found in available skills", + _skill_name, + ) + except Exception as e: + logger.warning("[Gateway] Failed to auto-load topic skill '%s': %s", event.auto_skill, e) + # Load conversation history from transcript history = self.session_store.load_transcript(session_entry.session_id) diff --git a/tests/gateway/test_dm_topics.py b/tests/gateway/test_dm_topics.py index 733acbebe699..98c6d4c063c2 100644 --- a/tests/gateway/test_dm_topics.py +++ b/tests/gateway/test_dm_topics.py @@ -395,3 +395,90 @@ def test_cache_dm_topic_from_message_no_overwrite(): adapter._cache_dm_topic_from_message("111", "999", "General") assert adapter._dm_topics["111:General"] == 100 # unchanged + + +# ── _build_message_event: auto_skill binding ── + + +def _make_mock_message(chat_id=111, chat_type="private", text="hello", thread_id=None, + user_id=42, user_name="Test User", forum_topic_created=None): + """Create a mock Telegram Message for _build_message_event tests.""" + chat = SimpleNamespace( + id=chat_id, + type=chat_type, + title=None, + ) + # Add full_name attribute for DM chats + if not hasattr(chat, "full_name"): + chat.full_name = user_name + + user = SimpleNamespace( + id=user_id, + full_name=user_name, + ) + + msg = SimpleNamespace( + chat=chat, + from_user=user, + text=text, + message_thread_id=thread_id, + message_id=1001, + reply_to_message=None, + date=None, + forum_topic_created=forum_topic_created, + ) + return msg + + +def test_build_message_event_sets_auto_skill(): + """When topic has a skill binding, auto_skill should be set on the event.""" + from gateway.platforms.base import MessageType + + adapter = _make_adapter([ + { + "chat_id": 111, + "topics": [ + {"name": "My Project", "skill": "accessibility-auditor", "thread_id": 100}, + ], + } + ]) + adapter._dm_topics["111:My Project"] = 100 + + msg = _make_mock_message(chat_id=111, thread_id=100, text="check this page") + event = adapter._build_message_event(msg, MessageType.TEXT) + + assert event.auto_skill == "accessibility-auditor" + # chat_topic should be the clean topic name, no [skill: ...] suffix + assert event.source.chat_topic == "My Project" + + +def test_build_message_event_no_auto_skill_without_binding(): + """Topics without skill binding should have auto_skill=None.""" + from gateway.platforms.base import MessageType + + adapter = _make_adapter([ + { + "chat_id": 111, + "topics": [ + {"name": "General", "thread_id": 200}, + ], + } + ]) + adapter._dm_topics["111:General"] = 200 + + msg = _make_mock_message(chat_id=111, thread_id=200) + event = adapter._build_message_event(msg, MessageType.TEXT) + + assert event.auto_skill is None + assert event.source.chat_topic == "General" + + +def test_build_message_event_no_auto_skill_without_thread(): + """Regular DM messages (no thread_id) should have auto_skill=None.""" + from gateway.platforms.base import MessageType + + adapter = _make_adapter() + msg = _make_mock_message(chat_id=111, thread_id=None) + event = adapter._build_message_event(msg, MessageType.TEXT) + + assert event.auto_skill is None