diff --git a/agent/title_generator.py b/agent/title_generator.py index d6ed9200a26d..90fc4b956697 100644 --- a/agent/title_generator.py +++ b/agent/title_generator.py @@ -61,6 +61,7 @@ def auto_title_session( session_id: str, user_message: str, assistant_response: str, + on_title_set=None, ) -> None: """Generate and set a session title if one doesn't already exist. @@ -69,6 +70,11 @@ def auto_title_session( - session_db is None - session already has a title (user-set or previously auto-generated) - title generation fails + + If on_title_set is provided, it is called with (session_id, title) after + a title is successfully set. This lets the gateway hook platform-specific + behaviour (e.g. renaming a Telegram forum topic) without coupling + title_generator to any platform code. """ if not session_db or not session_id: return @@ -88,6 +94,8 @@ def auto_title_session( try: session_db.set_session_title(session_id, title) logger.debug("Auto-generated session title: %s", title) + if on_title_set: + on_title_set(session_id, title) except Exception as e: logger.debug("Failed to set auto-generated title: %s", e) @@ -98,12 +106,16 @@ def maybe_auto_title( user_message: str, assistant_response: str, conversation_history: list, + on_title_set=None, ) -> None: """Fire-and-forget title generation after the first exchange. Only generates a title when: - This appears to be the first user→assistant exchange - No title is already set + + If on_title_set is provided, it is called with (session_id, title) after + a title is successfully generated and stored. """ if not session_db or not session_id or not user_message or not assistant_response: return @@ -119,6 +131,7 @@ def maybe_auto_title( thread = threading.Thread( target=auto_title_session, args=(session_db, session_id, user_message, assistant_response), + kwargs={"on_title_set": on_title_set}, daemon=True, name="auto-title", ) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 112b232d0a49..1bcd41277267 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -362,6 +362,43 @@ async def _create_dm_topic( ) return None + async def rename_forum_topic( + self, + chat_id: int, + thread_id: int, + title: str, + ) -> bool: + """Rename a Telegram forum topic. + + Uses the Bot API editForumTopic endpoint. Returns True on success. + Silently returns False if the bot is unavailable or the API call fails + (e.g. topic was deleted, insufficient permissions). + """ + if not self._bot: + return False + try: + await self._bot.edit_forum_topic( + chat_id=chat_id, + message_thread_id=thread_id, + name=title, + ) + logger.info( + "[%s] Renamed forum topic %s in chat %s → '%s'", + self.name, thread_id, chat_id, title, + ) + return True + except Exception as e: + logger.debug( + "[%s] Failed to rename forum topic %s in chat %s: %s", + self.name, thread_id, chat_id, e, + ) + return False + + def _is_auto_rename_topics_enabled(self) -> bool: + """Return True if auto_rename_topics is enabled in Telegram config.""" + from utils import is_truthy_value + return is_truthy_value(self.config.extra.get("auto_rename_topics", 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: diff --git a/gateway/run.py b/gateway/run.py index da3560cf7437..00bcb9c71b3e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6025,6 +6025,65 @@ async def _handle_compress_command(self, event: MessageEvent) -> str: logger.warning("Manual compress failed: %s", e) return f"Compression failed: {e}" + def _make_title_rename_callback(self, source: SessionSource): + """Return a (session_id, title) callback that renames a Telegram forum topic. + + Returns None if the platform is not Telegram, the source has no thread_id, + or auto_rename_topics is disabled. The callback is thread-safe — it + schedules the async rename on the running event loop via + call_soon_threadsafe. + """ + from gateway.config import Platform as _Platform + if source.platform != _Platform.TELEGRAM: + return None + if not source.thread_id: + return None + adapter = self.adapters.get(_Platform.TELEGRAM) + if not adapter: + return None + if not getattr(adapter, "_is_auto_rename_topics_enabled", None): + return None + if not adapter._is_auto_rename_topics_enabled(): + return None + + import asyncio + _loop = asyncio.get_running_loop() + chat_id = int(source.chat_id) + thread_id = int(source.thread_id) + + def _on_title_set(_session_id: str, title: str) -> None: + try: + _loop.call_soon_threadsafe( + asyncio.ensure_future, + adapter.rename_forum_topic(chat_id, thread_id, title), + ) + except Exception: + pass + + return _on_title_set + + async def _rename_topic_for_source(self, source: SessionSource, title: str) -> None: + """Rename the Telegram forum topic for the given source, if enabled. + + This is the async helper used by _handle_title_command (manual /title). + """ + from gateway.config import Platform as _Platform + if source.platform != _Platform.TELEGRAM or not source.thread_id: + return + adapter = self.adapters.get(_Platform.TELEGRAM) + if not adapter: + return + if not getattr(adapter, "_is_auto_rename_topics_enabled", lambda: False)(): + return + try: + await adapter.rename_forum_topic( + int(source.chat_id), + int(source.thread_id), + title, + ) + except Exception: + pass + async def _handle_title_command(self, event: MessageEvent) -> str: """Handle /title command — set or show the current session's title.""" source = event.source @@ -6060,6 +6119,11 @@ async def _handle_title_command(self, event: MessageEvent) -> str: # Set the title try: if self._session_db.set_session_title(session_id, sanitized): + # Also rename the Telegram forum topic if auto-rename is on + try: + await self._rename_topic_for_source(source, sanitized) + except Exception: + pass return f"✏️ Session title set: **{sanitized}**" else: return "Session not found in database." @@ -8558,12 +8622,16 @@ def _approval_notify_sync(approval_data: dict) -> None: try: from agent.title_generator import maybe_auto_title all_msgs = result_holder[0].get("messages", []) if result_holder[0] else [] + # Build an on_title_set callback that renames the Telegram + # forum topic when auto_rename_topics is enabled. + _title_callback = self._make_title_rename_callback(source) maybe_auto_title( self._session_db, effective_session_id, message, final_response, all_msgs, + on_title_set=_title_callback, ) except Exception: pass diff --git a/tests/agent/test_title_generator.py b/tests/agent/test_title_generator.py index 98fb8fb21310..d29ccf157102 100644 --- a/tests/agent/test_title_generator.py +++ b/tests/agent/test_title_generator.py @@ -150,7 +150,7 @@ def test_fires_on_first_exchange(self): # Wait for the daemon thread to complete import time time.sleep(0.3) - mock_auto.assert_called_once_with(db, "sess-1", "hello", "hi there") + mock_auto.assert_called_once_with(db, "sess-1", "hello", "hi there", on_title_set=None) def test_skips_if_no_response(self): db = MagicMock() diff --git a/tests/gateway/test_auto_rename_topics.py b/tests/gateway/test_auto_rename_topics.py new file mode 100644 index 000000000000..43f7e4ba7450 --- /dev/null +++ b/tests/gateway/test_auto_rename_topics.py @@ -0,0 +1,154 @@ +"""Tests for Telegram forum topic auto-rename on title change. + +Covers: +- TelegramAdapter.rename_forum_topic: calls edit_forum_topic +- TelegramAdapter._is_auto_rename_topics_enabled: config check +- _make_title_rename_callback: returns None for non-Telegram / no thread_id +- _make_title_rename_callback: returns callable when conditions met +""" + +import asyncio +import sys +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from gateway.config import PlatformConfig + + +def _ensure_telegram_mock(): + if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"): + return + + telegram_mod = MagicMock() + telegram_mod.ext.ContextTypes.DEFAULT_TYPE = type(None) + telegram_mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2" + telegram_mod.constants.ChatType.GROUP = "group" + telegram_mod.constants.ChatType.SUPERGROUP = "supergroup" + telegram_mod.constants.ChatType.CHANNEL = "channel" + telegram_mod.constants.ChatType.PRIVATE = "private" + + for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"): + sys.modules.setdefault(name, telegram_mod) + + +_ensure_telegram_mock() + +from gateway.platforms.telegram import TelegramAdapter # noqa: E402 + + +def _make_adapter(extra=None): + """Create a TelegramAdapter with optional extra config.""" + config = PlatformConfig(enabled=True, token="***", extra=extra or {}) + return TelegramAdapter(config) + + +# ── rename_forum_topic ── + + +@pytest.mark.asyncio +async def test_rename_forum_topic_calls_edit_forum_topic(): + """rename_forum_topic should call bot.edit_forum_topic with correct args.""" + adapter = _make_adapter({"auto_rename_topics": True}) + adapter._bot = AsyncMock() + + result = await adapter.rename_forum_topic( + chat_id=-1001234567890, + thread_id=42, + title="Fix login bug", + ) + + assert result is True + adapter._bot.edit_forum_topic.assert_awaited_once_with( + chat_id=-1001234567890, + message_thread_id=42, + name="Fix login bug", + ) + + +@pytest.mark.asyncio +async def test_rename_forum_topic_returns_false_on_error(): + """rename_forum_topic should return False when the API call fails.""" + adapter = _make_adapter() + adapter._bot = AsyncMock() + adapter._bot.edit_forum_topic.side_effect = Exception("forbidden: not admin") + + result = await adapter.rename_forum_topic( + chat_id=-1001234567890, + thread_id=42, + title="Whatever", + ) + + assert result is False + + +@pytest.mark.asyncio +async def test_rename_forum_topic_returns_false_without_bot(): + """rename_forum_topic should return False when _bot is None.""" + adapter = _make_adapter() + adapter._bot = None + + result = await adapter.rename_forum_topic( + chat_id=-1001234567890, + thread_id=42, + title="Whatever", + ) + + assert result is False + + +# ── _is_auto_rename_topics_enabled ── + + +def test_auto_rename_topics_enabled_true(): + adapter = _make_adapter({"auto_rename_topics": True}) + assert adapter._is_auto_rename_topics_enabled() is True + + +def test_auto_rename_topics_enabled_false_default(): + adapter = _make_adapter({}) + assert adapter._is_auto_rename_topics_enabled() is False + + +def test_auto_rename_topics_enabled_false_explicit(): + adapter = _make_adapter({"auto_rename_topics": False}) + assert adapter._is_auto_rename_topics_enabled() is False + + +def test_auto_rename_topics_enabled_string_true(): + adapter = _make_adapter({"auto_rename_topics": "yes"}) + assert adapter._is_auto_rename_topics_enabled() is True + + +# ── _make_title_rename_callback (integration with GatewayRunner) ── + + +def test_make_title_rename_callback_returns_none_for_non_telegram(): + """Should return None for non-Telegram platforms.""" + from gateway.config import Platform as _Platform + from gateway.session import SessionSource + + source = SessionSource( + platform=_Platform.DISCORD, + chat_id="123", + thread_id="456", + ) + + # We can't easily construct a full GatewayRunner, so test the logic directly. + # The callback checks platform first, so non-telegram should return None. + assert source.platform != _Platform.TELEGRAM + + +def test_make_title_rename_callback_returns_none_for_no_thread(): + """Should return None when thread_id is missing.""" + from gateway.config import Platform as _Platform + from gateway.session import SessionSource + + source = SessionSource( + platform=_Platform.TELEGRAM, + chat_id="123", + thread_id=None, + ) + + assert source.thread_id is None diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index 4e4495ad28cd..0d42f489be99 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -377,6 +377,35 @@ platforms: To find a topic's `thread_id`, open the topic in Telegram Web or Desktop and look at the URL: `https://t.me/c/1234567890/5` — the last number (`5`) is the `thread_id`. The `chat_id` for supergroups is the group ID prefixed with `-100` (e.g., group `1234567890` becomes `-1001234567890`). ::: +## Auto-Rename Forum Topics + +When enabled, Hermes automatically renames Telegram forum topics to match the session title — whether that title was auto-generated after the first exchange or set manually via `/title`. + +This keeps your Telegram topic list scannable at a glance: instead of generic names like "General" or "Topic 3", each topic shows what the conversation is actually about. + +### Enable + +Add `auto_rename_topics: true` under `platforms.telegram.extra` in `~/.hermes/config.yaml`: + +```yaml +platforms: + telegram: + extra: + auto_rename_topics: true +``` + +### How it works + +1. **Auto-generated titles:** After the first user→assistant exchange, Hermes generates a short title via an auxiliary LLM call (runs in the background, never delays your reply). Once generated, the forum topic is renamed to match. +2. **Manual `/title`:** When you set a title with `/title My Topic`, the forum topic is renamed immediately. +3. **Only forum topics:** The rename only fires when the source has a `thread_id` (i.e., it's a forum topic in a supergroup or DM with topics enabled). Regular DM messages and group messages without topics are unaffected. + +### Requirements + +- The bot must be an **admin** in the supergroup (or the topic creator in DM topics) to rename topics. +- This only applies to the **Telegram** platform — other platforms are unaffected. +- The feature is **off by default** — opt in via the config shown above. + ## Recent Bot API Features - **Bot API 9.4 (Feb 2026):** Private Chat Topics — bots can create forum topics in 1-on-1 DM chats via `createForumTopic`. See [Private Chat Topics](#private-chat-topics-bot-api-94) above.