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
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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/<group_id>/<thread_id>` 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.
|
||||
|
||||
Reference in New Issue
Block a user