diff --git a/app/modules/feishu/__init__.py b/app/modules/feishu/__init__.py index 0867a6b4..97a7bb5c 100644 --- a/app/modules/feishu/__init__.py +++ b/app/modules/feishu/__init__.py @@ -318,5 +318,5 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]): client = self.get_instance(client_config.name) if not client: return False - sequence = int(stream_meta.get("sequence") or 1) + 1 + sequence = int(stream_meta.get("sequence") or 0) + 1 return client.close_streaming_card(card_id=card_id, sequence=sequence) diff --git a/app/modules/feishu/feishu.py b/app/modules/feishu/feishu.py index f4a60ac6..b57c1344 100644 --- a/app/modules/feishu/feishu.py +++ b/app/modules/feishu/feishu.py @@ -32,7 +32,11 @@ from lark_oapi.api.im.v1 import ( GetMessageResourceRequest, PatchMessageRequest, PatchMessageRequestBody, + P2ImChatAccessEventBotP2pChatEnteredV1, P2ImMessageMessageReadV1, + P2ImMessageReactionCreatedV1, + P2ImMessageReactionDeletedV1, + P2ImMessageRecalledV1, P2ImMessageReceiveV1, ReplyMessageRequest, ReplyMessageRequestBody, @@ -119,6 +123,10 @@ class Feishu: ) builder.register_p2_im_message_receive_v1(self._on_message) builder.register_p2_im_message_message_read_v1(self._on_message_read) + builder.register_p2_im_message_reaction_created_v1(self._on_message_reaction_created) + builder.register_p2_im_message_reaction_deleted_v1(self._on_message_reaction_deleted) + builder.register_p2_im_message_recalled_v1(self._on_message_recalled) + builder.register_p2_im_chat_access_event_bot_p2p_chat_entered_v1(self._on_bot_p2p_chat_entered) builder.register_p2_card_action_trigger(self._on_card_action) return builder.build() @@ -359,6 +367,54 @@ class Feishu: len(getattr(event, "message_id_list", None) or []), ) + @staticmethod + def _on_message_reaction_created(data: P2ImMessageReactionCreatedV1) -> None: + """忽略消息表情新增事件,避免长连接打印未注册处理器错误。""" + event = getattr(data, "event", None) + operator = getattr(event, "operator", None) + reaction = getattr(event, "reaction", None) + logger.debug( + "收到飞书消息表情新增事件:message_id=%s, user=%s, emoji=%s", + getattr(event, "message_id", None), + getattr(operator, "open_id", None) or getattr(operator, "user_id", None), + getattr(reaction, "emoji_type", None), + ) + + @staticmethod + def _on_message_reaction_deleted(data: P2ImMessageReactionDeletedV1) -> None: + """忽略消息表情删除事件,避免长连接打印未注册处理器错误。""" + event = getattr(data, "event", None) + operator = getattr(event, "operator", None) + reaction = getattr(event, "reaction", None) + logger.debug( + "收到飞书消息表情删除事件:message_id=%s, user=%s, emoji=%s", + getattr(event, "message_id", None), + getattr(operator, "open_id", None) or getattr(operator, "user_id", None), + getattr(reaction, "emoji_type", None), + ) + + @staticmethod + def _on_message_recalled(data: P2ImMessageRecalledV1) -> None: + """忽略消息撤回事件,避免长连接打印未注册处理器错误。""" + event = getattr(data, "event", None) + operator = getattr(event, "operator", None) + logger.debug( + "收到飞书消息撤回事件:message_id=%s, user=%s", + getattr(event, "message_id", None), + getattr(operator, "open_id", None) or getattr(operator, "user_id", None), + ) + + @staticmethod + def _on_bot_p2p_chat_entered(data: P2ImChatAccessEventBotP2pChatEnteredV1) -> None: + """忽略机器人进入单聊事件,避免长连接打印未注册处理器错误。""" + event = getattr(data, "event", None) + operator = getattr(event, "operator_id", None) + logger.debug( + "收到飞书机器人进入单聊事件:chat_id=%s, user=%s", + getattr(event, "chat_id", None), + getattr(operator, "open_id", None) or getattr(operator, "user_id", None), + ) + def get_state(self) -> bool: """返回飞书客户端是否已就绪。""" return self._ready.is_set() and self._api_client is not None @@ -681,7 +737,9 @@ class Feishu: "feishu_streaming": { "card_id": card_id, "element_id": self.STREAM_CARD_BODY_ELEMENT_ID, - "sequence": 1, + # CardKit 的后续 PATCH/设置调用都依赖单调递增 sequence, + # 首次建卡后尚未发生内容更新,因此从 0 开始记录。 + "sequence": 0, } } return result @@ -1181,7 +1239,7 @@ class Feishu: if isinstance(stream_meta, dict) and not buttons: card_id = str(stream_meta.get("card_id") or "").strip() element_id = str(stream_meta.get("element_id") or self.STREAM_CARD_BODY_ELEMENT_ID).strip() - sequence = int(stream_meta.get("sequence") or 1) + 1 + sequence = int(stream_meta.get("sequence") or 0) + 1 if card_id and element_id and self._update_streaming_card_content( card_id=card_id, element_id=element_id, diff --git a/tests/test_feishu.py b/tests/test_feishu.py index 2b2ebd8d..723cd760 100644 --- a/tests/test_feishu.py +++ b/tests/test_feishu.py @@ -144,6 +144,36 @@ class TestFeishu(unittest.TestCase): self.assertTrue(result.is_callback) self.assertEqual(result.chat_id, "oc_123") + def test_build_event_handler_registers_common_im_events(self): + registered = [] + + class _Builder: + def __getattr__(self, name): + if name.startswith("register_"): + def _register(handler): + registered.append(name) + return self + return _register + raise AttributeError(name) + + def build(self): + return "handler" + + client = self._build_client() + fake_builder = _Builder() + + with patch("app.modules.feishu.feishu.lark.EventDispatcherHandler.builder", return_value=fake_builder): + handler = client._build_event_handler() + + self.assertEqual(handler, "handler") + self.assertIn("register_p2_im_message_receive_v1", registered) + self.assertIn("register_p2_im_message_message_read_v1", registered) + self.assertIn("register_p2_im_message_reaction_created_v1", registered) + self.assertIn("register_p2_im_message_reaction_deleted_v1", registered) + self.assertIn("register_p2_im_message_recalled_v1", registered) + self.assertIn("register_p2_im_chat_access_event_bot_p2p_chat_entered_v1", registered) + self.assertIn("register_p2_card_action_trigger", registered) + def test_parse_message_blocks_non_admin_command(self): client = self._build_client(FEISHU_ADMINS="ou_admin") @@ -292,6 +322,7 @@ class TestFeishu(unittest.TestCase): self.assertTrue(result["success"]) self.assertEqual(result["metadata"]["feishu_streaming"]["card_id"], "card_stream") + self.assertEqual(result["metadata"]["feishu_streaming"]["sequence"], 0) card_request = client._api_client.cardkit.v1.card.create.call_args.args[0] self.assertEqual(card_request.request_body.type, "card_json") card_payload = json.loads(card_request.request_body.data) @@ -324,6 +355,31 @@ class TestFeishu(unittest.TestCase): self.assertEqual(reply_request.message_id, "om_origin") self.assertEqual(reply_request.request_body.msg_type, "interactive") self.assertEqual(json.loads(reply_request.request_body.content)["data"]["card_id"], "card_stream") + self.assertEqual(result["metadata"]["feishu_streaming"]["sequence"], 0) + + def test_edit_replied_streaming_card_uses_first_increment_sequence(self): + client = self._build_client() + client._api_client, message_api = self._build_message_api( + patch_response=self._success_response(), + card_content_response=self._success_response(), + ) + + success = client.edit_message( + message_id="om_reply", + text="补充内容", + metadata={ + "feishu_streaming": { + "card_id": "card_stream", + "element_id": Feishu.STREAM_CARD_BODY_ELEMENT_ID, + "sequence": 0, + } + }, + ) + + self.assertTrue(success) + message_api.patch.assert_not_called() + content_request = client._api_client.cardkit.v1.card_element.content.call_args.args[0] + self.assertEqual(content_request.request_body.sequence, 1) def test_edit_message_uses_cardkit_content_for_streaming_card(self): client = self._build_client() @@ -339,7 +395,7 @@ class TestFeishu(unittest.TestCase): "feishu_streaming": { "card_id": "card_stream", "element_id": Feishu.STREAM_CARD_BODY_ELEMENT_ID, - "sequence": 1, + "sequence": 0, } }, ) @@ -350,7 +406,7 @@ class TestFeishu(unittest.TestCase): content_request = client._api_client.cardkit.v1.card_element.content.call_args.args[0] self.assertEqual(content_request.card_id, "card_stream") self.assertEqual(content_request.element_id, Feishu.STREAM_CARD_BODY_ELEMENT_ID) - self.assertEqual(content_request.request_body.sequence, 2) + self.assertEqual(content_request.request_body.sequence, 1) def test_close_streaming_card_updates_card_settings(self): client = self._build_client() @@ -628,7 +684,7 @@ class TestFeishu(unittest.TestCase): metadata={ "feishu_streaming": { "card_id": "card_stream", - "sequence": 3, + "sequence": 2, } }, success=True, @@ -636,7 +692,7 @@ class TestFeishu(unittest.TestCase): ) self.assertTrue(success) - client.close_streaming_card.assert_called_once_with(card_id="card_stream", sequence=4) + client.close_streaming_card.assert_called_once_with(card_id="card_stream", sequence=3) def test_module_post_message_prefers_file_and_voice_paths(self): module = FeishuModule()