From 5505dbbf43a480a31e35ac4500f0584519e2484d Mon Sep 17 00:00:00 2001 From: Glen Workman Date: Wed, 1 Jul 2026 01:07:29 -0700 Subject: [PATCH] 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. --- plugins/platforms/telegram/adapter.py | 28 ++++++-- tests/gateway/test_dm_topics.py | 94 +++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 4 deletions(-) diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 851aa513510..3b8302723b2 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -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") diff --git a/tests/gateway/test_dm_topics.py b/tests/gateway/test_dm_topics.py index d994cb257de..9603271e96c 100644 --- a/tests/gateway/test_dm_topics.py +++ b/tests/gateway/test_dm_topics.py @@ -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 ──