feat(feishu): enhance message handling with file and voice support, add reaction management

This commit is contained in:
jxxghp
2026-05-13 00:07:36 +08:00
parent a46ce24691
commit 0989439d25
5 changed files with 1050 additions and 55 deletions
+105
View File
@@ -5,6 +5,7 @@ import mimetypes
import re import re
import time import time
import uuid import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Any, Optional, Dict, Union, List, Tuple from typing import Any, Optional, Dict, Union, List, Tuple
@@ -47,6 +48,13 @@ class MessageChain(ChainBase):
# 会话超时时间(分钟) # 会话超时时间(分钟)
_session_timeout_minutes: int = 24 * 60 _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: def process(self, body: Any, form: Any, args: Any) -> None:
""" """
调用模块识别消息内容 调用模块识别消息内容
@@ -151,6 +159,46 @@ class MessageChain(ChainBase):
text=text, 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 text.startswith("CALLBACK:"):
if ChannelCapabilityManager.supports_callbacks(channel): if ChannelCapabilityManager.supports_callbacks(channel):
self._handle_callback( 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( def _handle_callback(
self, self,
text: str, text: str,
@@ -1098,12 +1189,14 @@ class MessageChain(ChainBase):
), ),
global_vars.loop, global_vars.loop,
) )
return
except Exception as e: except Exception as e:
logger.error(f"处理AI智能体消息失败: {e}") logger.error(f"处理AI智能体消息失败: {e}")
self.messagehelper.put( self.messagehelper.put(
f"AI智能体处理失败: {str(e)}", role="system", title="MoviePilot助手" f"AI智能体处理失败: {str(e)}", role="system", title="MoviePilot助手"
) )
return
def _transcribe_audio_refs( def _transcribe_audio_refs(
self, audio_refs: List[str], channel: MessageChannel, source: str self, audio_refs: List[str], channel: MessageChannel, source: str
@@ -1289,6 +1382,14 @@ class MessageChain(ChainBase):
) )
if data_url: if data_url:
data_urls.append(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: elif channel == MessageChannel.Slack:
data_url = self.run_module( data_url = self.run_module(
"download_slack_file_to_data_url", "download_slack_file_to_data_url",
@@ -1462,6 +1563,10 @@ class MessageChain(ChainBase):
return self.run_module( return self.run_module(
"download_wechat_media_bytes", media_ref=file_ref, source=source "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/"): if file_ref.startswith("slack://file/"):
return self.run_module( return self.run_module(
"download_slack_file_bytes", file_ref=file_ref, source=source "download_slack_file_bytes", file_ref=file_ref, source=source
+110
View File
@@ -92,11 +92,33 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]):
userid, chat_id, receive_id_type = self._resolve_message_target(message) userid, chat_id, receive_id_type = self._resolve_message_target(message)
client: Feishu = self.get_instance(conf.name) client: Feishu = self.get_instance(conf.name)
if client: if client:
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( client.send_notification(
message=message, message=message,
userid=userid, userid=userid,
chat_id=chat_id, chat_id=chat_id,
receive_id_type=receive_id_type, 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: def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
@@ -162,11 +184,33 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]):
client: Feishu = self.get_instance(conf.name) client: Feishu = self.get_instance(conf.name)
if not client: if not client:
continue continue
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( result = client.send_notification(
message=message, message=message,
userid=userid, userid=userid,
chat_id=chat_id, chat_id=chat_id,
receive_id_type=receive_id_type, 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"): if result and result.get("success"):
return MessageResponse( return MessageResponse(
@@ -177,3 +221,69 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]):
success=True, success=True,
) )
return None 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)
+482 -18
View File
@@ -1,17 +1,33 @@
import asyncio import asyncio
import base64
import json import json
import mimetypes
import threading import threading
import uuid import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import lark_oapi as lark import lark_oapi as lark
import lark_oapi.ws.client as lark_ws_client_module import lark_oapi.ws.client as lark_ws_client_module
from lark_oapi.api.im.v1 import ( from lark_oapi.api.im.v1 import (
CreateFileRequest,
CreateFileRequestBody,
CreateImageRequest,
CreateImageRequestBody,
CreateMessageRequest, CreateMessageRequest,
CreateMessageRequestBody, CreateMessageRequestBody,
CreateMessageReactionRequest,
CreateMessageReactionRequestBody,
DeleteMessageReactionRequest,
GetFileRequest,
GetImageRequest,
GetMessageResourceRequest,
PatchMessageRequest, PatchMessageRequest,
PatchMessageRequestBody, PatchMessageRequestBody,
P2ImMessageReceiveV1, P2ImMessageReceiveV1,
ReplyMessageRequest,
ReplyMessageRequestBody,
Emoji,
) )
from lark_oapi.core.const import FEISHU_DOMAIN from lark_oapi.core.const import FEISHU_DOMAIN
from lark_oapi.core.enum import LogLevel from lark_oapi.core.enum import LogLevel
@@ -31,6 +47,8 @@ from app.utils.http import RequestUtils
class Feishu: class Feishu:
"""飞书通知客户端,负责长连接收消息与主动发送通知。""" """飞书通知客户端,负责长连接收消息与主动发送通知。"""
PROCESSING_REACTION_EMOJI = "GLANCE"
def __init__( def __init__(
self, self,
FEISHU_APP_ID: Optional[str] = None, FEISHU_APP_ID: Optional[str] = None,
@@ -164,20 +182,43 @@ class Feishu:
threading.Thread(target=_run, daemon=True).start() threading.Thread(target=_run, daemon=True).start()
@staticmethod @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) raw_content = getattr(message, "content", None)
if not raw_content: if not raw_content:
return "" return "", None, None, None
try: try:
content = json.loads(raw_content) content = json.loads(raw_content)
except Exception: except Exception:
return "" return "", None, None, None
if not isinstance(content, dict): if not isinstance(content, dict):
return "" return "", None, None, None
if isinstance(content.get("text"), str):
return content.get("text", "").strip() message_type = getattr(message, "message_type", None)
return "" 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: 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) open_id = getattr(sender_id, "open_id", None)
user_id = getattr(sender_id, "user_id", None) user_id = getattr(sender_id, "user_id", None)
chat_id = getattr(message, "chat_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 = { payload = {
"type": "message", "type": "message",
@@ -218,7 +260,11 @@ class Feishu:
"message_id": getattr(message, "message_id", None), "message_id": getattr(message, "message_id", None),
"chat_id": chat_id, "chat_id": chat_id,
"chat_type": getattr(message, "chat_type", None), "chat_type": getattr(message, "chat_type", None),
"message_type": message_type,
"text": text, "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": { "sender": {
"open_id": open_id, "open_id": open_id,
"user_id": user_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_user_id_type(open_id=open_id, user_id=user_id)
self._remember_target(userid=userid, chat_id=chat_id) self._remember_target(userid=userid, chat_id=chat_id)
logger.info( logger.info(
"收到来自 %s 的飞书消息:userid=%s, chat_id=%s, text=%s", "收到来自 %s 的飞书消息:userid=%s, chat_id=%s, type=%s, text=%s",
self._name, self._name,
userid, userid,
chat_id, chat_id,
message_type,
text, text,
) )
self._forward_to_message_chain(payload) self._forward_to_message_chain(payload)
@@ -345,7 +392,19 @@ class Feishu:
) )
text = (message.get("text") or "").strip() 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 return None
if text.startswith("/") and self._admins and str(userid) not in self._admins: if text.startswith("/") and self._admins and str(userid) not in self._admins:
@@ -365,6 +424,9 @@ class Feishu:
text=text, text=text,
message_id=message.get("message_id"), message_id=message.get("message_id"),
chat_id=message.get("chat_id"), chat_id=message.get("chat_id"),
images=images,
audio_refs=audio_refs,
files=files,
) )
def _resolve_target( def _resolve_target(
@@ -393,16 +455,41 @@ class Feishu:
return resolved_chat_id, "chat_id" return resolved_chat_id, "chat_id"
raise ValueError("未找到可发送的飞书目标") raise ValueError("未找到可发送的飞书目标")
@staticmethod
def _escape_card_text(text: Optional[str]) -> str:
"""转义飞书卡片 markdown 中易误触的字符。"""
if not text:
return ""
escaped = str(text)
for source, target in {
"\\": "\",
"<": "&#60;",
">": "&#62;",
}.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 @staticmethod
def _build_message_text(title: Optional[str], text: Optional[str], link: Optional[str] = None) -> str: def _build_message_text(title: Optional[str], text: Optional[str], link: Optional[str] = None) -> str:
"""拼接飞书 Markdown 文本内容。""" """拼接飞书 Markdown 文本内容。"""
parts = [] parts = []
if title: if title:
parts.append(f"**{title.strip()}**") parts.append(f"**{Feishu._escape_card_text(title).strip()}**")
if text: if text:
parts.append(text.strip()) parts.append(Feishu._escape_card_text(text).strip())
if link: if link:
parts.append(f"[查看详情]({link})") parts.append(f"[查看详情]({link.strip()})")
return "\n\n".join(part for part in parts if part) return "\n\n".join(part for part in parts if part)
@staticmethod @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]: 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] = [] elements: List[dict] = []
if content: title_section = self._build_markdown_section(title, text_size="heading")
elements.append({"tag": "markdown", "content": content}) 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)) elements.extend(self._card_actions(buttons))
return { 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, "elements": elements,
} }
@@ -483,17 +581,176 @@ class Feishu:
"success": True, "success": True,
"message_id": getattr(data, "message_id", None), "message_id": getattr(data, "message_id", None),
"chat_id": getattr(data, "chat_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( def send_text(
self, self,
text: str, text: str,
userid: Optional[str] = None, userid: Optional[str] = None,
chat_id: Optional[str] = None, chat_id: Optional[str] = None,
receive_id_type: Optional[str] = None, receive_id_type: Optional[str] = None,
original_message_id: Optional[str] = None,
) -> Optional[dict]: ) -> Optional[dict]:
"""发送纯文本消息。""" """发送纯文本消息。"""
try: try:
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( receive_id, resolved_receive_id_type = self._resolve_target(
userid=userid, userid=userid,
chat_id=chat_id, chat_id=chat_id,
@@ -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 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 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( def send_notification(
self, self,
message: Notification, message: Notification,
userid: Optional[str] = None, userid: Optional[str] = None,
chat_id: Optional[str] = None, chat_id: Optional[str] = None,
receive_id_type: Optional[str] = None, receive_id_type: Optional[str] = None,
original_message_id: Optional[str] = None,
) -> Optional[dict]: ) -> Optional[dict]:
"""发送通知消息,优先使用交互卡片承载按钮。""" """发送通知消息,优先使用交互卡片承载按钮。"""
payload = self._build_card( payload = self._build_card(
@@ -529,6 +922,13 @@ class Feishu:
buttons=message.buttons, buttons=message.buttons,
) )
try: try:
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( receive_id, resolved_receive_id_type = self._resolve_target(
userid=userid, userid=userid,
chat_id=chat_id, chat_id=chat_id,
@@ -578,6 +978,70 @@ class Feishu:
logger.error(f"飞书消息更新失败:{err}") logger.error(f"飞书消息更新失败:{err}")
return False 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( def send_medias_message(
self, self,
message: Notification, message: Notification,
+43
View File
@@ -611,6 +611,49 @@ class AgentImageSupportTest(unittest.TestCase):
source="wechat-test", 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): def test_wechat_message_parser_extracts_image_media_id(self):
module = WechatModule() module = WechatModule()
xml_message = b""" xml_message = b"""
+276 -3
View File
@@ -1,7 +1,9 @@
import sys import sys
import asyncio import asyncio
import json import json
import tempfile
import unittest import unittest
from pathlib import Path
from types import ModuleType, SimpleNamespace from types import ModuleType, SimpleNamespace
from unittest.mock import ANY, MagicMock, patch 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 import FeishuModule
from app.modules.feishu.feishu import Feishu from app.modules.feishu.feishu import Feishu
from app.schemas import Notification from app.schemas import Notification
from app.schemas.message import ChannelCapability, ChannelCapabilityManager
from app.schemas.types import MessageChannel 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"): def _success_response(message_id="om_test", chat_id="oc_test"):
response = MagicMock() response = MagicMock()
response.success.return_value = True 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 return response
@staticmethod @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( message_api = SimpleNamespace(
create=MagicMock(return_value=create_response), create=MagicMock(return_value=create_response),
patch=MagicMock(return_value=patch_response), patch=MagicMock(return_value=patch_response),
reply=MagicMock(return_value=reply_response),
update=MagicMock(), 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 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): def test_parse_message_returns_callback_message(self):
client = self._build_client() client = self._build_client()
@@ -123,6 +173,8 @@ class TestFeishu(unittest.TestCase):
content = json.loads(request.request_body.content) content = json.loads(request.request_body.content)
self.assertNotIn("card", 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") self.assertEqual(content["elements"][0]["tag"], "markdown")
def test_send_notification_supports_user_id_target(self): def test_send_notification_supports_user_id_target(self):
@@ -161,8 +213,160 @@ class TestFeishu(unittest.TestCase):
self.assertEqual(request.message_id, "om_456") self.assertEqual(request.message_id, "om_456")
content = json.loads(request.request_body.content) content = json.loads(request.request_body.content)
self.assertNotIn("card", content) self.assertNotIn("card", content)
self.assertTrue(content["config"]["update_multi"])
self.assertEqual(content["elements"][0]["tag"], "markdown") 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): def test_module_send_direct_message_prefers_open_id_target(self):
module = FeishuModule() module = FeishuModule()
module._channel = MessageChannel.Feishu module._channel = MessageChannel.Feishu
@@ -191,6 +395,7 @@ class TestFeishu(unittest.TestCase):
userid="ou_target", userid="ou_target",
chat_id=None, chat_id=None,
receive_id_type="open_id", receive_id_type="open_id",
original_message_id=None,
) )
self.assertTrue(response.success) self.assertTrue(response.success)
self.assertEqual(response.message_id, "om_789") self.assertEqual(response.message_id, "om_789")
@@ -241,6 +446,74 @@ class TestFeishu(unittest.TestCase):
runner.assert_called_once() runner.assert_called_once()
future.result.assert_called_once_with(timeout=5) 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__": if __name__ == "__main__":
unittest.main() unittest.main()