feat(file): 新增文件消息的接收与发送功能
本次更新为框架引入了完整的文件消息处理能力,涵盖了发送和接收两个方面,使机器人能够处理文件传输。 主要变更包括: - **发送功能**: - 在 `plugin_system.apis.send_api` 中新增了 `file_to_stream` 公共 API,允许插件向指定聊天流(私聊或群聊)发送本地文件。 - 为文件上传设置了更长的超时时间,并增加了临时的 WSL 路径转换逻辑。 - **接收功能**: - `chat.message_receive` 模块现在能够正确处理 `file` 类型的消息段,并生成可读的文本描述。 - NapCat 适配器增加了对文件消息 (`file`) 和群文件上传通知 (`group_upload`) 的解析能力。 - 通过智能识别和解析,能够将机器人自己发送文件后收到的 JSON 卡片回声消息,正确地转换回标准的文件消息段。 - **重构**: - 将接收消息的 `content_format` 属性改为根据消息段动态生成,提高了对复合消息类型的适应性。 - 将未知消息段的日志级别从 `info` 调整为 `warning`,以便更好地监控未处理的消息类型。
This commit is contained in:
@@ -239,6 +239,12 @@ class MessageRecv(Message):
|
||||
}
|
||||
"""
|
||||
return ""
|
||||
elif segment.type == "file":
|
||||
if isinstance(segment.data, dict):
|
||||
file_name = segment.data.get('name', '未知文件')
|
||||
file_size = segment.data.get('size', '未知大小')
|
||||
return f"[文件:{file_name} ({file_size}字节)]"
|
||||
return "[收到一个文件]"
|
||||
elif segment.type == "video":
|
||||
self.is_picid = False
|
||||
self.is_emoji = False
|
||||
@@ -296,8 +302,8 @@ class MessageRecv(Message):
|
||||
else:
|
||||
return ""
|
||||
else:
|
||||
logger.info("未启用视频识别")
|
||||
return "[视频]"
|
||||
logger.warning(f"未知的消息段类型: {segment.type}")
|
||||
return f"[{segment.type} 消息]"
|
||||
except Exception as e:
|
||||
logger.error(f"处理消息段失败: {e!s}, 类型: {segment.type}, 数据: {segment.data}")
|
||||
return f"[处理失败的{segment.type}消息]"
|
||||
@@ -433,6 +439,12 @@ class MessageRecvS4U(MessageRecv):
|
||||
self.is_screen = True
|
||||
self.screen_info = segment.data
|
||||
return "屏幕信息"
|
||||
elif segment.type == "file":
|
||||
if isinstance(segment.data, dict):
|
||||
file_name = segment.data.get('name', '未知文件')
|
||||
file_size = segment.data.get('size', '未知大小')
|
||||
return f"[文件:{file_name} ({file_size}字节)]"
|
||||
return "[收到一个文件]"
|
||||
elif segment.type == "video":
|
||||
self.is_voice = False
|
||||
self.is_picid = False
|
||||
@@ -490,8 +502,8 @@ class MessageRecvS4U(MessageRecv):
|
||||
else:
|
||||
return ""
|
||||
else:
|
||||
logger.info("未启用视频识别")
|
||||
return "[视频]"
|
||||
logger.warning(f"未知的消息段类型: {segment.type}")
|
||||
return f"[{segment.type} 消息]"
|
||||
except Exception as e:
|
||||
logger.error(f"处理消息段失败: {e!s}, 类型: {segment.type}, 数据: {segment.data}")
|
||||
return f"[处理失败的{segment.type}消息]"
|
||||
|
||||
@@ -27,6 +27,67 @@
|
||||
|
||||
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
async def file_to_stream(
|
||||
file_path: str,
|
||||
stream_id: str,
|
||||
file_name: str | None = None,
|
||||
storage_message: bool = True,
|
||||
set_reply: bool = True
|
||||
) -> bool:
|
||||
"""向指定流发送文件
|
||||
|
||||
Args:
|
||||
file_path: 文件的本地路径
|
||||
stream_id: 聊天流ID
|
||||
file_name: 发送到对方时显示的文件名,如果为 None 则使用原始文件名
|
||||
storage_message: 是否存储消息到数据库
|
||||
|
||||
Returns:
|
||||
bool: 是否发送成功
|
||||
"""
|
||||
target_stream = await get_chat_manager().get_stream(stream_id)
|
||||
if not target_stream:
|
||||
logger.error(f"[SendAPI] 未找到聊天流: {stream_id}")
|
||||
return False
|
||||
|
||||
if not file_name:
|
||||
file_name = Path(file_path).name
|
||||
|
||||
# 临时的WSL路径转换方案
|
||||
if file_path.startswith("E:"):
|
||||
original_path = file_path
|
||||
file_path = "/mnt/e/" + file_path[3:].replace("\\", "/")
|
||||
logger.info(f"WSL路径转换: {original_path} -> {file_path}")
|
||||
|
||||
params = {
|
||||
"file": file_path,
|
||||
"name": file_name,
|
||||
}
|
||||
|
||||
action = ""
|
||||
if target_stream.group_info:
|
||||
action = "upload_group_file"
|
||||
params["group_id"] = target_stream.group_info.group_id
|
||||
else:
|
||||
action = "upload_private_file"
|
||||
params["user_id"] = target_stream.user_info.user_id
|
||||
|
||||
response = await adapter_command_to_stream(
|
||||
action=action,
|
||||
params=params,
|
||||
stream_id=stream_id,
|
||||
timeout=300.0 # 文件上传可能需要更长时间
|
||||
)
|
||||
|
||||
if response.get("status") == "ok":
|
||||
logger.info(f"文件 {file_name} 已成功发送到 {stream_id}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"文件 {file_name} 发送到 {stream_id} 失败: {response.get('message')}")
|
||||
return False
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
@@ -33,6 +33,7 @@ class NoticeType: # 通知事件
|
||||
notify = "notify"
|
||||
group_ban = "group_ban" # 群禁言
|
||||
group_msg_emoji_like = "group_msg_emoji_like" # 群聊表情回复
|
||||
group_upload = "group_upload" # 群文件上传
|
||||
|
||||
class Notify:
|
||||
poke = "poke" # 戳一戳
|
||||
@@ -59,6 +60,7 @@ class RealMessageType: # 实际消息分类
|
||||
forward = "forward" # 转发消息
|
||||
node = "node" # 转发消息节点
|
||||
json = "json" # json消息
|
||||
file = "file" # 文件
|
||||
|
||||
|
||||
class MessageSentType:
|
||||
|
||||
@@ -178,10 +178,6 @@ class MessageHandler:
|
||||
message_time: float = time.time() # 应可乐要求,现在是float了
|
||||
|
||||
template_info: TemplateInfo = None # 模板信息,暂时为空,等待启用
|
||||
format_info: FormatInfo = FormatInfo(
|
||||
content_format=["text", "image", "emoji", "voice"],
|
||||
accept_format=ACCEPT_FORMAT,
|
||||
) # 格式化信息
|
||||
if message_type == MessageType.private:
|
||||
sub_type = raw_message.get("sub_type")
|
||||
if sub_type == MessageType.Private.friend:
|
||||
@@ -275,6 +271,25 @@ class MessageHandler:
|
||||
logger.warning(f"群聊消息类型 {sub_type} 不支持")
|
||||
return None
|
||||
|
||||
# 处理实际信息
|
||||
if not raw_message.get("message"):
|
||||
logger.warning("原始消息内容为空")
|
||||
return None
|
||||
|
||||
# 获取Seg列表
|
||||
seg_message: List[Seg] = await self.handle_real_message(raw_message)
|
||||
if not seg_message:
|
||||
logger.warning("处理后消息内容为空")
|
||||
return None
|
||||
|
||||
# 动态生成 content_format
|
||||
content_formats = sorted(list(set(seg.type for seg in seg_message)))
|
||||
logger.debug(f"动态生成 content_format: {content_formats}")
|
||||
format_info: FormatInfo = FormatInfo(
|
||||
content_format=content_formats,
|
||||
accept_format=ACCEPT_FORMAT,
|
||||
)
|
||||
|
||||
additional_config: dict = {}
|
||||
if config_api.get_plugin_config(self.plugin_config, "voice.use_tts"):
|
||||
additional_config["allow_tts"] = True
|
||||
@@ -291,17 +306,6 @@ class MessageHandler:
|
||||
additional_config=additional_config,
|
||||
)
|
||||
|
||||
# 处理实际信息
|
||||
if not raw_message.get("message"):
|
||||
logger.warning("原始消息内容为空")
|
||||
return None
|
||||
|
||||
# 获取Seg列表
|
||||
seg_message: List[Seg] = await self.handle_real_message(raw_message)
|
||||
if not seg_message:
|
||||
logger.warning("处理后消息内容为空")
|
||||
return None
|
||||
|
||||
# 消息缓冲功能已移除,直接处理消息
|
||||
|
||||
logger.debug(f"准备发送消息到MoFox-Bot,消息段数量: {len(seg_message)}")
|
||||
@@ -482,6 +486,13 @@ class MessageHandler:
|
||||
seg_message.append(ret_seg)
|
||||
else:
|
||||
logger.warning("json处理失败")
|
||||
case RealMessageType.file:
|
||||
ret_seg = await self.handle_file_message(sub_message)
|
||||
if ret_seg:
|
||||
# NapcatEvent doesn't have a FILE event yet, so we won't trigger one for now.
|
||||
seg_message.append(ret_seg)
|
||||
else:
|
||||
logger.warning("file处理失败")
|
||||
case _:
|
||||
logger.warning(f"未知消息类型: {sub_message_type}")
|
||||
|
||||
@@ -773,8 +784,19 @@ class MessageHandler:
|
||||
return Seg(type="json", data=json.dumps(message_data))
|
||||
|
||||
try:
|
||||
# 尝试将json_data解析为Python对象
|
||||
nested_data = json.loads(json_data)
|
||||
|
||||
# 检查是否是机器人自己上传文件的回声
|
||||
if self._is_file_upload_echo(nested_data):
|
||||
logger.info("检测到机器人发送文件的回声消息,将作为文件消息处理")
|
||||
# 从回声消息中提取文件信息
|
||||
file_info = self._extract_file_info_from_echo(nested_data)
|
||||
if file_info:
|
||||
# 构建一个与普通文件消息格式相同的字典
|
||||
file_message_dict = {"type": "file", "data": file_info}
|
||||
return await self.handle_file_message(file_message_dict)
|
||||
|
||||
# 检查是否是QQ小程序分享消息
|
||||
if "app" in nested_data and "com.tencent.miniapp" in str(nested_data.get("app", "")):
|
||||
logger.debug("检测到QQ小程序分享消息,开始提取信息")
|
||||
@@ -888,13 +910,88 @@ class MessageHandler:
|
||||
# 如果没有提取到关键信息,返回None
|
||||
return None
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"解析JSON消息失败: {e}")
|
||||
except json.JSONDecodeError:
|
||||
# 如果解析失败,我们假设它不是我们关心的任何一种结构化JSON,
|
||||
# 而是普通的文本或者无法解析的格式。
|
||||
logger.debug(f"无法将data字段解析为JSON: {json_data}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"处理JSON消息时出错: {e}")
|
||||
logger.error(f"处理JSON消息时发生未知错误: {e}")
|
||||
return None
|
||||
|
||||
async def handle_file_message(self, raw_message: dict) -> Seg | None:
|
||||
"""
|
||||
处理文件消息
|
||||
Parameters:
|
||||
raw_message: dict: 原始消息
|
||||
Returns:
|
||||
seg_data: Seg: 处理后的消息段
|
||||
"""
|
||||
message_data: dict = raw_message.get("data")
|
||||
if not message_data:
|
||||
logger.warning("文件消息缺少 data 字段")
|
||||
return None
|
||||
|
||||
# 提取文件信息
|
||||
file_name = message_data.get("file")
|
||||
file_size = message_data.get("file_size")
|
||||
file_id = message_data.get("file_id")
|
||||
|
||||
logger.info(f"收到文件消息: name={file_name}, size={file_size}, id={file_id}")
|
||||
|
||||
# 将文件信息打包成字典
|
||||
file_data = {
|
||||
"name": file_name,
|
||||
"size": file_size,
|
||||
"id": file_id,
|
||||
}
|
||||
|
||||
return Seg(type="file", data=file_data)
|
||||
|
||||
def _is_file_upload_echo(self, nested_data: Any) -> bool:
|
||||
"""检查一个JSON对象是否是机器人自己上传文件的回声消息"""
|
||||
if not isinstance(nested_data, dict):
|
||||
return False
|
||||
|
||||
# 检查 'app' 和 'meta' 字段是否存在
|
||||
if "app" not in nested_data or "meta" not in nested_data:
|
||||
return False
|
||||
|
||||
# 检查 'app' 字段是否包含 'com.tencent.miniapp'
|
||||
if "com.tencent.miniapp" not in str(nested_data.get("app", "")):
|
||||
return False
|
||||
|
||||
# 检查 'meta' 内部的 'detail_1' 的 'busi_id' 是否为 '1014'
|
||||
meta = nested_data.get("meta", {})
|
||||
detail_1 = meta.get("detail_1", {})
|
||||
if detail_1.get("busi_id") == "1014":
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _extract_file_info_from_echo(self, nested_data: dict) -> Optional[dict]:
|
||||
"""从文件上传的回声消息中提取文件信息"""
|
||||
try:
|
||||
meta = nested_data.get("meta", {})
|
||||
detail_1 = meta.get("detail_1", {})
|
||||
|
||||
# 文件名在 'desc' 字段
|
||||
file_name = detail_1.get("desc")
|
||||
|
||||
# 文件大小在 'summary' 字段,格式为 "大小:1.7MB"
|
||||
summary = detail_1.get("summary", "")
|
||||
file_size_str = summary.replace("大小:", "").strip() # 移除前缀和空格
|
||||
|
||||
# QQ API有时返回的大小不标准,这里我们只提取它给的字符串
|
||||
# 实际大小已经由Napcat在发送时记录,这里主要是为了保持格式一致
|
||||
|
||||
if file_name and file_size_str:
|
||||
return {"file": file_name, "file_size": file_size_str, "file_id": None} # file_id在回声中不可用
|
||||
except Exception as e:
|
||||
logger.error(f"从文件回声中提取信息失败: {e}")
|
||||
|
||||
return None
|
||||
|
||||
async def handle_rps_message(self, raw_message: dict) -> Seg:
|
||||
message_data: dict = raw_message.get("data", {})
|
||||
res = message_data.get("result", "")
|
||||
|
||||
@@ -159,6 +159,11 @@ class NoticeHandler:
|
||||
system_notice = True
|
||||
case _:
|
||||
logger.warning(f"不支持的group_ban类型: {notice_type}.{sub_type}")
|
||||
case NoticeType.group_upload:
|
||||
logger.info("群文件上传")
|
||||
if not await message_handler.check_allow_to_chat(user_id, group_id, False, False):
|
||||
return None
|
||||
handled_message, user_info = await self.handle_group_upload_notify(raw_message, group_id, user_id)
|
||||
case _:
|
||||
logger.warning(f"不支持的notice类型: {notice_type}")
|
||||
return None
|
||||
@@ -211,6 +216,9 @@ class NoticeHandler:
|
||||
elif notice_type == NoticeType.group_msg_emoji_like:
|
||||
notice_config["notice_type"] = "emoji_like"
|
||||
notice_config["is_notice"] = True # 表情回复也是notice
|
||||
elif notice_type == NoticeType.group_upload:
|
||||
notice_config["notice_type"] = "group_upload"
|
||||
notice_config["is_notice"] = True # 文件上传也是notice
|
||||
|
||||
message_info: BaseMessageInfo = BaseMessageInfo(
|
||||
platform=config_api.get_plugin_config(self.plugin_config, "maibot_server.platform_name", "qq"),
|
||||
@@ -374,6 +382,38 @@ class NoticeHandler:
|
||||
seg_data = Seg(type="text",data=f"{user_name}使用Emoji表情{QQ_FACE.get(like_emoji_id,"")}回复了你的消息[{target_message_text}]")
|
||||
return seg_data, user_info
|
||||
|
||||
async def handle_group_upload_notify(self, raw_message: dict, group_id: int, user_id: int):
|
||||
if not group_id:
|
||||
logger.error("群ID不能为空,无法处理群文件上传通知")
|
||||
return None, None
|
||||
|
||||
user_qq_info: dict = await get_member_info(self.get_server_connection(), group_id, user_id)
|
||||
if user_qq_info:
|
||||
user_name = user_qq_info.get("nickname")
|
||||
user_cardname = user_qq_info.get("card")
|
||||
else:
|
||||
user_name = "QQ用户"
|
||||
user_cardname = "QQ用户"
|
||||
logger.debug("无法获取上传文件的用户昵称")
|
||||
|
||||
file_info = raw_message.get("file")
|
||||
if not file_info:
|
||||
logger.error("群文件上传通知中缺少文件信息")
|
||||
return None, None
|
||||
|
||||
user_info: UserInfo = UserInfo(
|
||||
platform=config_api.get_plugin_config(self.plugin_config, "maibot_server.platform_name", "qq"),
|
||||
user_id=user_id,
|
||||
user_nickname=user_name,
|
||||
user_cardname=user_cardname,
|
||||
)
|
||||
|
||||
file_name = file_info.get("name", "未知文件")
|
||||
file_size = file_info.get("size", 0)
|
||||
|
||||
seg_data = Seg(type="text", data=f"{user_name} 上传了文件: {file_name} (大小: {file_size} 字节)")
|
||||
return seg_data, user_info
|
||||
|
||||
async def handle_ban_notify(self, raw_message: dict, group_id: int) -> Tuple[Seg, UserInfo] | Tuple[None, None]:
|
||||
if not group_id:
|
||||
logger.error("群ID不能为空,无法处理禁言通知")
|
||||
|
||||
@@ -411,6 +411,12 @@ class SendHandler:
|
||||
|
||||
def handle_file_message(self, file_path: str) -> dict:
|
||||
"""处理文件消息"""
|
||||
# 临时的WSL路径转换方案
|
||||
if file_path.startswith("E:"):
|
||||
original_path = file_path
|
||||
file_path = "/mnt/e/" + file_path[3:].replace("\\", "/")
|
||||
logger.info(f"WSL路径转换: {original_path} -> {file_path}")
|
||||
|
||||
return {
|
||||
"type": "file",
|
||||
"data": {"file": f"file://{file_path}"},
|
||||
|
||||
Reference in New Issue
Block a user