diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index afb8767124..7e32382728 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1134,6 +1134,20 @@ class BasePlatformAdapter(ABC): Default is a no-op for platforms with one-shot typing indicators. """ pass + + async def update_thread_title( + self, + chat_id: str, + thread_id: str, + title: str, + ) -> bool: + """Rename a platform-specific thread/topic when supported. + + Default implementation is a no-op so callers can optimistically try + to sync session titles to platform threads without special-casing + every adapter. + """ + return False async def send_image( self, diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index bec0d690a3..0654c2f383 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -247,6 +247,10 @@ class TelegramAdapter(BasePlatformAdapter): self._dm_topics: Dict[str, int] = {} # DM Topics config from extra.dm_topics self._dm_topics_config: List[Dict[str, Any]] = self.config.extra.get("dm_topics", []) + # Runtime cache of topic titles keyed by "chat_id:thread_id". + # This lets /title-driven renames and Telegram service messages show up + # in Hermes context immediately without waiting for config changes. + self._thread_topic_titles: Dict[str, str] = {} # Interactive model picker state per chat self._model_picker_state: Dict[str, dict] = {} # Approval button state: message_id → session_key @@ -510,6 +514,57 @@ class TelegramAdapter(BasePlatformAdapter): ) return None + def _cache_thread_topic_title(self, chat_id: str, thread_id: str, title: str) -> None: + """Remember the latest known title for a Telegram thread/topic.""" + normalized = (title or "").strip() + if not normalized or not chat_id or not thread_id: + return + cache_key = f"{chat_id}:{thread_id}" + self._thread_topic_titles[cache_key] = normalized + + def _get_cached_thread_topic_title(self, chat_id: str, thread_id: Optional[str]) -> Optional[str]: + if not chat_id or not thread_id: + return None + return self._thread_topic_titles.get(f"{chat_id}:{thread_id}") + + async def update_thread_title(self, chat_id: str, thread_id: str, title: str) -> bool: + """Rename a Telegram forum topic / DM topic thread.""" + if not self._bot or not chat_id or not thread_id: + return False + normalized = (title or "").strip() + if not normalized: + return False + try: + if str(thread_id) == self._GENERAL_TOPIC_THREAD_ID: + await self._bot.edit_general_forum_topic( + chat_id=int(chat_id), + name=normalized, + ) + else: + await self._bot.edit_forum_topic( + chat_id=int(chat_id), + message_thread_id=int(thread_id), + name=normalized, + ) + self._cache_thread_topic_title(str(chat_id), str(thread_id), normalized) + logger.info( + "[%s] Renamed Telegram topic chat=%s thread=%s -> %s", + self.name, + chat_id, + thread_id, + normalized, + ) + return True + except Exception as e: + logger.warning( + "[%s] Failed to rename Telegram topic chat=%s thread=%s: %s", + self.name, + chat_id, + thread_id, + e, + ) + return False + def _persist_dm_topic_thread_id(self, chat_id: int, topic_name: str, thread_id: int) -> None: """Save a newly created thread_id back into config.yaml so it persists across restarts.""" try: @@ -759,7 +814,11 @@ class TelegramAdapter(BasePlatformAdapter): )) self._app.add_handler(TelegramMessageHandler( filters.PHOTO | filters.VIDEO | filters.AUDIO | filters.VOICE | filters.Document.ALL | filters.Sticker.ALL, - self._handle_media_message + self._handle_media_message, + )) + self._app.add_handler(TelegramMessageHandler( + filters.StatusUpdate.FORUM_TOPIC_CREATED | filters.StatusUpdate.FORUM_TOPIC_EDITED, + self._handle_forum_topic_service_message, )) # Handle inline keyboard button callbacks (update prompts) self._app.add_handler(CallbackQueryHandler(self._handle_callback_query)) @@ -2446,6 +2505,26 @@ class TelegramAdapter(BasePlatformAdapter): event.text = "\n".join(parts) await self.handle_message(event) + async def _handle_forum_topic_service_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Cache Telegram forum-topic create/edit service messages.""" + message = getattr(update, "message", None) + if not message: + return + thread_id = getattr(message, "message_thread_id", None) + chat = getattr(message, "chat", None) + if thread_id is None or not chat: + return + + if hasattr(message, "forum_topic_created") and message.forum_topic_created: + created_name = getattr(message.forum_topic_created, "name", None) + if created_name: + self._cache_thread_topic_title(str(chat.id), str(thread_id), created_name) + + if hasattr(message, "forum_topic_edited") and message.forum_topic_edited: + edited_name = getattr(message.forum_topic_edited, "name", None) + if edited_name: + self._cache_thread_topic_title(str(chat.id), str(thread_id), edited_name) + # ------------------------------------------------------------------ # Text message aggregation (handles Telegram client-side splits) # ------------------------------------------------------------------ @@ -3020,6 +3099,22 @@ class TelegramAdapter(BasePlatformAdapter): break break + cached_topic_title = self._get_cached_thread_topic_title(str(chat.id), thread_id_str) + if cached_topic_title: + chat_topic = cached_topic_title + + if thread_id_str and hasattr(message, "forum_topic_created") and message.forum_topic_created: + created_name = message.forum_topic_created.name + if created_name: + self._cache_thread_topic_title(str(chat.id), thread_id_str, created_name) + chat_topic = created_name + + if thread_id_str and hasattr(message, "forum_topic_edited") and message.forum_topic_edited: + edited_name = getattr(message.forum_topic_edited, "name", None) + if edited_name: + self._cache_thread_topic_title(str(chat.id), thread_id_str, edited_name) + chat_topic = edited_name + # Build source source = self.build_source( chat_id=str(chat.id), diff --git a/gateway/run.py b/gateway/run.py index c19303e61b..0debaae27d 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7064,7 +7064,16 @@ class GatewayRunner: # Set the title try: if self._session_db.set_session_title(session_id, sanitized): - return f"✏️ Session title set: **{sanitized}**" + response = f"✏️ Session title set: **{sanitized}**" + if source.platform == Platform.TELEGRAM and source.thread_id: + adapter = self.adapters.get(source.platform) + if adapter and await adapter.update_thread_title( + source.chat_id, + source.thread_id, + sanitized, + ): + response += "\n🧵 Telegram topic renamed too." + return response else: return "Session not found in database." except ValueError as e: diff --git a/tests/gateway/test_dm_topics.py b/tests/gateway/test_dm_topics.py index 39cabd950a..302937726c 100644 --- a/tests/gateway/test_dm_topics.py +++ b/tests/gateway/test_dm_topics.py @@ -198,6 +198,71 @@ async def test_create_dm_topic_returns_none_without_bot(): assert result is None +@pytest.mark.asyncio +async def test_update_thread_title_calls_edit_forum_topic_and_caches_name(): + """Successful topic rename should hit Bot API and update runtime cache.""" + adapter = _make_adapter() + adapter._bot = AsyncMock() + + result = await adapter.update_thread_title("111", "222", "Indicative Topic") + + assert result is True + adapter._bot.edit_forum_topic.assert_called_once_with( + chat_id=111, + message_thread_id=222, + name="Indicative Topic", + ) + assert adapter._get_cached_thread_topic_title("111", "222") == "Indicative Topic" + + +@pytest.mark.asyncio +async def test_update_thread_title_uses_general_topic_api_for_thread_one(): + """The General topic uses Telegram's separate rename API.""" + adapter = _make_adapter() + adapter._bot = AsyncMock() + + result = await adapter.update_thread_title("111", TelegramAdapter._GENERAL_TOPIC_THREAD_ID, "Lobby") + + assert result is True + adapter._bot.edit_general_forum_topic.assert_called_once_with( + chat_id=111, + name="Lobby", + ) + adapter._bot.edit_forum_topic.assert_not_called() + assert adapter._get_cached_thread_topic_title("111", TelegramAdapter._GENERAL_TOPIC_THREAD_ID) == "Lobby" + + +@pytest.mark.asyncio +async def test_update_thread_title_returns_false_on_api_error(): + """Rename failures should be non-fatal and return False.""" + adapter = _make_adapter() + adapter._bot = AsyncMock() + adapter._bot.edit_forum_topic.side_effect = Exception("boom") + + result = await adapter.update_thread_title("111", "222", "Indicative Topic") + + assert result is False + assert adapter._get_cached_thread_topic_title("111", "222") is None + + +@pytest.mark.asyncio +async def test_forum_topic_service_handler_caches_created_and_edited_names(): + """Service messages should hot-update the runtime topic-title cache.""" + adapter = _make_adapter() + update = SimpleNamespace( + message=SimpleNamespace( + chat=SimpleNamespace(id=111), + message_thread_id=222, + forum_topic_created=SimpleNamespace(name="Created Title"), + forum_topic_edited=SimpleNamespace(name="Edited Title"), + ) + ) + + await adapter._handle_forum_topic_service_message(update, None) + + assert adapter._get_cached_thread_topic_title("111", "222") == "Edited Title" + + # ── _persist_dm_topic_thread_id ── diff --git a/tests/gateway/test_title_command.py b/tests/gateway/test_title_command.py index d5bad6c57a..0107df75c8 100644 --- a/tests/gateway/test_title_command.py +++ b/tests/gateway/test_title_command.py @@ -4,8 +4,7 @@ Tests the _handle_title_command handler (set/show session titles) across all gateway messenger platforms. """ -import os -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -15,13 +14,14 @@ from gateway.session import SessionSource def _make_event(text="/title", platform=Platform.TELEGRAM, - user_id="12345", chat_id="67890"): + user_id="12345", chat_id="67890", thread_id=None): """Build a MessageEvent for testing.""" source = SessionSource( platform=platform, user_id=user_id, chat_id=chat_id, user_name="testuser", + thread_id=thread_id, ) return MessageEvent(text=text, source=source) @@ -70,6 +70,66 @@ class TestHandleTitleCommand: assert db.get_session_title("test_session_123") == "My Research Project" db.close() + @pytest.mark.asyncio + async def test_set_title_renames_telegram_topic_when_in_thread(self, tmp_path): + """Telegram /title should also rename the active topic thread when possible.""" + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("test_session_123", "telegram") + + runner = _make_runner(session_db=db) + adapter = MagicMock() + adapter.update_thread_title = AsyncMock(return_value=True) + runner.adapters[Platform.TELEGRAM] = adapter + + event = _make_event(text="/title Indicative Topic", thread_id="470094") + result = await runner._handle_title_command(event) + + adapter.update_thread_title.assert_awaited_once_with("67890", "470094", "Indicative Topic") + assert "Telegram topic renamed too" in result + assert db.get_session_title("test_session_123") == "Indicative Topic" + db.close() + + @pytest.mark.asyncio + async def test_set_title_renames_telegram_general_topic_when_thread_is_one(self, tmp_path): + """Telegram General topic thread_id=1 should still trigger a rename attempt.""" + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("test_session_123", "telegram") + + runner = _make_runner(session_db=db) + adapter = MagicMock() + adapter.update_thread_title = AsyncMock(return_value=True) + runner.adapters[Platform.TELEGRAM] = adapter + + event = _make_event(text="/title Lobby", thread_id="1") + result = await runner._handle_title_command(event) + + adapter.update_thread_title.assert_awaited_once_with("67890", "1", "Lobby") + assert "Telegram topic renamed too" in result + assert db.get_session_title("test_session_123") == "Lobby" + db.close() + + @pytest.mark.asyncio + async def test_set_title_skips_telegram_topic_rename_without_thread(self, tmp_path): + """Telegram chats without a thread_id keep the existing DB-only behavior.""" + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("test_session_123", "telegram") + + runner = _make_runner(session_db=db) + adapter = MagicMock() + adapter.update_thread_title = AsyncMock(return_value=True) + runner.adapters[Platform.TELEGRAM] = adapter + + event = _make_event(text="/title Plain Chat Title") + result = await runner._handle_title_command(event) + + adapter.update_thread_title.assert_not_called() + assert "Telegram topic renamed too" not in result + assert db.get_session_title("test_session_123") == "Plain Chat Title" + db.close() + @pytest.mark.asyncio async def test_show_title_when_set(self, tmp_path): """Showing title when one is set returns the title."""