From 0989439d2573b1fdf4f7c4e9b9ea03543d1cab7b Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 13 May 2026 00:07:36 +0800 Subject: [PATCH] feat(feishu): enhance message handling with file and voice support, add reaction management --- app/chain/message.py | 105 ++++++ app/modules/feishu/__init__.py | 134 +++++++- app/modules/feishu/feishu.py | 544 +++++++++++++++++++++++++++--- tests/test_agent_image_support.py | 43 +++ tests/test_feishu.py | 279 ++++++++++++++- 5 files changed, 1050 insertions(+), 55 deletions(-) diff --git a/app/chain/message.py b/app/chain/message.py index cf2f95a3..b6180644 100644 --- a/app/chain/message.py +++ b/app/chain/message.py @@ -5,6 +5,7 @@ import mimetypes import re import time import uuid +from dataclasses import dataclass from datetime import datetime, timedelta from pathlib import Path from typing import Any, Optional, Dict, Union, List, Tuple @@ -47,6 +48,13 @@ class MessageChain(ChainBase): # 会话超时时间(分钟) _session_timeout_minutes: int = 24 * 60 + @dataclass + class _ProcessingMarker: + channel: MessageChannel + source: str + message_id: str + reaction_id: str + def process(self, body: Any, form: Any, args: Any) -> None: """ 调用模块识别消息内容 @@ -151,6 +159,46 @@ class MessageChain(ChainBase): text=text, ) + processing_marker = self._mark_message_processing_started( + channel=channel, + source=source, + original_message_id=original_message_id, + text=text, + ) + + try: + self._handle_message_core( + channel=channel, + source=source, + userid=userid, + username=username, + text=text, + original_message_id=original_message_id, + original_chat_id=original_chat_id, + images=images, + audio_refs=audio_refs, + files=files, + has_audio_input=has_audio_input, + ) + finally: + self._mark_message_processing_finished(processing_marker) + + def _handle_message_core( + self, + channel: MessageChannel, + source: str, + userid: Union[str, int], + username: str, + text: str, + original_message_id: Optional[Union[str, int]] = None, + original_chat_id: Optional[str] = None, + images: Optional[List[CommingMessage.MessageImage]] = None, + audio_refs: Optional[List[str]] = None, + files: Optional[List[CommingMessage.MessageAttachment]] = None, + has_audio_input: bool = False, + ) -> None: + """执行实际消息路由,便于统一包裹处理中状态。""" + if text.startswith("CALLBACK:"): if ChannelCapabilityManager.supports_callbacks(channel): self._handle_callback( @@ -264,6 +312,49 @@ class MessageChain(ChainBase): }, ) + def _mark_message_processing_started( + self, + channel: MessageChannel, + source: str, + original_message_id: Optional[Union[str, int]], + text: str, + ) -> Optional[_ProcessingMarker]: + """为支持的渠道标记“消息正在处理”。""" + if channel != MessageChannel.Feishu: + return None + if not original_message_id or not text or text.startswith("CALLBACK:"): + return None + + reaction_id = self.run_module( + "add_feishu_message_reaction", + message_id=str(original_message_id), + emoji_type="GLANCE", + source=source, + ) + if not reaction_id: + return None + + return self._ProcessingMarker( + channel=channel, + source=source, + message_id=str(original_message_id), + reaction_id=str(reaction_id), + ) + + def _mark_message_processing_finished( + self, + marker: Optional[_ProcessingMarker], + ) -> None: + """清理渠道“消息正在处理”标记。""" + if not marker: + return + self.run_module( + "delete_feishu_message_reaction", + message_id=marker.message_id, + reaction_id=marker.reaction_id, + source=marker.source, + ) + def _handle_callback( self, text: str, @@ -1098,12 +1189,14 @@ class MessageChain(ChainBase): ), global_vars.loop, ) + return except Exception as e: logger.error(f"处理AI智能体消息失败: {e}") self.messagehelper.put( f"AI智能体处理失败: {str(e)}", role="system", title="MoviePilot助手" ) + return def _transcribe_audio_refs( self, audio_refs: List[str], channel: MessageChannel, source: str @@ -1289,6 +1382,14 @@ class MessageChain(ChainBase): ) if data_url: data_urls.append(data_url) + elif attachment_ref.startswith("feishu://image/"): + data_url = self.run_module( + "download_feishu_image_to_data_url", + image_ref=attachment_ref, + source=source, + ) + if data_url: + data_urls.append(data_url) elif channel == MessageChannel.Slack: data_url = self.run_module( "download_slack_file_to_data_url", @@ -1462,6 +1563,10 @@ class MessageChain(ChainBase): return self.run_module( "download_wechat_media_bytes", media_ref=file_ref, source=source ) + if file_ref.startswith("feishu://file/"): + return self.run_module( + "download_feishu_file_bytes", file_ref=file_ref, source=source + ) if file_ref.startswith("slack://file/"): return self.run_module( "download_slack_file_bytes", file_ref=file_ref, source=source diff --git a/app/modules/feishu/__init__.py b/app/modules/feishu/__init__.py index 3793a0aa..2c72cc12 100644 --- a/app/modules/feishu/__init__.py +++ b/app/modules/feishu/__init__.py @@ -92,12 +92,34 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]): userid, chat_id, receive_id_type = self._resolve_message_target(message) client: Feishu = self.get_instance(conf.name) if client: - client.send_notification( - message=message, - userid=userid, - chat_id=chat_id, - receive_id_type=receive_id_type, - ) + if message.file_path: + client.send_file( + file_path=message.file_path, + userid=userid, + chat_id=chat_id, + title=message.title, + text=message.text, + file_name=message.file_name, + receive_id_type=receive_id_type, + original_message_id=str(message.original_message_id) if message.original_message_id else None, + ) + elif message.voice_path: + client.send_voice( + voice_path=message.voice_path, + userid=userid, + chat_id=chat_id, + caption=message.voice_caption, + receive_id_type=receive_id_type, + original_message_id=str(message.original_message_id) if message.original_message_id else None, + ) + else: + client.send_notification( + message=message, + userid=userid, + chat_id=chat_id, + receive_id_type=receive_id_type, + original_message_id=str(message.original_message_id) if message.original_message_id else None, + ) def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None: for conf in self.get_configs().values(): @@ -162,12 +184,34 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]): client: Feishu = self.get_instance(conf.name) if not client: continue - result = client.send_notification( - message=message, - userid=userid, - chat_id=chat_id, - receive_id_type=receive_id_type, - ) + if message.file_path: + result = client.send_file( + file_path=message.file_path, + userid=userid, + chat_id=chat_id, + title=message.title, + text=message.text, + file_name=message.file_name, + receive_id_type=receive_id_type, + original_message_id=str(message.original_message_id) if message.original_message_id else None, + ) + elif message.voice_path: + result = client.send_voice( + voice_path=message.voice_path, + userid=userid, + chat_id=chat_id, + caption=message.voice_caption, + receive_id_type=receive_id_type, + original_message_id=str(message.original_message_id) if message.original_message_id else None, + ) + else: + result = client.send_notification( + message=message, + userid=userid, + chat_id=chat_id, + receive_id_type=receive_id_type, + original_message_id=str(message.original_message_id) if message.original_message_id else None, + ) if result and result.get("success"): return MessageResponse( message_id=result.get("message_id"), @@ -177,3 +221,69 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]): success=True, ) return None + + def download_feishu_image_to_data_url(self, image_ref: str, source: str) -> Optional[str]: + if not image_ref or not image_ref.startswith("feishu://image/"): + return None + client_config = self.get_config(source) + if not client_config: + return None + client = self.get_instance(client_config.name) + if not client: + return None + image_key = image_ref.replace("feishu://image/", "", 1) + downloaded = client._download_image_bytes(image_key) + if not downloaded: + return None + content, _, content_type = downloaded + mime_type = content_type or "image/jpeg" + import base64 + + return f"data:{mime_type};base64,{base64.b64encode(content).decode()}" + + def download_feishu_file_bytes(self, file_ref: str, source: str) -> Optional[bytes]: + if not file_ref or not file_ref.startswith("feishu://file/"): + return None + client_config = self.get_config(source) + if not client_config: + return None + client = self.get_instance(client_config.name) + if not client: + return None + parts = file_ref.replace("feishu://file/", "", 1).split("/", 1) + file_key = parts[0].strip() if parts else "" + if not file_key: + return None + downloaded = client._download_file_bytes(file_key) + if not downloaded: + return None + content, _, _ = downloaded + return content + + def add_feishu_message_reaction( + self, + message_id: str, + emoji_type: str, + source: str, + ) -> Optional[str]: + client_config = self.get_config(source) + if not client_config: + return None + client = self.get_instance(client_config.name) + if not client: + return None + return client.add_message_reaction(message_id=message_id, emoji_type=emoji_type) + + def delete_feishu_message_reaction( + self, + message_id: str, + reaction_id: str, + source: str, + ) -> bool: + client_config = self.get_config(source) + if not client_config: + return False + client = self.get_instance(client_config.name) + if not client: + return False + return client.delete_message_reaction(message_id=message_id, reaction_id=reaction_id) diff --git a/app/modules/feishu/feishu.py b/app/modules/feishu/feishu.py index 993591ff..59bbcdc5 100644 --- a/app/modules/feishu/feishu.py +++ b/app/modules/feishu/feishu.py @@ -1,17 +1,33 @@ import asyncio +import base64 import json +import mimetypes import threading import uuid +from pathlib import Path from typing import Any, Dict, List, Optional, Tuple import lark_oapi as lark import lark_oapi.ws.client as lark_ws_client_module from lark_oapi.api.im.v1 import ( + CreateFileRequest, + CreateFileRequestBody, + CreateImageRequest, + CreateImageRequestBody, CreateMessageRequest, CreateMessageRequestBody, + CreateMessageReactionRequest, + CreateMessageReactionRequestBody, + DeleteMessageReactionRequest, + GetFileRequest, + GetImageRequest, + GetMessageResourceRequest, PatchMessageRequest, PatchMessageRequestBody, P2ImMessageReceiveV1, + ReplyMessageRequest, + ReplyMessageRequestBody, + Emoji, ) from lark_oapi.core.const import FEISHU_DOMAIN from lark_oapi.core.enum import LogLevel @@ -31,6 +47,8 @@ from app.utils.http import RequestUtils class Feishu: """飞书通知客户端,负责长连接收消息与主动发送通知。""" + PROCESSING_REACTION_EMOJI = "GLANCE" + def __init__( self, FEISHU_APP_ID: Optional[str] = None, @@ -164,20 +182,43 @@ class Feishu: threading.Thread(target=_run, daemon=True).start() @staticmethod - def _extract_message_text(message) -> str: - """从飞书事件消息体中提取可读文本。""" + def _parse_message_content(message) -> Tuple[str, Optional[List[CommingMessage.MessageImage]], Optional[List[str]], Optional[List[CommingMessage.MessageAttachment]]]: + """从飞书事件消息体中提取文本、图片、音频和文件引用。""" raw_content = getattr(message, "content", None) if not raw_content: - return "" + return "", None, None, None try: content = json.loads(raw_content) except Exception: - return "" + return "", None, None, None if not isinstance(content, dict): - return "" - if isinstance(content.get("text"), str): - return content.get("text", "").strip() - return "" + return "", None, None, None + + message_type = getattr(message, "message_type", None) + text = content.get("text", "").strip() if isinstance(content.get("text"), str) else "" + images = None + audio_refs = None + files = None + + if message_type == "image": + image_key = str(content.get("image_key") or "").strip() + if image_key: + images = [CommingMessage.MessageImage(ref=f"feishu://image/{image_key}")] + elif message_type in {"audio", "media", "file"}: + file_key = str(content.get("file_key") or "").strip() + file_name = str(content.get("file_name") or "").strip() or None + if file_key: + if message_type == "audio": + audio_refs = [f"feishu://file/{file_key}/{file_name or 'audio.opus'}"] + else: + files = [ + CommingMessage.MessageAttachment( + ref=f"feishu://file/{file_key}/{file_name or 'attachment'}", + name=file_name, + ) + ] + + return text, images, audio_refs, files def _remember_target(self, userid: Optional[str], chat_id: Optional[str]) -> None: """记录最近互动的用户与会话映射,便于后续主动回复。""" @@ -210,7 +251,8 @@ class Feishu: open_id = getattr(sender_id, "open_id", None) user_id = getattr(sender_id, "user_id", None) chat_id = getattr(message, "chat_id", None) - text = self._extract_message_text(message) + text, images, audio_refs, files = self._parse_message_content(message) + message_type = getattr(message, "message_type", None) payload = { "type": "message", @@ -218,7 +260,11 @@ class Feishu: "message_id": getattr(message, "message_id", None), "chat_id": chat_id, "chat_type": getattr(message, "chat_type", None), + "message_type": message_type, "text": text, + "images": [image.model_dump() for image in images] if images else None, + "audio_refs": audio_refs, + "files": [file.model_dump() for file in files] if files else None, "sender": { "open_id": open_id, "user_id": user_id, @@ -229,10 +275,11 @@ class Feishu: self._remember_user_id_type(open_id=open_id, user_id=user_id) self._remember_target(userid=userid, chat_id=chat_id) logger.info( - "收到来自 %s 的飞书消息:userid=%s, chat_id=%s, text=%s", + "收到来自 %s 的飞书消息:userid=%s, chat_id=%s, type=%s, text=%s", self._name, userid, chat_id, + message_type, text, ) self._forward_to_message_chain(payload) @@ -345,7 +392,19 @@ class Feishu: ) text = (message.get("text") or "").strip() - if not text: + images = CommingMessage.MessageImage.normalize_list(message.get("images")) + audio_refs = None + if isinstance(message.get("audio_refs"), list): + audio_refs = [str(item).strip() for item in message.get("audio_refs") if str(item).strip()] or None + files = None + if isinstance(message.get("files"), list): + normalized_files = [] + for item in message.get("files"): + if isinstance(item, dict) and item.get("ref"): + normalized_files.append(CommingMessage.MessageAttachment(**item)) + files = normalized_files or None + + if not text and not images and not audio_refs and not files: return None if text.startswith("/") and self._admins and str(userid) not in self._admins: @@ -365,6 +424,9 @@ class Feishu: text=text, message_id=message.get("message_id"), chat_id=message.get("chat_id"), + images=images, + audio_refs=audio_refs, + files=files, ) def _resolve_target( @@ -393,16 +455,41 @@ class Feishu: return resolved_chat_id, "chat_id" raise ValueError("未找到可发送的飞书目标") + @staticmethod + def _escape_card_text(text: Optional[str]) -> str: + """转义飞书卡片 markdown 中易误触的字符。""" + if not text: + return "" + escaped = str(text) + for source, target in { + "\\": "\", + "<": "<", + ">": ">", + }.items(): + escaped = escaped.replace(source, target) + return escaped + + @classmethod + def _build_markdown_section(cls, text: Optional[str], text_size: str = "normal") -> Optional[dict]: + content = cls._escape_card_text(text).strip() + if not content: + return None + return { + "tag": "markdown", + "text_size": text_size, + "content": content, + } + @staticmethod def _build_message_text(title: Optional[str], text: Optional[str], link: Optional[str] = None) -> str: """拼接飞书 Markdown 文本内容。""" parts = [] if title: - parts.append(f"**{title.strip()}**") + parts.append(f"**{Feishu._escape_card_text(title).strip()}**") if text: - parts.append(text.strip()) + parts.append(Feishu._escape_card_text(text).strip()) if link: - parts.append(f"[查看详情]({link})") + parts.append(f"[查看详情]({link.strip()})") return "\n\n".join(part for part in parts if part) @staticmethod @@ -440,13 +527,24 @@ class Feishu: def _build_card(self, title: Optional[str], text: Optional[str], link: Optional[str], buttons: Optional[List[List[dict]]]) -> Dict[str, Any]: """构建飞书交互卡片结构。""" - content = self._build_message_text(title=title, text=text, link=link) elements: List[dict] = [] - if content: - elements.append({"tag": "markdown", "content": content}) + title_section = self._build_markdown_section(title, text_size="heading") + body_section = self._build_markdown_section( + self._build_message_text(title=None, text=text, link=link), + text_size="normal", + ) + if title_section: + elements.append(title_section) + if body_section: + elements.append(body_section) elements.extend(self._card_actions(buttons)) return { - "config": {"wide_screen_mode": True, "enable_forward": True}, + # 飞书卡片消息要支持后续 PATCH 更新,发送和更新时都必须显式声明 update_multi。 + "config": { + "wide_screen_mode": True, + "enable_forward": True, + "update_multi": True, + }, "elements": elements, } @@ -483,28 +581,187 @@ class Feishu: "success": True, "message_id": getattr(data, "message_id", None), "chat_id": getattr(data, "chat_id", None), + "msg_type": getattr(data, "msg_type", None), } + def _reply_message( + self, + message_id: str, + msg_type: str, + content: dict, + reply_in_thread: bool = False, + ) -> Optional[dict]: + """按原消息回复,保持飞书会话中的引用关系。""" + if not self._api_client: + raise RuntimeError("飞书客户端未初始化") + + request = ( + ReplyMessageRequest.builder() + .message_id(message_id) + .request_body( + ReplyMessageRequestBody.builder() + .content(json.dumps(content, ensure_ascii=False)) + .msg_type(msg_type) + .reply_in_thread(reply_in_thread) + .uuid(str(uuid.uuid4())) + .build() + ) + .build() + ) + response = self._api_client.im.v1.message.reply(request) + if not response.success(): + logger.error( + "飞书回复消息失败:code=%s, msg=%s, log_id=%s", + response.code, + response.msg, + response.get_log_id(), + ) + return None + + data = getattr(response, "data", None) + return { + "success": True, + "message_id": getattr(data, "message_id", None), + "chat_id": getattr(data, "chat_id", None), + "msg_type": getattr(data, "msg_type", None), + "root_id": getattr(data, "root_id", None), + "parent_id": getattr(data, "parent_id", None), + "thread_id": getattr(data, "thread_id", None), + } + + @staticmethod + def _guess_file_type(file_path: Path) -> str: + suffix = file_path.suffix.lower().lstrip(".") + if suffix == "opus": + return "opus" + if suffix == "mp4": + return "mp4" + if suffix in {"pdf", "doc", "xls", "ppt"}: + return suffix + return "stream" + + def _upload_image(self, file_path: Path) -> Optional[str]: + if not self._api_client: + return None + with file_path.open("rb") as fp: + response = self._api_client.im.v1.image.create( + CreateImageRequest.builder() + .request_body( + CreateImageRequestBody.builder() + .image_type("message") + .image(fp) + .build() + ) + .build() + ) + if not response.success(): + logger.error( + "飞书图片上传失败:code=%s, msg=%s, log_id=%s", + response.code, + response.msg, + response.get_log_id(), + ) + return None + data = getattr(response, "data", None) + return getattr(data, "image_key", None) + + def _upload_file(self, file_path: Path, file_name: Optional[str] = None, duration: Optional[int] = None) -> Optional[str]: + if not self._api_client: + return None + with file_path.open("rb") as fp: + builder = ( + CreateFileRequestBody.builder() + .file_type(self._guess_file_type(file_path)) + .file_name(file_name or file_path.name) + .file(fp) + ) + if duration is not None: + builder.duration(duration) + response = self._api_client.im.v1.file.create( + CreateFileRequest.builder().request_body(builder.build()).build() + ) + if not response.success(): + logger.error( + "飞书文件上传失败:code=%s, msg=%s, log_id=%s", + response.code, + response.msg, + response.get_log_id(), + ) + return None + data = getattr(response, "data", None) + return getattr(data, "file_key", None) + + def _download_image_bytes(self, image_key: str) -> Optional[Tuple[bytes, Optional[str], Optional[str]]]: + if not self._api_client or not image_key: + return None + response = self._api_client.im.v1.image.get( + GetImageRequest.builder().image_key(image_key).build() + ) + if getattr(response, "code", -1) != 0 or not getattr(response, "file", None): + return None + content_type = None + if getattr(response, "raw", None) and getattr(response.raw, "headers", None): + content_type = response.raw.headers.get("Content-Type") + return response.file.read(), response.file_name, content_type + + def _download_file_bytes(self, file_key: str) -> Optional[Tuple[bytes, Optional[str], Optional[str]]]: + if not self._api_client or not file_key: + return None + response = self._api_client.im.v1.file.get( + GetFileRequest.builder().file_key(file_key).build() + ) + if getattr(response, "code", -1) != 0 or not getattr(response, "file", None): + return None + content_type = None + if getattr(response, "raw", None) and getattr(response.raw, "headers", None): + content_type = response.raw.headers.get("Content-Type") + return response.file.read(), response.file_name, content_type + + def _download_message_resource_bytes(self, message_id: str, file_key: str, resource_type: str) -> Optional[Tuple[bytes, Optional[str], Optional[str]]]: + if not self._api_client or not message_id or not file_key: + return None + response = self._api_client.im.v1.message_resource.get( + GetMessageResourceRequest.builder() + .message_id(message_id) + .file_key(file_key) + .type(resource_type) + .build() + ) + if getattr(response, "code", -1) != 0 or not getattr(response, "file", None): + return None + content_type = None + if getattr(response, "raw", None) and getattr(response.raw, "headers", None): + content_type = response.raw.headers.get("Content-Type") + return response.file.read(), response.file_name, content_type + def send_text( self, text: str, userid: Optional[str] = None, chat_id: Optional[str] = None, receive_id_type: Optional[str] = None, + original_message_id: Optional[str] = None, ) -> Optional[dict]: """发送纯文本消息。""" try: - receive_id, resolved_receive_id_type = self._resolve_target( - userid=userid, - chat_id=chat_id, - receive_id_type=receive_id_type, - ) - result = self._send_message( - receive_id, - resolved_receive_id_type, - "text", - {"text": text}, - ) + if original_message_id: + result = self._reply_message( + message_id=original_message_id, + msg_type="text", + content={"text": text}, + ) + else: + receive_id, resolved_receive_id_type = self._resolve_target( + userid=userid, + chat_id=chat_id, + receive_id_type=receive_id_type, + ) + result = self._send_message( + receive_id, + resolved_receive_id_type, + "text", + {"text": text}, + ) except Exception as err: logger.error(f"飞书文本消息发送失败:{err}") return {"success": False} @@ -514,12 +771,148 @@ class Feishu: result["chat_id"] = result.get("chat_id") or chat_id or self._user_chat_mapping.get(userid or "") or self._default_chat_id return result + def send_file( + self, + file_path: str, + userid: Optional[str] = None, + chat_id: Optional[str] = None, + title: Optional[str] = None, + text: Optional[str] = None, + file_name: Optional[str] = None, + receive_id_type: Optional[str] = None, + original_message_id: Optional[str] = None, + ) -> Optional[dict]: + """发送本地图片或文件。""" + local_file = Path(file_path) + if not local_file.exists() or not local_file.is_file(): + logger.error(f"飞书附件不存在:{local_file}") + return {"success": False} + + suffix = local_file.suffix.lower() + is_image = suffix in {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".tiff", ".heic"} + try: + if is_image: + image_key = self._upload_image(local_file) + if not image_key: + return {"success": False} + if original_message_id: + result = self._reply_message( + message_id=original_message_id, + msg_type="image", + content={"image_key": image_key}, + ) + else: + receive_id, resolved_receive_id_type = self._resolve_target( + userid=userid, + chat_id=chat_id, + receive_id_type=receive_id_type, + ) + result = self._send_message( + receive_id, + resolved_receive_id_type, + "image", + {"image_key": image_key}, + ) + else: + file_key = self._upload_file(local_file, file_name=file_name) + if not file_key: + return {"success": False} + if original_message_id: + result = self._reply_message( + message_id=original_message_id, + msg_type="file", + content={"file_key": file_key}, + ) + else: + receive_id, resolved_receive_id_type = self._resolve_target( + userid=userid, + chat_id=chat_id, + receive_id_type=receive_id_type, + ) + result = self._send_message( + receive_id, + resolved_receive_id_type, + "file", + {"file_key": file_key}, + ) + if result and (title or text): + self.send_text( + self._build_message_text(title=title, text=text), + userid=userid, + chat_id=chat_id, + receive_id_type=receive_id_type, + original_message_id=original_message_id, + ) + except Exception as err: + logger.error(f"飞书附件发送失败:{err}") + return {"success": False} + + if not result: + return {"success": False} + result["chat_id"] = result.get("chat_id") or chat_id or self._user_chat_mapping.get(userid or "") or self._default_chat_id + return result + + def send_voice( + self, + voice_path: str, + userid: Optional[str] = None, + chat_id: Optional[str] = None, + caption: Optional[str] = None, + receive_id_type: Optional[str] = None, + original_message_id: Optional[str] = None, + ) -> Optional[dict]: + """发送飞书语音消息。""" + local_file = Path(voice_path) + if not local_file.exists() or not local_file.is_file(): + logger.error(f"飞书语音文件不存在:{local_file}") + return {"success": False} + + try: + file_key = self._upload_file(local_file, file_name=local_file.name) + if not file_key: + return {"success": False} + if original_message_id: + result = self._reply_message( + message_id=original_message_id, + msg_type="audio", + content={"file_key": file_key}, + ) + else: + receive_id, resolved_receive_id_type = self._resolve_target( + userid=userid, + chat_id=chat_id, + receive_id_type=receive_id_type, + ) + result = self._send_message( + receive_id, + resolved_receive_id_type, + "audio", + {"file_key": file_key}, + ) + if result and caption: + self.send_text( + caption, + userid=userid, + chat_id=chat_id, + receive_id_type=receive_id_type, + original_message_id=original_message_id, + ) + except Exception as err: + logger.error(f"飞书语音消息发送失败:{err}") + return {"success": False} + + if not result: + return {"success": False} + result["chat_id"] = result.get("chat_id") or chat_id or self._user_chat_mapping.get(userid or "") or self._default_chat_id + return result + def send_notification( self, message: Notification, userid: Optional[str] = None, chat_id: Optional[str] = None, receive_id_type: Optional[str] = None, + original_message_id: Optional[str] = None, ) -> Optional[dict]: """发送通知消息,优先使用交互卡片承载按钮。""" payload = self._build_card( @@ -529,17 +922,24 @@ class Feishu: buttons=message.buttons, ) try: - receive_id, resolved_receive_id_type = self._resolve_target( - userid=userid, - chat_id=chat_id, - receive_id_type=receive_id_type, - ) - result = self._send_message( - receive_id, - resolved_receive_id_type, - "interactive", - payload, - ) + if original_message_id: + result = self._reply_message( + message_id=original_message_id, + msg_type="interactive", + content=payload, + ) + else: + receive_id, resolved_receive_id_type = self._resolve_target( + userid=userid, + chat_id=chat_id, + receive_id_type=receive_id_type, + ) + result = self._send_message( + receive_id, + resolved_receive_id_type, + "interactive", + payload, + ) except Exception as err: logger.error(f"飞书通知发送失败:{err}") return {"success": False} @@ -578,6 +978,70 @@ class Feishu: logger.error(f"飞书消息更新失败:{err}") return False + def add_message_reaction( + self, + message_id: str, + emoji_type: str, + ) -> Optional[str]: + """为指定消息添加表情回应,并返回 reaction_id。""" + if not self._api_client or not message_id or not emoji_type: + return None + + try: + response = self._api_client.im.v1.message_reaction.create( + CreateMessageReactionRequest.builder() + .message_id(message_id) + .request_body( + CreateMessageReactionRequestBody.builder() + .reaction_type( + Emoji.builder().emoji_type(emoji_type).build() + ) + .build() + ) + .build() + ) + if not response.success(): + logger.error( + "飞书消息表情添加失败:message_id=%s, emoji_type=%s, code=%s, msg=%s, log_id=%s", + message_id, + emoji_type, + response.code, + response.msg, + response.get_log_id(), + ) + return None + data = getattr(response, "data", None) + return getattr(data, "reaction_id", None) + except Exception as err: + logger.error(f"飞书消息表情添加失败:{err}") + return None + + def delete_message_reaction(self, message_id: str, reaction_id: str) -> bool: + """删除指定消息上的表情回应。""" + if not self._api_client or not message_id or not reaction_id: + return False + + try: + response = self._api_client.im.v1.message_reaction.delete( + DeleteMessageReactionRequest.builder() + .message_id(message_id) + .reaction_id(reaction_id) + .build() + ) + if response.success(): + return True + logger.error( + "飞书消息表情删除失败:message_id=%s, reaction_id=%s, code=%s, msg=%s, log_id=%s", + message_id, + reaction_id, + response.code, + response.msg, + response.get_log_id(), + ) + except Exception as err: + logger.error(f"飞书消息表情删除失败:{err}") + return False + def send_medias_message( self, message: Notification, diff --git a/tests/test_agent_image_support.py b/tests/test_agent_image_support.py index 28180fee..5bb688a2 100644 --- a/tests/test_agent_image_support.py +++ b/tests/test_agent_image_support.py @@ -611,6 +611,49 @@ class AgentImageSupportTest(unittest.TestCase): source="wechat-test", ) + def test_download_images_routes_feishu_refs_to_module_downloader(self): + chain = MessageChain() + + with patch.object( + chain, + "run_module", + return_value="data:image/png;base64,feishu123", + ) as run_module: + data_urls = chain._download_attachments_to_data_urls( + attachments=[ + CommingMessage.MessageImage( + ref="feishu://image/img_v2_xxx", + mime_type="image/png", + ) + ], + channel=MessageChannel.Feishu, + source="feishu-test", + ) + + self.assertEqual(data_urls, ["data:image/png;base64,feishu123"]) + run_module.assert_called_once_with( + "download_feishu_image_to_data_url", + image_ref="feishu://image/img_v2_xxx", + source="feishu-test", + ) + + def test_download_message_file_bytes_supports_feishu_refs(self): + chain = MessageChain() + + with patch.object(chain, "run_module", return_value=b"feishu-file") as run_module: + content = chain._download_message_file_bytes( + file_ref="feishu://file/file_xxx/report.pdf", + channel=MessageChannel.Feishu, + source="feishu-test", + ) + + self.assertEqual(content, b"feishu-file") + run_module.assert_called_once_with( + "download_feishu_file_bytes", + file_ref="feishu://file/file_xxx/report.pdf", + source="feishu-test", + ) + def test_wechat_message_parser_extracts_image_media_id(self): module = WechatModule() xml_message = b""" diff --git a/tests/test_feishu.py b/tests/test_feishu.py index 488a3242..c6a071fb 100644 --- a/tests/test_feishu.py +++ b/tests/test_feishu.py @@ -1,7 +1,9 @@ import sys import asyncio import json +import tempfile import unittest +from pathlib import Path from types import ModuleType, SimpleNamespace from unittest.mock import ANY, MagicMock, patch @@ -19,6 +21,7 @@ if "Pinyin2Hanzi" not in sys.modules: from app.modules.feishu import FeishuModule from app.modules.feishu.feishu import Feishu from app.schemas import Notification +from app.schemas.message import ChannelCapability, ChannelCapabilityManager from app.schemas.types import MessageChannel @@ -39,19 +42,66 @@ class TestFeishu(unittest.TestCase): def _success_response(message_id="om_test", chat_id="oc_test"): response = MagicMock() response.success.return_value = True - response.data = SimpleNamespace(message_id=message_id, chat_id=chat_id) + response.data = SimpleNamespace( + message_id=message_id, + chat_id=chat_id, + msg_type="interactive", + ) return response @staticmethod - def _build_message_api(create_response=None, patch_response=None): + def _reaction_success_response(reaction_id="reaction_test"): + response = MagicMock() + response.success.return_value = True + response.data = SimpleNamespace(reaction_id=reaction_id) + return response + + @staticmethod + def _build_message_api(create_response=None, patch_response=None, reply_response=None, reaction_create_response=None, reaction_delete_response=None, image_create_response=None, file_create_response=None, image_get_response=None, file_get_response=None, message_resource_response=None): message_api = SimpleNamespace( create=MagicMock(return_value=create_response), patch=MagicMock(return_value=patch_response), + reply=MagicMock(return_value=reply_response), update=MagicMock(), ) - api_client = SimpleNamespace(im=SimpleNamespace(v1=SimpleNamespace(message=message_api))) + message_reaction_api = SimpleNamespace( + create=MagicMock(return_value=reaction_create_response), + delete=MagicMock(return_value=reaction_delete_response), + ) + image_api = SimpleNamespace( + create=MagicMock(return_value=image_create_response), + get=MagicMock(return_value=image_get_response), + ) + file_api = SimpleNamespace( + create=MagicMock(return_value=file_create_response), + get=MagicMock(return_value=file_get_response), + ) + message_resource_api = SimpleNamespace( + get=MagicMock(return_value=message_resource_response), + ) + api_client = SimpleNamespace( + im=SimpleNamespace( + v1=SimpleNamespace( + message=message_api, + message_reaction=message_reaction_api, + image=image_api, + file=file_api, + message_resource=message_resource_api, + ) + ) + ) return api_client, message_api + @staticmethod + def _resource_response(content: bytes, file_name: str = "resource.bin", content_type: str = "application/octet-stream"): + response = MagicMock() + response.code = 0 + response.file = MagicMock() + response.file.read.return_value = content + response.file_name = file_name + response.raw = SimpleNamespace(headers={"Content-Type": content_type}) + return response + def test_parse_message_returns_callback_message(self): client = self._build_client() @@ -123,6 +173,8 @@ class TestFeishu(unittest.TestCase): content = json.loads(request.request_body.content) self.assertNotIn("card", content) + self.assertTrue(content["config"]["update_multi"]) + self.assertEqual(content["elements"][0]["text_size"], "heading") self.assertEqual(content["elements"][0]["tag"], "markdown") def test_send_notification_supports_user_id_target(self): @@ -161,8 +213,160 @@ class TestFeishu(unittest.TestCase): self.assertEqual(request.message_id, "om_456") content = json.loads(request.request_body.content) self.assertNotIn("card", content) + self.assertTrue(content["config"]["update_multi"]) self.assertEqual(content["elements"][0]["tag"], "markdown") + def test_send_notification_replies_when_original_message_id_is_present(self): + client = self._build_client() + client._api_client, message_api = self._build_message_api( + reply_response=self._success_response(message_id="om_reply") + ) + + result = client.send_notification( + Notification(title="回复标题", text="回复正文"), + userid="ou_user_9", + original_message_id="om_origin", + ) + + self.assertTrue(result["success"]) + message_api.reply.assert_called_once() + request = message_api.reply.call_args.args[0] + self.assertEqual(request.message_id, "om_origin") + self.assertEqual(request.request_body.msg_type, "interactive") + + def test_message_reaction_create_and_delete_use_official_api(self): + client = self._build_client() + client._api_client, _ = self._build_message_api( + reaction_create_response=self._reaction_success_response("reaction_1"), + reaction_delete_response=self._success_response(), + ) + + reaction_id = client.add_message_reaction("om_origin", Feishu.PROCESSING_REACTION_EMOJI) + deleted = client.delete_message_reaction("om_origin", "reaction_1") + + self.assertEqual(reaction_id, "reaction_1") + self.assertTrue(deleted) + create_request = client._api_client.im.v1.message_reaction.create.call_args.args[0] + self.assertEqual(create_request.message_id, "om_origin") + self.assertEqual( + create_request.request_body.reaction_type.emoji_type, + Feishu.PROCESSING_REACTION_EMOJI, + ) + delete_request = client._api_client.im.v1.message_reaction.delete.call_args.args[0] + self.assertEqual(delete_request.message_id, "om_origin") + self.assertEqual(delete_request.reaction_id, "reaction_1") + + def test_parse_message_supports_image_and_file_payloads(self): + client = self._build_client() + + image_message = client.parse_message( + { + "type": "message", + "text": "", + "images": [{"ref": "feishu://image/img_v2_test"}], + "message_id": "om_img", + "chat_id": "oc_chat", + "sender": { + "open_id": "ou_user_5", + "name": "tester", + }, + } + ) + + file_message = client.parse_message( + { + "type": "message", + "text": "", + "files": [{"ref": "feishu://file/file_key/report.pdf", "name": "report.pdf"}], + "message_id": "om_file", + "chat_id": "oc_chat", + "sender": { + "open_id": "ou_user_6", + "name": "tester", + }, + } + ) + + self.assertEqual(image_message.images[0].ref, "feishu://image/img_v2_test") + self.assertEqual(file_message.files[0].ref, "feishu://file/file_key/report.pdf") + + def test_feishu_channel_capabilities_enable_images_and_files(self): + self.assertTrue( + ChannelCapabilityManager.supports_capability( + MessageChannel.Feishu, + ChannelCapability.IMAGES, + ) + ) + self.assertTrue( + ChannelCapabilityManager.supports_capability( + MessageChannel.Feishu, + ChannelCapability.FILE_SENDING, + ) + ) + + def test_send_file_uploads_image_then_sends_image_message(self): + client = self._build_client() + image_upload_response = MagicMock() + image_upload_response.success.return_value = True + image_upload_response.data = SimpleNamespace(image_key="img_v2_uploaded") + client._api_client, message_api = self._build_message_api( + create_response=self._success_response(message_id="om_image"), + image_create_response=image_upload_response, + ) + + with tempfile.NamedTemporaryFile(suffix=".png") as fp: + fp.write(b"png-bytes") + fp.flush() + result = client.send_file(file_path=fp.name, userid="ou_user_7") + + self.assertTrue(result["success"]) + client._api_client.im.v1.image.create.assert_called_once() + request = message_api.create.call_args.args[0] + self.assertEqual(request.request_body.msg_type, "image") + self.assertEqual(json.loads(request.request_body.content)["image_key"], "img_v2_uploaded") + + def test_send_voice_uploads_audio_file_and_optionally_sends_caption(self): + client = self._build_client() + file_upload_response = MagicMock() + file_upload_response.success.return_value = True + file_upload_response.data = SimpleNamespace(file_key="file_audio") + client._api_client, message_api = self._build_message_api( + create_response=self._success_response(message_id="om_audio"), + file_create_response=file_upload_response, + ) + + with tempfile.NamedTemporaryFile(suffix=".opus") as fp: + fp.write(b"opus-bytes") + fp.flush() + with patch.object(client, "send_text", return_value={"success": True}) as send_text: + result = client.send_voice( + voice_path=fp.name, + userid="ou_user_8", + caption="这是说明", + ) + + self.assertTrue(result["success"]) + request = message_api.create.call_args.args[0] + self.assertEqual(request.request_body.msg_type, "audio") + self.assertEqual(json.loads(request.request_body.content)["file_key"], "file_audio") + send_text.assert_called_once() + + def test_download_helpers_return_bytes_and_data_url(self): + client = self._build_client() + client._api_client, _ = self._build_message_api( + image_get_response=self._resource_response(b"image-bytes", file_name="poster.png", content_type="image/png"), + file_get_response=self._resource_response(b"file-bytes", file_name="report.txt", content_type="text/plain"), + message_resource_response=self._resource_response(b"resource-bytes", file_name="voice.opus", content_type="audio/ogg"), + ) + + image_download = client._download_image_bytes("img_v2_test") + file_download = client._download_file_bytes("file_test") + resource_download = client._download_message_resource_bytes("om_test", "file_test", "audio") + + self.assertEqual(image_download[0], b"image-bytes") + self.assertEqual(file_download[0], b"file-bytes") + self.assertEqual(resource_download[0], b"resource-bytes") + def test_module_send_direct_message_prefers_open_id_target(self): module = FeishuModule() module._channel = MessageChannel.Feishu @@ -191,6 +395,7 @@ class TestFeishu(unittest.TestCase): userid="ou_target", chat_id=None, receive_id_type="open_id", + original_message_id=None, ) self.assertTrue(response.success) self.assertEqual(response.message_id, "om_789") @@ -241,6 +446,74 @@ class TestFeishu(unittest.TestCase): runner.assert_called_once() future.result.assert_called_once_with(timeout=5) + def test_module_download_helpers_delegate_to_client(self): + module = FeishuModule() + client = MagicMock() + client._download_image_bytes.return_value = (b"image", "poster.png", "image/png") + client._download_file_bytes.return_value = (b"file", "note.txt", "text/plain") + + with patch.object(module, "get_config", return_value=SimpleNamespace(name="feishu-main")), patch.object( + module, "get_instance", return_value=client + ): + data_url = module.download_feishu_image_to_data_url("feishu://image/img_v2_xxx", "feishu-main") + file_bytes = module.download_feishu_file_bytes("feishu://file/file_xxx/note.txt", "feishu-main") + + self.assertTrue(data_url.startswith("data:image/png;base64,")) + self.assertEqual(file_bytes, b"file") + + def test_module_message_reaction_helpers_delegate_to_client(self): + module = FeishuModule() + client = MagicMock() + client.add_message_reaction.return_value = "reaction_2" + client.delete_message_reaction.return_value = True + + with patch.object(module, "get_config", return_value=SimpleNamespace(name="feishu-main")), patch.object( + module, "get_instance", return_value=client + ): + reaction_id = module.add_feishu_message_reaction("om_x", "GLANCE", "feishu-main") + deleted = module.delete_feishu_message_reaction("om_x", "reaction_2", "feishu-main") + + self.assertEqual(reaction_id, "reaction_2") + self.assertTrue(deleted) + + def test_module_post_message_prefers_file_and_voice_paths(self): + module = FeishuModule() + conf = SimpleNamespace(name="feishu-main") + client = MagicMock() + + with patch.object(module, "get_configs", return_value={"feishu-main": conf}), patch.object( + module, "check_message", return_value=True + ), patch.object(module, "get_instance", return_value=client): + module.post_message(Notification(file_path="/tmp/demo.txt", text="说明", title="标题", userid="ou_user")) + module.post_message(Notification(voice_path="/tmp/demo.opus", voice_caption="语音说明", userid="ou_user")) + + client.send_file.assert_called_once() + client.send_voice.assert_called_once() + + def test_module_post_message_passes_original_message_id_for_reply(self): + module = FeishuModule() + conf = SimpleNamespace(name="feishu-main") + client = MagicMock() + + with patch.object(module, "get_configs", return_value={"feishu-main": conf}), patch.object( + module, "check_message", return_value=True + ), patch.object(module, "get_instance", return_value=client): + module.post_message( + Notification( + title="标题", + text="正文", + userid="ou_user", + original_message_id="om_source", + original_chat_id="oc_source", + ) + ) + + client.send_notification.assert_called_once() + self.assertEqual( + client.send_notification.call_args.kwargs["original_message_id"], + "om_source", + ) + if __name__ == "__main__": unittest.main()