fix(telegram): accept both list and mapping shapes for group_topics config

The forum-topic skill-binding lookup assumed config.extra['group_topics']
was always a list of {chat_id, topics} entries. When an operator writes the
natural mapping shape ({"-100...": [...]}), iterating yields string keys and
chat_entry.get(...) raises AttributeError, breaking dispatch for that group.

Normalize both shapes to a common iterator and guard non-dict/non-list
entries so malformed config falls through cleanly instead of crashing.
This commit is contained in:
Glen Workman 2026-07-01 01:07:29 -07:00 committed by Teknium
parent 42d0174699
commit 5505dbbf43
2 changed files with 118 additions and 4 deletions

View file

@ -7707,11 +7707,31 @@ class TelegramAdapter(BasePlatformAdapter):
chat_topic = created_name
elif chat_type == "group" and thread_id_str:
# Group/supergroup forum topic skill binding via config.extra['group_topics']
group_topics_config: list = self.config.extra.get("group_topics", [])
for chat_entry in group_topics_config:
# Group/supergroup forum topic skill binding via config.extra['group_topics'].
# Accept both supported shapes:
# [{"chat_id": "-100...", "topics": [...]}]
# and legacy/operator-edited mapping shape:
# {"-100...": [{"thread_id": 12, ...}]}
group_topics_config = self.config.extra.get("group_topics", [])
if isinstance(group_topics_config, dict):
group_topics_iter = [
{"chat_id": cfg_chat_id, "topics": topics}
for cfg_chat_id, topics in group_topics_config.items()
]
elif isinstance(group_topics_config, list):
group_topics_iter = [
entry for entry in group_topics_config if isinstance(entry, dict)
]
else:
group_topics_iter = []
for chat_entry in group_topics_iter:
if str(chat_entry.get("chat_id", "")) == str(chat.id):
for topic in chat_entry.get("topics", []):
topics = chat_entry.get("topics", [])
if not isinstance(topics, list):
topics = []
for topic in topics:
if not isinstance(topic, dict):
continue
tid = topic.get("thread_id")
if tid is not None and str(tid) == thread_id_str:
chat_topic = topic.get("name")

View file

@ -853,6 +853,100 @@ def test_group_topic_chat_id_int_string_coercion():
assert event.source.chat_topic == "Dev"
def test_group_topic_mapping_shape_config():
"""Operator-edited mapping shape {chat_id: [topics]} must resolve like the list shape."""
from gateway.platforms.base import MessageType
# Dict/mapping shape instead of the canonical list-of-entries shape.
adapter = _make_adapter(group_topics_config={
"-1001234567890": [
{"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",
is_topic_message=True,
is_forum=True,
)
event = adapter._build_message_event(msg, MessageType.TEXT)
assert event.auto_skill == "sales-framework"
assert event.source.chat_topic == "Sales"
def test_group_topic_malformed_config_does_not_crash():
"""Non-dict entries / non-list topics must be skipped, not raise AttributeError."""
from gateway.platforms.base import MessageType
# Junk list entries (str) are filtered out; a matching entry with a good
# topic still resolves; non-dict topic entries within it are skipped.
adapter = _make_adapter(group_topics_config=[
"not-a-dict",
{"chat_id": -1001234567890, "topics": ["also-not-a-dict",
{"name": "Good", "thread_id": 5}]},
])
msg = _make_mock_message(
chat_id=-1001234567890,
chat_type=_ChatType.SUPERGROUP,
thread_id=5,
text="hi",
is_topic_message=True,
is_forum=True,
)
event = adapter._build_message_event(msg, MessageType.TEXT)
assert event.auto_skill is None
assert event.source.chat_topic == "Good"
def test_group_topic_non_list_topics_does_not_crash():
"""A matched entry whose topics is not a list must fall through, not raise."""
from gateway.platforms.base import MessageType
adapter = _make_adapter(group_topics_config=[
{"chat_id": -1001234567890, "topics": "oops-not-a-list"},
])
msg = _make_mock_message(
chat_id=-1001234567890,
chat_type=_ChatType.SUPERGROUP,
thread_id=5,
text="hi",
is_topic_message=True,
is_forum=True,
)
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_scalar_config_falls_through():
"""A scalar (int/str) group_topics value must fall through cleanly, not raise."""
from gateway.platforms.base import MessageType
adapter = _make_adapter(group_topics_config=42)
msg = _make_mock_message(
chat_id=-1001234567890,
chat_type=_ChatType.SUPERGROUP,
thread_id=5,
text="hi",
is_topic_message=True,
is_forum=True,
)
event = adapter._build_message_event(msg, MessageType.TEXT)
assert event.auto_skill is None
assert event.source.chat_topic is None
# ── _build_message_event: from_user=None fallback in DMs ──