mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-13 07:26:45 +00:00
feat(feishu): enhance message handling with file and voice support, add reaction management
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user