From d4bf517b19d901958b8fd18fca3177101b8018b0 Mon Sep 17 00:00:00 2001 From: Teknium Date: Fri, 3 Apr 2026 15:06:44 -0700 Subject: [PATCH] test+docs: add group_topics tests and documentation - 7 new tests covering skill binding, fallthrough, coercion - Docs section in telegram.md with config format, field reference, comparison table, and thread_id discovery tip --- tests/gateway/test_dm_topics.py | 164 +++++++++++++++++- website/docs/user-guide/messaging/telegram.md | 65 +++++++ 2 files changed, 227 insertions(+), 2 deletions(-) diff --git a/tests/gateway/test_dm_topics.py b/tests/gateway/test_dm_topics.py index e71d3f82c..b9a94c343 100644 --- a/tests/gateway/test_dm_topics.py +++ b/tests/gateway/test_dm_topics.py @@ -42,11 +42,13 @@ _ensure_telegram_mock() from gateway.platforms.telegram import TelegramAdapter # noqa: E402 -def _make_adapter(dm_topics_config=None): - """Create a TelegramAdapter with optional DM topics config.""" +def _make_adapter(dm_topics_config=None, group_topics_config=None): + """Create a TelegramAdapter with optional DM/group topics config.""" extra = {} if dm_topics_config is not None: extra["dm_topics"] = dm_topics_config + if group_topics_config is not None: + extra["group_topics"] = group_topics_config config = PlatformConfig(enabled=True, token="***", extra=extra) adapter = TelegramAdapter(config) return adapter @@ -485,3 +487,161 @@ def test_build_message_event_no_auto_skill_without_thread(): event = adapter._build_message_event(msg, MessageType.TEXT) assert event.auto_skill is None + + +# ── _build_message_event: group_topics skill binding ── + +# The telegram mock sets sys.modules["telegram.constants"] = telegram_mod (root mock), +# so `from telegram.constants import ChatType` in telegram.py resolves to +# telegram_mod.ChatType — not telegram_mod.constants.ChatType. We must use +# the same ChatType object the production code sees so equality checks work. +from telegram.constants import ChatType as _ChatType # noqa: E402 + + +def test_group_topic_skill_binding(): + """Group topic with skill config should set auto_skill on the event.""" + from gateway.platforms.base import MessageType + + adapter = _make_adapter(group_topics_config=[ + { + "chat_id": -1001234567890, + "topics": [ + {"name": "Engineering", "thread_id": 5, "skill": "software-development"}, + {"name": "Sales", "thread_id": 12, "skill": "sales-framework"}, + ], + } + ]) + + msg = _make_mock_message( + chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=5, text="hello" + ) + event = adapter._build_message_event(msg, MessageType.TEXT) + + assert event.auto_skill == "software-development" + assert event.source.chat_topic == "Engineering" + + +def test_group_topic_skill_binding_second_topic(): + """A different thread_id in the same group should resolve its own skill.""" + from gateway.platforms.base import MessageType + + adapter = _make_adapter(group_topics_config=[ + { + "chat_id": -1001234567890, + "topics": [ + {"name": "Engineering", "thread_id": 5, "skill": "software-development"}, + {"name": "Sales", "thread_id": 12, "skill": "sales-framework"}, + ], + } + ]) + + msg = _make_mock_message( + chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=12, text="deal update" + ) + event = adapter._build_message_event(msg, MessageType.TEXT) + + assert event.auto_skill == "sales-framework" + assert event.source.chat_topic == "Sales" + + +def test_group_topic_no_skill_binding(): + """Group topic without a skill key should have auto_skill=None but set chat_topic.""" + from gateway.platforms.base import MessageType + + adapter = _make_adapter(group_topics_config=[ + { + "chat_id": -1001234567890, + "topics": [ + {"name": "General", "thread_id": 1}, + ], + } + ]) + + msg = _make_mock_message( + chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=1, text="hey" + ) + event = adapter._build_message_event(msg, MessageType.TEXT) + + assert event.auto_skill is None + assert event.source.chat_topic == "General" + + +def test_group_topic_unmapped_thread_id(): + """Thread ID not in config should fall through — no skill, no topic name.""" + from gateway.platforms.base import MessageType + + adapter = _make_adapter(group_topics_config=[ + { + "chat_id": -1001234567890, + "topics": [ + {"name": "Engineering", "thread_id": 5, "skill": "software-development"}, + ], + } + ]) + + msg = _make_mock_message( + chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=999, text="random" + ) + event = adapter._build_message_event(msg, MessageType.TEXT) + + assert event.auto_skill is None + assert event.source.chat_topic is None + + +def test_group_topic_unmapped_chat_id(): + """Chat ID not in group_topics config should fall through silently.""" + from gateway.platforms.base import MessageType + + adapter = _make_adapter(group_topics_config=[ + { + "chat_id": -1001234567890, + "topics": [ + {"name": "Engineering", "thread_id": 5, "skill": "software-development"}, + ], + } + ]) + + msg = _make_mock_message( + chat_id=-1009999999999, chat_type=_ChatType.SUPERGROUP, thread_id=5, text="wrong group" + ) + event = adapter._build_message_event(msg, MessageType.TEXT) + + assert event.auto_skill is None + assert event.source.chat_topic is None + + +def test_group_topic_no_config(): + """No group_topics config at all should be fine — no skill, no topic.""" + from gateway.platforms.base import MessageType + + adapter = _make_adapter() # no group_topics_config + + msg = _make_mock_message( + chat_id=-1001234567890, chat_type=_ChatType.GROUP, thread_id=5, text="hi" + ) + event = adapter._build_message_event(msg, MessageType.TEXT) + + assert event.auto_skill is None + assert event.source.chat_topic is None + + +def test_group_topic_chat_id_int_string_coercion(): + """chat_id as string in config should match integer chat.id via str() coercion.""" + from gateway.platforms.base import MessageType + + adapter = _make_adapter(group_topics_config=[ + { + "chat_id": "-1001234567890", # string, not int + "topics": [ + {"name": "Dev", "thread_id": "7", "skill": "hermes-agent-dev"}, + ], + } + ]) + + msg = _make_mock_message( + chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=7, text="test" + ) + event = adapter._build_message_event(msg, MessageType.TEXT) + + assert event.auto_skill == "hermes-agent-dev" + assert event.source.chat_topic == "Dev" diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index 473619ccf..54d89fea7 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -312,6 +312,71 @@ For example, a topic with `skill: arxiv` will have the arxiv skill pre-loaded wh Topics created outside of the config (e.g., by manually calling the Telegram API) are discovered automatically when a `forum_topic_created` service message arrives. You can also add topics to the config while the gateway is running — they'll be picked up on the next cache miss. ::: +## Group Forum Topic Skill Binding + +Supergroups with **Topics mode** enabled (also called "forum topics") already get session isolation per topic — each `thread_id` maps to its own conversation. But you may want to **auto-load a skill** when messages arrive in a specific group topic, just like DM topic skill binding works. + +### Use case + +A team supergroup with forum topics for different workstreams: + +- **Engineering** topic → auto-loads the `software-development` skill +- **Research** topic → auto-loads the `arxiv` skill +- **General** topic → no skill, general-purpose assistant + +### Configuration + +Add topic bindings under `platforms.telegram.extra.group_topics` in `~/.hermes/config.yaml`: + +```yaml +platforms: + telegram: + extra: + group_topics: + - chat_id: -1001234567890 # Supergroup ID + topics: + - name: Engineering + thread_id: 5 + skill: software-development + - name: Research + thread_id: 12 + skill: arxiv + - name: General + thread_id: 1 + # No skill — general purpose +``` + +**Fields:** + +| Field | Required | Description | +|-------|----------|-------------| +| `chat_id` | Yes | The supergroup's numeric ID (negative number starting with `-100`) | +| `name` | No | Human-readable label for the topic (informational only) | +| `thread_id` | Yes | Telegram forum topic ID — visible in `t.me/c//` links | +| `skill` | No | Skill to auto-load on new sessions in this topic | + +### How it works + +1. When a message arrives in a mapped group topic, Hermes looks up the `chat_id` and `thread_id` in `group_topics` config +2. If a matching entry has a `skill` field, that skill is auto-loaded for the session — identical to DM topic skill binding +3. Topics without a `skill` key get session isolation only (existing behavior, unchanged) +4. Unmapped `thread_id` values or `chat_id` values fall through silently — no error, no skill + +### Differences from DM Topics + +| | DM Topics | Group Topics | +|---|---|---| +| Config key | `extra.dm_topics` | `extra.group_topics` | +| Topic creation | Hermes creates topics via API if `thread_id` is missing | Admin creates topics in Telegram UI | +| `thread_id` | Auto-populated after creation | Must be set manually | +| `icon_color` / `icon_custom_emoji_id` | Supported | Not applicable (admin controls appearance) | +| Skill binding | ✓ | ✓ | +| Session isolation | ✓ | ✓ (already built-in for forum topics) | + +:::tip +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`). +::: + ## 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.