From 3b235fd182684c806e93882909ac184db5ac4329 Mon Sep 17 00:00:00 2001 From: knukn Date: Fri, 20 Mar 2026 17:03:39 +0800 Subject: [PATCH] fix(feishu): support @bot message in topic groups (#1206) * fix(feishu): support @bot message in topic groups * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(feishu): preserve rich-text formatting and add parser unit tests * chore(test): remove unused import to fix ruff lint error * style: auto-format imports to satisfy ruff --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Willem Jiang --- backend/app/channels/feishu.py | 28 +++++++++- backend/tests/test_feishu_parser.py | 81 +++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_feishu_parser.py diff --git a/backend/app/channels/feishu.py b/backend/app/channels/feishu.py index 86aa46a..31507bc 100644 --- a/backend/app/channels/feishu.py +++ b/backend/app/channels/feishu.py @@ -466,7 +466,33 @@ class FeishuChannel(Channel): # Parse message content content = json.loads(message.content) - text = content.get("text", "").strip() + + if "text" in content: + # Handle plain text messages + text = content["text"] + elif "content" in content and isinstance(content["content"], list): + # Handle rich-text messages with a top-level "content" list (e.g., topic groups/posts) + text_paragraphs: list[str] = [] + for paragraph in content["content"]: + if isinstance(paragraph, list): + paragraph_text_parts: list[str] = [] + for element in paragraph: + if isinstance(element, dict): + # Include both normal text and @ mentions + if element.get("tag") in ("text", "at"): + text_value = element.get("text", "") + if text_value: + paragraph_text_parts.append(text_value) + if paragraph_text_parts: + # Join text segments within a paragraph with spaces to avoid "helloworld" + text_paragraphs.append(" ".join(paragraph_text_parts)) + + # Join paragraphs with blank lines to preserve paragraph boundaries + text = "\n\n".join(text_paragraphs) + else: + text = "" + text = text.strip() + logger.info( "[Feishu] parsed message: chat_id=%s, msg_id=%s, root_id=%s, sender=%s, text=%r", chat_id, diff --git a/backend/tests/test_feishu_parser.py b/backend/tests/test_feishu_parser.py new file mode 100644 index 0000000..1a6be26 --- /dev/null +++ b/backend/tests/test_feishu_parser.py @@ -0,0 +1,81 @@ +import json +from unittest.mock import MagicMock + +import pytest + +from app.channels.feishu import FeishuChannel +from app.channels.message_bus import MessageBus + + +def test_feishu_on_message_plain_text(): + bus = MessageBus() + config = {"app_id": "test", "app_secret": "test"} + channel = FeishuChannel(bus, config) + + # Create mock event + event = MagicMock() + event.event.message.chat_id = "chat_1" + event.event.message.message_id = "msg_1" + event.event.message.root_id = None + event.event.sender.sender_id.open_id = "user_1" + + # Plain text content + content_dict = {"text": "Hello world"} + event.event.message.content = json.dumps(content_dict) + + # Call _on_message + channel._on_message(event) + + # Since main_loop isn't running in this synchronous test, we can't easily assert on bus, + # but we can intercept _make_inbound to check the parsed text. + with pytest.MonkeyPatch.context() as m: + mock_make_inbound = MagicMock() + m.setattr(channel, "_make_inbound", mock_make_inbound) + channel._on_message(event) + + mock_make_inbound.assert_called_once() + assert mock_make_inbound.call_args[1]["text"] == "Hello world" + + +def test_feishu_on_message_rich_text(): + bus = MessageBus() + config = {"app_id": "test", "app_secret": "test"} + channel = FeishuChannel(bus, config) + + # Create mock event + event = MagicMock() + event.event.message.chat_id = "chat_1" + event.event.message.message_id = "msg_1" + event.event.message.root_id = None + event.event.sender.sender_id.open_id = "user_1" + + # Rich text content (topic group / post) + content_dict = { + "content": [ + [ + {"tag": "text", "text": "Paragraph 1, part 1."}, + {"tag": "text", "text": "Paragraph 1, part 2."} + ], + [ + {"tag": "at", "text": "@bot"}, + {"tag": "text", "text": " Paragraph 2."} + ] + ] + } + event.event.message.content = json.dumps(content_dict) + + with pytest.MonkeyPatch.context() as m: + mock_make_inbound = MagicMock() + m.setattr(channel, "_make_inbound", mock_make_inbound) + channel._on_message(event) + + mock_make_inbound.assert_called_once() + parsed_text = mock_make_inbound.call_args[1]["text"] + + # Expected text: + # Paragraph 1, part 1. Paragraph 1, part 2. + # + # @bot Paragraph 2. + assert "Paragraph 1, part 1. Paragraph 1, part 2." in parsed_text + assert "@bot Paragraph 2." in parsed_text + assert "\n\n" in parsed_text