Skip to content
Closed
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
13 changes: 13 additions & 0 deletions agent/title_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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)

Expand All @@ -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
Expand All @@ -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",
)
Expand Down
37 changes: 37 additions & 0 deletions gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
68 changes: 68 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/agent/test_title_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
154 changes: 154 additions & 0 deletions tests/gateway/test_auto_rename_topics.py
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions website/docs/user-guide/messaging/telegram.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down