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:
tt-P607
2025-10-26 22:57:11 +08:00
parent 5e6857c8f7
commit c98fa30358
6 changed files with 241 additions and 23 deletions

View File

@@ -239,6 +239,12 @@ class MessageRecv(Message):
} }
""" """
return "" 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": elif segment.type == "video":
self.is_picid = False self.is_picid = False
self.is_emoji = False self.is_emoji = False
@@ -296,8 +302,8 @@ class MessageRecv(Message):
else: else:
return "" return ""
else: else:
logger.info("启用视频识别") logger.warning(f"知的消息段类型: {segment.type}")
return "[视频]" return f"[{segment.type} 消息]"
except Exception as e: except Exception as e:
logger.error(f"处理消息段失败: {e!s}, 类型: {segment.type}, 数据: {segment.data}") logger.error(f"处理消息段失败: {e!s}, 类型: {segment.type}, 数据: {segment.data}")
return f"[处理失败的{segment.type}消息]" return f"[处理失败的{segment.type}消息]"
@@ -433,6 +439,12 @@ class MessageRecvS4U(MessageRecv):
self.is_screen = True self.is_screen = True
self.screen_info = segment.data self.screen_info = segment.data
return "屏幕信息" 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": elif segment.type == "video":
self.is_voice = False self.is_voice = False
self.is_picid = False self.is_picid = False
@@ -490,8 +502,8 @@ class MessageRecvS4U(MessageRecv):
else: else:
return "" return ""
else: else:
logger.info("启用视频识别") logger.warning(f"知的消息段类型: {segment.type}")
return "[视频]" return f"[{segment.type} 消息]"
except Exception as e: except Exception as e:
logger.error(f"处理消息段失败: {e!s}, 类型: {segment.type}, 数据: {segment.data}") logger.error(f"处理消息段失败: {e!s}, 类型: {segment.type}, 数据: {segment.data}")
return f"[处理失败的{segment.type}消息]" return f"[处理失败的{segment.type}消息]"

View File

@@ -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 asyncio
import time import time

View File

@@ -33,7 +33,8 @@ class NoticeType: # 通知事件
notify = "notify" notify = "notify"
group_ban = "group_ban" # 群禁言 group_ban = "group_ban" # 群禁言
group_msg_emoji_like = "group_msg_emoji_like" # 群聊表情回复 group_msg_emoji_like = "group_msg_emoji_like" # 群聊表情回复
group_upload = "group_upload" # 群文件上传
class Notify: class Notify:
poke = "poke" # 戳一戳 poke = "poke" # 戳一戳
input_status = "input_status" # 正在输入 input_status = "input_status" # 正在输入
@@ -59,6 +60,7 @@ class RealMessageType: # 实际消息分类
forward = "forward" # 转发消息 forward = "forward" # 转发消息
node = "node" # 转发消息节点 node = "node" # 转发消息节点
json = "json" # json消息 json = "json" # json消息
file = "file" # 文件
class MessageSentType: class MessageSentType:

View File

@@ -178,10 +178,6 @@ class MessageHandler:
message_time: float = time.time() # 应可乐要求现在是float了 message_time: float = time.time() # 应可乐要求现在是float了
template_info: TemplateInfo = None # 模板信息,暂时为空,等待启用 template_info: TemplateInfo = None # 模板信息,暂时为空,等待启用
format_info: FormatInfo = FormatInfo(
content_format=["text", "image", "emoji", "voice"],
accept_format=ACCEPT_FORMAT,
) # 格式化信息
if message_type == MessageType.private: if message_type == MessageType.private:
sub_type = raw_message.get("sub_type") sub_type = raw_message.get("sub_type")
if sub_type == MessageType.Private.friend: if sub_type == MessageType.Private.friend:
@@ -275,6 +271,25 @@ class MessageHandler:
logger.warning(f"群聊消息类型 {sub_type} 不支持") logger.warning(f"群聊消息类型 {sub_type} 不支持")
return None 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 = {} additional_config: dict = {}
if config_api.get_plugin_config(self.plugin_config, "voice.use_tts"): if config_api.get_plugin_config(self.plugin_config, "voice.use_tts"):
additional_config["allow_tts"] = True additional_config["allow_tts"] = True
@@ -291,17 +306,6 @@ class MessageHandler:
additional_config=additional_config, 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)}") logger.debug(f"准备发送消息到MoFox-Bot消息段数量: {len(seg_message)}")
@@ -482,6 +486,13 @@ class MessageHandler:
seg_message.append(ret_seg) seg_message.append(ret_seg)
else: else:
logger.warning("json处理失败") 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 _: case _:
logger.warning(f"未知消息类型: {sub_message_type}") logger.warning(f"未知消息类型: {sub_message_type}")
@@ -773,8 +784,19 @@ class MessageHandler:
return Seg(type="json", data=json.dumps(message_data)) return Seg(type="json", data=json.dumps(message_data))
try: try:
# 尝试将json_data解析为Python对象
nested_data = json.loads(json_data) 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小程序分享消息 # 检查是否是QQ小程序分享消息
if "app" in nested_data and "com.tencent.miniapp" in str(nested_data.get("app", "")): if "app" in nested_data and "com.tencent.miniapp" in str(nested_data.get("app", "")):
logger.debug("检测到QQ小程序分享消息开始提取信息") logger.debug("检测到QQ小程序分享消息开始提取信息")
@@ -888,13 +910,88 @@ class MessageHandler:
# 如果没有提取到关键信息返回None # 如果没有提取到关键信息返回None
return None return None
except json.JSONDecodeError as e: except json.JSONDecodeError:
logger.error(f"解析JSON消息失败: {e}") # 如果解析失败我们假设它不是我们关心的任何一种结构化JSON
# 而是普通的文本或者无法解析的格式。
logger.debug(f"无法将data字段解析为JSON: {json_data}")
return None return None
except Exception as e: except Exception as e:
logger.error(f"处理JSON消息时出错: {e}") logger.error(f"处理JSON消息时发生未知错误: {e}")
return None 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: async def handle_rps_message(self, raw_message: dict) -> Seg:
message_data: dict = raw_message.get("data", {}) message_data: dict = raw_message.get("data", {})
res = message_data.get("result", "") res = message_data.get("result", "")

View File

@@ -159,6 +159,11 @@ class NoticeHandler:
system_notice = True system_notice = True
case _: case _:
logger.warning(f"不支持的group_ban类型: {notice_type}.{sub_type}") 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 _: case _:
logger.warning(f"不支持的notice类型: {notice_type}") logger.warning(f"不支持的notice类型: {notice_type}")
return None return None
@@ -211,6 +216,9 @@ class NoticeHandler:
elif notice_type == NoticeType.group_msg_emoji_like: elif notice_type == NoticeType.group_msg_emoji_like:
notice_config["notice_type"] = "emoji_like" notice_config["notice_type"] = "emoji_like"
notice_config["is_notice"] = True # 表情回复也是notice 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( message_info: BaseMessageInfo = BaseMessageInfo(
platform=config_api.get_plugin_config(self.plugin_config, "maibot_server.platform_name", "qq"), platform=config_api.get_plugin_config(self.plugin_config, "maibot_server.platform_name", "qq"),
@@ -373,6 +381,38 @@ class NoticeHandler:
) )
seg_data = Seg(type="text",data=f"{user_name}使用Emoji表情{QQ_FACE.get(like_emoji_id,"")}回复了你的消息[{target_message_text}]") seg_data = Seg(type="text",data=f"{user_name}使用Emoji表情{QQ_FACE.get(like_emoji_id,"")}回复了你的消息[{target_message_text}]")
return seg_data, user_info 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]: async def handle_ban_notify(self, raw_message: dict, group_id: int) -> Tuple[Seg, UserInfo] | Tuple[None, None]:
if not group_id: if not group_id:

View File

@@ -411,6 +411,12 @@ class SendHandler:
def handle_file_message(self, file_path: str) -> dict: 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 { return {
"type": "file", "type": "file",
"data": {"file": f"file://{file_path}"}, "data": {"file": f"file://{file_path}"},