feat(napcat_adapter): 添加请求处理程序、发送处理程序、视频处理程序以及实用函数

- 实现了request_handler.py来处理对核心的请求。
- 创建了send_handler.py文件,用于处理并向Napcat发送消息。
- 添加了video_handler.py文件,用于从QQ消息中下载和处理视频文件。
- 开发了utils.py,用于缓存和实现与Napcat操作相关的实用函数。
- 为群组、成员和自身信息引入了带有生存时间(TTL)设置的缓存机制。
- 新模块中增强了错误处理和日志记录功能。
This commit is contained in:
Windpicker-owo
2025-11-26 20:09:41 +08:00
parent 46a98fefc4
commit e0157256b1
22 changed files with 1590 additions and 716 deletions

View File

@@ -0,0 +1,427 @@
# NEW_napcat_adapter
基于 mofox-wire v2.x 的 Napcat 适配器(使用 BaseAdapter 架构)
## 🏗️ 架构设计
本插件采用 **BaseAdapter 继承模式** 重写,完全抛弃旧版 maim_message 库,改用 mofox-wire 的 TypedDict 数据结构。
### 核心组件
- **NapcatAdapter**: 继承自 `mofox_wire.AdapterBase`,负责 OneBot 11 协议与 MessageEnvelope 的双向转换
- **WebSocketAdapterOptions**: 自动管理 WebSocket 连接,提供 incoming_parser 和 outgoing_encoder
- **CoreMessageSink**: 通过 `InProcessCoreSink` 将消息递送到核心系统
- **Handlers**: 独立的消息处理器,分为 to_core接收和 to_napcat发送两个方向
## 📁 项目结构
```
NEW_napcat_adapter/
├── plugin.py # ✅ 主插件文件BaseAdapter实现
├── _manifest.json # 插件清单
└── src/
├── event_models.py # ✅ OneBot事件类型常量
├── common/
│ └── core_sink.py # ✅ 全局CoreSink访问点
├── utils/
│ ├── utils.py # ⏳ 工具函数(待实现)
│ ├── qq_emoji_list.py # ⏳ QQ表情映射待实现
│ ├── video_handler.py # ⏳ 视频处理(待实现)
│ └── message_chunker.py # ⏳ 消息切片(待实现)
├── websocket/
│ └── (无需单独实现使用WebSocketAdapterOptions)
├── database/
│ └── database.py # ⏳ 数据库模型(待实现)
└── handlers/
├── to_core/ # Napcat → MessageEnvelope 方向
│ ├── message_handler.py # ⏳ 消息处理(部分完成)
│ ├── notice_handler.py # ⏳ 通知处理(待完成)
│ └── meta_event_handler.py # ⏳ 元事件(待完成)
└── to_napcat/ # MessageEnvelope → Napcat API 方向
└── send_handler.py # ⏳ 发送处理(部分完成)
```
## 🚀 快速开始
### 使用方式
1. **配置文件**: 在 `config/plugins/NEW_napcat_adapter.toml` 中配置 WebSocket URL 和其他参数
2. **启动插件**: 插件自动在系统启动时加载
3. **WebSocket连接**: 自动连接到 Napcat OneBot 11 服务器
## 🔑 核心数据结构
### MessageEnvelope (mofox-wire v2.x)
```python
from mofox_wire import MessageEnvelope, SegPayload, MessageInfoPayload
# 创建消息信封
envelope: MessageEnvelope = {
"direction": "input",
"message_info": {
"message_type": "group",
"message_id": "12345",
"self_id": "bot_qq",
"user_info": {
"user_id": "sender_qq",
"user_name": "发送者",
"user_displayname": "昵称"
},
"group_info": {
"group_id": "group_id",
"group_name": "群名"
},
"to_me": False
},
"message_segment": {
"type": "seglist",
"data": [
{"type": "text", "data": "hello"},
{"type": "image", "data": "base64_data"}
]
},
"raw_message": "hello[图片]",
"platform": "napcat",
"message_id": "12345",
"timestamp_ms": 1234567890
}
```
### BaseAdapter 核心方法
```python
class NapcatAdapter(BaseAdapter):
async def from_platform_message(self, message: dict[str, Any]) -> MessageEnvelope | None:
"""将 OneBot 11 事件转换为 MessageEnvelope"""
# 路由到对应的 Handler
async def _send_platform_message(self, envelope: MessageEnvelope) -> dict[str, Any]:
"""将 MessageEnvelope 转换为 OneBot 11 API 调用"""
# 调用 SendHandler 处理
```
## 📝 实现进度
### ✅ 已完成的核心架构
1. **BaseAdapter 实现** (plugin.py)
- ✅ WebSocket 自动连接管理
- ✅ from_platform_message() 事件路由
- ✅ _send_platform_message() 消息发送
- ✅ API 响应池机制echo-based request-response
- ✅ CoreSink 集成
2. **Handler 基础结构**
- ✅ MessageHandler 骨架text、image、at 基本实现)
- ✅ NoticeHandler 骨架
- ✅ MetaEventHandler 骨架
- ✅ SendHandler 骨架(基本类型转换)
3. **辅助组件**
- ✅ event_models.py事件类型常量
- ✅ core_sink.py全局 CoreSink 访问)
- ✅ 配置 Schema 定义
### ⏳ 部分完成的功能
4. **消息类型处理** (MessageHandler)
- ✅ 基础消息类型text, image, at
- ❌ 高级消息类型face, reply, forward, video, json, file, rps, dice, shake
5. **发送处理** (SendHandler)
- ✅ 基础 SegPayload 转换text, image
- ❌ 高级 Seg 类型emoji, voice, voiceurl, music, videourl, file, command
### ❌ 待实现的功能
6. **通知事件处理** (NoticeHandler)
- ❌ 戳一戳事件
- ❌ 表情回应事件
- ❌ 撤回事件
- ❌ 禁言事件
7. **工具函数** (utils.py)
- ❌ get_group_info
- ❌ get_member_info
- ❌ get_image_base64
- ❌ get_message_detail
- ❌ get_record_detail
8. **权限系统**
- ❌ check_allow_to_chat()
- ❌ 群组黑名单/白名单
- ❌ 私聊黑名单/白名单
- ❌ QQ机器人检测
9. **其他组件**
- ❌ 视频处理器
- ❌ 消息切片器
- ❌ 数据库模型
- ❌ QQ 表情映射表
## 📋 下一步工作
### 优先级 1完善消息处理参考旧版 recv_handler/message_handler.py
1. **完整实现 MessageHandler.handle_raw_message()**
- [ ] face表情消息段
- [ ] reply回复消息段
- [ ] forward转发消息段解析
- [ ] video视频消息段
- [ ] jsonJSON卡片消息段
- [ ] file文件消息段
- [ ] rps/dice/shake特殊消息
2. **实现工具函数**(参考旧版 utils.py
- [ ] `get_group_info()` - 获取群组信息
- [ ] `get_member_info()` - 获取成员信息
- [ ] `get_image_base64()` - 下载图片并转Base64
- [ ] `get_message_detail()` - 获取消息详情
- [ ] `get_record_detail()` - 获取语音详情
3. **实现权限检查**
- [ ] `check_allow_to_chat()` - 检查是否允许聊天
- [ ] 群组白名单/黑名单逻辑
- [ ] 私聊白名单/黑名单逻辑
- [ ] QQ机器人检测ban_qq_bot
### 优先级 2完善发送处理参考旧版 send_handler.py
4. **完整实现 SendHandler._convert_seg_to_onebot()**
- [ ] emoji表情回应命令
- [ ] voice语音消息段
- [ ] voiceurl语音URL消息段
- [ ] music音乐卡片消息段
- [ ] videourl视频URL消息段
- [ ] file文件消息段
- [ ] command命令消息段
5. **实现命令处理**
- [ ] GROUP_BAN禁言
- [ ] GROUP_KICK踢人
- [ ] SEND_POKE戳一戳
- [ ] DELETE_MSG撤回消息
- [ ] GROUP_WHOLE_BAN全员禁言
- [ ] SET_GROUP_CARD设置群名片
- [ ] SET_GROUP_ADMIN设置管理员
### 优先级 3补全其他组件参考旧版对应文件
6. **NoticeHandler 实现**
- [ ] 戳一戳通知notify.poke
- [ ] 表情回应通知notice.group_emoji_like
- [ ] 消息撤回通知notice.group_recall
- [ ] 禁言通知notice.group_ban
7. **辅助组件**
- [ ] `qq_emoji_list.py` - QQ表情ID映射表
- [ ] `video_handler.py` - 视频处理ffmpeg封面提取
- [ ] `message_chunker.py` - 消息分块与重组
- [ ] `database.py` - 数据库模型(如有需要)
### 优先级 4测试与优化
8. **功能测试**
- [ ] 文本消息收发
- [ ] 图片消息收发
- [ ] @消息处理
- [ ] 表情/语音/视频消息
- [ ] 转发消息解析
- [ ] 所有命令功能
- [ ] 通知事件处理
9. **性能优化**
- [ ] 消息处理并发性能
- [ ] API响应池性能
- [ ] 内存占用优化
## 🔍 关键实现细节
### 1. MessageEnvelope vs 旧版 MessageBase
**不再使用 Seg dataclass**,全部使用 TypedDict
```python
# ❌ 旧版maim_message
from mofox_wire import Seg, MessageBase
seg = Seg(type="text", data="hello")
message = MessageBase(message_info=info, message_segment=seg)
# ✅ 新版mofox-wire v2.x
from mofox_wire import SegPayload, MessageEnvelope
seg_payload: SegPayload = {"type": "text", "data": "hello"}
envelope: MessageEnvelope = {
"direction": "input",
"message_info": {...},
"message_segment": seg_payload,
...
}
```
### 2. Handler 架构模式
**接收方向** (to_core):
```python
class MessageHandler:
def __init__(self, adapter: "NapcatAdapter"):
self.adapter = adapter
async def handle_raw_message(self, data: dict[str, Any]) -> MessageEnvelope:
# 1. 解析 OneBot 11 数据
# 2. 构建 message_infoMessageInfoPayload
# 3. 转换消息段为 SegPayload
# 4. 返回完整的 MessageEnvelope
```
**发送方向** (to_napcat):
```python
class SendHandler:
def __init__(self, adapter: "NapcatAdapter"):
self.adapter = adapter
async def handle_message(self, envelope: MessageEnvelope) -> dict[str, Any]:
# 1. 从 envelope 提取 message_segment
# 2. 递归转换 SegPayload → OneBot 格式
# 3. 调用 adapter.send_napcat_api() 发送
```
### 3. API 调用模式(响应池)
```python
# 在 NapcatAdapter 中
async def send_napcat_api(self, action: str, params: dict[str, Any]) -> dict[str, Any]:
# 1. 生成唯一 echo
echo = f"{action}_{uuid.uuid4()}"
# 2. 创建 Future 等待响应
future = asyncio.Future()
self._response_pool[echo] = future
# 3. 发送请求(通过 WebSocket
await self._send_request({"action": action, "params": params, "echo": echo})
# 4. 等待响应(带超时)
try:
result = await asyncio.wait_for(future, timeout=10.0)
return result
finally:
self._response_pool.pop(echo, None)
# 响应回来时(在 incoming_parser 中)
def _handle_api_response(data: dict[str, Any]):
echo = data.get("echo")
if echo in adapter._response_pool:
adapter._response_pool[echo].set_result(data)
```
### 4. 类型提示技巧
处理 TypedDict 的严格类型检查:
```python
# 使用 type: ignore 标注(编译时是 TypedDict运行时是 dict
envelope: MessageEnvelope = {
"direction": "input",
...
} # type: ignore[typeddict-item]
# 或在函数签名中使用 dict[str, Any]
async def from_platform_message(self, message: dict[str, Any]) -> MessageEnvelope | None:
...
return envelope # type: ignore[return-value]
```
## 🔍 测试检查清单
- [ ] 文本消息接收/发送
- [ ] 图片消息接收/发送
- [ ] 语音消息接收/发送
- [ ] 视频消息接收/发送
- [ ] @消息接收/发送
- [ ] 回复消息接收/发送
- [ ] 转发消息接收
- [ ] JSON消息接收
- [ ] 文件消息接收/发送
- [ ] 禁言命令
- [ ] 踢人命令
- [ ] 戳一戳命令
- [ ] 表情回应命令
- [ ] 通知事件处理
- [ ] 元事件处理
## 📚 参考资料
- **mofox-wire 文档**: 查看 `mofox_wire/types.py` 了解 TypedDict 定义
- **BaseAdapter 示例**: 参考 `docs/mofox_wire_demo_adapter.py`
- **旧版实现**: `src/plugins/built_in/napcat_adapter_plugin/` (仅参考逻辑)
- **OneBot 11 协议**: [OneBot 11 标准](https://github.com/botuniverse/onebot-11)
## ⚠️ 重要注意事项
1. **完全抛弃旧版数据结构**
- ❌ 不再使用 `Seg` dataclass
- ❌ 不再使用 `MessageBase`
- ✅ 全部使用 `SegPayload`TypedDict
- ✅ 全部使用 `MessageEnvelope`TypedDict
2. **BaseAdapter 生命周期**
- `__init__()` 中初始化同步资源
- `start()` 中执行异步初始化WebSocket连接自动建立
- `stop()` 中清理资源WebSocket自动断开
3. **WebSocketAdapterOptions 自动管理**
- 无需手动管理 WebSocket 连接
- incoming_parser 自动解析接收数据
- outgoing_encoder 自动编码发送数据
- 重连机制由基类处理
4. **CoreSink 依赖注入**
- 必须在插件加载后调用 `set_core_sink(sink)`
- 通过 `get_core_sink()` 全局访问
- 用于将消息递送到核心系统
5. **类型安全与灵活性平衡**
- TypedDict 在编译时提供类型检查
- 运行时仍是普通 dict可灵活操作
- 必要时使用 `type: ignore` 抑制误报
6. **参考旧版但不照搬**
- 旧版逻辑流程可参考
- 数据结构需完全重写
- API调用模式已改变响应池
## 📊 预估工作量
- ✅ 核心架构: **已完成** (BaseAdapter + Handlers 骨架)
- ⏳ 消息处理完善: **4-6 小时** (所有消息类型 + 工具函数)
- ⏳ 发送处理完善: **3-4 小时** (所有 Seg 类型 + 命令)
- ⏳ 通知事件处理: **2-3 小时** (poke/emoji_like/recall/ban)
- ⏳ 测试调试: **2-4 小时** (全流程测试)
- **总剩余时间: 11-17 小时**
## ✅ 完成标准
当以下条件全部满足时,重写完成:
1. ✅ BaseAdapter 架构实现完成
2. ⏳ 所有 OneBot 11 消息类型支持
3. ⏳ 所有发送消息段类型支持
4. ⏳ 所有通知事件正确处理
5. ⏳ 权限系统集成完成
6. ⏳ 与旧版功能完全对等
7. ⏳ 所有测试用例通过
---
**最后更新**: 2025-11-23
**架构状态**: ✅ 核心架构完成
**实现状态**: ⏳ 消息处理部分完成,需完善细节
**预计完成**: 根据优先级,核心功能预计 1-2 个工作日

View File

@@ -0,0 +1,16 @@
from src.plugin_system.base.plugin_metadata import PluginMetadata
__plugin_meta__ = PluginMetadata(
name="napcat_adapter_plugin",
description="基于OneBot 11协议的NapCat QQ协议插件提供完整的QQ机器人API接口使用现有adapter连接",
usage="该插件提供 `napcat_tool` tool。",
version="1.0.0",
author="Windpicker_owo",
license="GPL-v3.0-or-later",
repository_url="https://github.com/Windpicker-owo",
keywords=["qq", "bot", "napcat", "onebot", "api", "websocket"],
categories=["protocol"],
extra={
"is_built_in": False,
},
)

View File

@@ -0,0 +1,270 @@
"""
Napcat 适配器(基于 MoFox-Bus 完全重写版)
核心流程:
1. Napcat WebSocket 连接 → 接收 OneBot 格式消息
2. from_platform_message: OneBot dict → MessageEnvelope
3. CoreSink → 推送到 MoFox-Bot 核心
4. 核心回复 → _send_platform_message: MessageEnvelope → OneBot API 调用
"""
from __future__ import annotations
import asyncio
import uuid
from typing import Any, ClassVar, Dict, List, Optional
import orjson
import websockets
from mofox_wire import CoreSink, MessageEnvelope, WebSocketAdapterOptions
from src.common.logger import get_logger
from src.plugin_system import ConfigField, register_plugin
from src.plugin_system.base import BaseAdapter, BasePlugin
from src.plugin_system.apis import config_api
from .src.handlers import utils as handler_utils
from .src.handlers.to_core.message_handler import MessageHandler
from .src.handlers.to_core.notice_handler import NoticeHandler
from .src.handlers.to_core.meta_event_handler import MetaEventHandler
from .src.handlers.to_napcat.send_handler import SendHandler
logger = get_logger("napcat_adapter")
class NapcatAdapter(BaseAdapter):
"""Napcat 适配器 - 完全基于 mofox-wire 架构"""
adapter_name = "napcat_adapter"
adapter_version = "2.0.0"
adapter_author = "MoFox Team"
adapter_description = "基于 MoFox-Bus 的 Napcat/OneBot 11 适配器"
platform = "qq"
run_in_subprocess = False
def __init__(self, core_sink: CoreSink, plugin: Optional[BasePlugin] = None, **kwargs):
"""初始化 Napcat 适配器"""
# 从插件配置读取 WebSocket URL
if plugin:
host = config_api.get_plugin_config(plugin.config, "napcat_server.host", "localhost")
port = config_api.get_plugin_config(plugin.config, "napcat_server.port", 8095)
access_token = config_api.get_plugin_config(plugin.config, "napcat_server.access_token", "")
ws_url = f"ws://{host}:{port}"
headers = {}
if access_token:
headers["Authorization"] = f"Bearer {access_token}"
else:
ws_url = "ws://127.0.0.1:8095"
headers = {}
# 配置 WebSocket 传输
transport = WebSocketAdapterOptions(
mode="server",
url=ws_url,
headers=headers if headers else None,
)
super().__init__(core_sink, plugin=plugin, transport=transport, **kwargs)
# 初始化处理器
self.message_handler = MessageHandler(self)
self.notice_handler = NoticeHandler(self)
self.meta_event_handler = MetaEventHandler(self)
self.send_handler = SendHandler(self)
# 响应池:用于存储等待的 API 响应
self._response_pool: Dict[str, asyncio.Future] = {}
self._response_timeout = 30.0
# WebSocket 连接(用于发送 API 请求)
# 注意_ws 继承自 BaseAdapter是 WebSocketLike 协议类型
self._napcat_ws = None # 可选的额外连接引用
# 注册 utils 内部使用的适配器实例,便于工具方法自动获取 WS
handler_utils.register_adapter(self)
async def on_adapter_loaded(self) -> None:
"""适配器加载时的初始化"""
logger.info("Napcat 适配器正在启动...")
# 设置处理器配置
if self.plugin:
self.message_handler.set_plugin_config(self.plugin.config)
self.notice_handler.set_plugin_config(self.plugin.config)
self.meta_event_handler.set_plugin_config(self.plugin.config)
self.send_handler.set_plugin_config(self.plugin.config)
logger.info("Napcat 适配器已加载")
async def on_adapter_unloaded(self) -> None:
"""适配器卸载时的清理"""
logger.info("Napcat 适配器正在关闭...")
# 清理响应池
for future in self._response_pool.values():
if not future.done():
future.cancel()
self._response_pool.clear()
logger.info("Napcat 适配器已关闭")
async def from_platform_message(self, raw: Dict[str, Any]) -> MessageEnvelope | None: # type: ignore[override]
"""
将 Napcat/OneBot 原始消息转换为 MessageEnvelope
这是核心转换方法,处理:
- message 事件 → 消息
- notice 事件 → 通知(戳一戳、表情回复等)
- meta_event 事件 → 元事件(心跳、生命周期)
- API 响应 → 存入响应池
"""
post_type = raw.get("post_type")
# API 响应(没有 post_type有 echo
if post_type is None and "echo" in raw:
echo = raw.get("echo")
if echo and echo in self._response_pool:
future = self._response_pool[echo]
if not future.done():
future.set_result(raw)
# 消息事件
if post_type == "message":
return await self.message_handler.handle_raw_message(raw) # type: ignore[return-value]
# 通知事件
elif post_type == "notice":
return await self.notice_handler.handle_notice(raw) # type: ignore[return-value]
# 元事件
elif post_type == "meta_event":
return await self.meta_event_handler.handle_meta_event(raw) # type: ignore[return-value]
# 未知事件类型
else:
return
async def _send_platform_message(self, envelope: MessageEnvelope) -> None: # type: ignore[override]
"""
将 MessageEnvelope 转换并发送到 Napcat
这里不直接通过 WebSocket 发送 envelope
而是调用 Napcat APIsend_group_msg, send_private_msg 等)
"""
await self.send_handler.handle_message(envelope)
async def send_napcat_api(self, action: str, params: Dict[str, Any], timeout: float = 30.0) -> Dict[str, Any]:
"""
发送 Napcat API 请求并等待响应
Args:
action: API 动作名称(如 send_group_msg
params: API 参数
timeout: 超时时间(秒)
Returns:
API 响应数据
"""
if not self._ws:
raise RuntimeError("WebSocket 连接未建立")
# 生成唯一的 echo ID
echo = str(uuid.uuid4())
# 创建 Future 用于等待响应
future = asyncio.Future()
self._response_pool[echo] = future
# 构造请求
# Napcat expects JSON text frames; orjson.dumps returns bytes so decode to str
request = orjson.dumps(
{
"action": action,
"params": params,
"echo": echo,
}
).decode()
try:
# 发送请求
await self._ws.send(request)
# 等待响应
response = await asyncio.wait_for(future, timeout=timeout)
return response
except asyncio.TimeoutError:
logger.error(f"API 请求超时: {action}")
raise
except Exception as e:
logger.error(f"API 请求失败: {action}, 错误: {e}")
raise
finally:
# 清理响应池
self._response_pool.pop(echo, None)
def get_ws_connection(self):
"""获取 WebSocket 连接(用于发送 API 请求)"""
if not self._ws:
raise RuntimeError("WebSocket 连接未建立")
return self._ws
@register_plugin
class NapcatAdapterPlugin(BasePlugin):
"""Napcat 适配器插件"""
plugin_name = "napcat_adapter_plugin"
config_file_name = "config.toml"
enable_plugin = True
plugin_version = "2.0.0"
plugin_author = "MoFox Team"
plugin_description = "Napcat/OneBot 11 适配器(基于 MoFox-Bus 重写)"
config_section_descriptions: ClassVar = {
"plugin": "插件开关",
"napcat_server": "Napcat WebSocket 连接设置",
"features": "过滤和名单配置",
}
config_schema: ClassVar[dict] = {
"plugin": {
"enabled": ConfigField(type=bool, default=True, description="是否启用 Napcat 适配器"),
"config_version": ConfigField(type=str, default="2.0.0", description="配置文件版本"),
},
"napcat_server": {
"mode": ConfigField(
type=str,
default="reverse",
description="ws 连接模式: reverse/direct",
choices=["reverse", "direct"],
),
"host": ConfigField(type=str, default="localhost", description="Napcat WebSocket 服务地址"),
"port": ConfigField(type=int, default=8095, description="Napcat WebSocket 服务端口"),
"access_token": ConfigField(type=str, default="", description="Napcat API 访问令牌(可选)"),
},
"features": {
"group_list_type": ConfigField(
type=str,
default="blacklist",
description="群聊名单模式: blacklist/whitelist",
choices=["blacklist", "whitelist"],
),
"group_list": ConfigField(type=list, default=[], description="群聊名单;根据名单模式过滤"),
"private_list_type": ConfigField(
type=str,
default="blacklist",
description="私聊名单模式: blacklist/whitelist",
choices=["blacklist", "whitelist"],
),
"private_list": ConfigField(type=list, default=[], description="私聊名单;根据名单模式过滤"),
"ban_user_id": ConfigField(type=list, default=[], description="全局封禁的用户 ID 列表"),
"ban_qq_bot": ConfigField(type=bool, default=False, description="是否屏蔽其他 QQ 机器人消息"),
},
}
def get_plugin_components(self) -> list:
"""返回适配器组件"""
return [(NapcatAdapter.get_adapter_info(), NapcatAdapter)]

View File

@@ -0,0 +1 @@
"""工具模块"""

View File

@@ -0,0 +1,310 @@
from enum import Enum
class MetaEventType:
lifecycle = "lifecycle" # 生命周期
class Lifecycle:
connect = "connect" # 生命周期 - WebSocket 连接成功
heartbeat = "heartbeat" # 心跳
class MessageType: # 接受消息大类
private = "private" # 私聊消息
class Private:
friend = "friend" # 私聊消息 - 好友
group = "group" # 私聊消息 - 群临时
group_self = "group_self" # 私聊消息 - 群中自身发送
other = "other" # 私聊消息 - 其他
group = "group" # 群聊消息
class Group:
normal = "normal" # 群聊消息 - 普通
anonymous = "anonymous" # 群聊消息 - 匿名消息
notice = "notice" # 群聊消息 - 系统提示
class NoticeType: # 通知事件
friend_recall = "friend_recall" # 私聊消息撤回
group_recall = "group_recall" # 群聊消息撤回
notify = "notify"
group_ban = "group_ban" # 群禁言
group_msg_emoji_like = "group_msg_emoji_like" # 群聊表情回复
group_upload = "group_upload" # 群文件上传
class Notify:
poke = "poke" # 戳一戳
input_status = "input_status" # 正在输入
class GroupBan:
ban = "ban" # 禁言
lift_ban = "lift_ban" # 解除禁言
class RealMessageType: # 实际消息分类
text = "text" # 纯文本
face = "face" # qq表情
image = "image" # 图片
record = "record" # 语音
video = "video" # 视频
at = "at" # @某人
rps = "rps" # 猜拳魔法表情
dice = "dice" # 骰子
shake = "shake" # 私聊窗口抖动(只收)
poke = "poke" # 群聊戳一戳
share = "share" # 链接分享json形式
reply = "reply" # 回复消息
forward = "forward" # 转发消息
node = "node" # 转发消息节点
json = "json" # json消息
file = "file" # 文件
class MessageSentType:
private = "private"
class Private:
friend = "friend"
group = "group"
group = "group"
class Group:
normal = "normal"
class CommandType(Enum):
"""命令类型"""
GROUP_BAN = "set_group_ban" # 禁言用户
GROUP_WHOLE_BAN = "set_group_whole_ban" # 群全体禁言
GROUP_KICK = "set_group_kick" # 踢出群聊
SEND_POKE = "send_poke" # 戳一戳
DELETE_MSG = "delete_msg" # 撤回消息
AI_VOICE_SEND = "ai_voice_send" # AI语音发送
SET_EMOJI_LIKE = "set_msg_emoji_like" # 设置表情回应
SEND_AT_MESSAGE = "send_at_message" # 发送@消息
SEND_LIKE = "send_like" # 发送点赞
def __str__(self) -> str:
return self.value
# 支持的消息格式
ACCEPT_FORMAT = [
"text",
"image",
"emoji",
"reply",
"voice",
"command",
"voiceurl",
"music",
"videourl",
"file",
]
# 插件名称
PLUGIN_NAME = "NEW_napcat_adapter"
# QQ表情映射表
QQ_FACE = {
"0": "[表情:惊讶]",
"1": "[表情:撇嘴]",
"2": "[表情:色]",
"3": "[表情:发呆]",
"4": "[表情:得意]",
"5": "[表情:流泪]",
"6": "[表情:害羞]",
"7": "[表情:闭嘴]",
"8": "[表情:睡]",
"9": "[表情:大哭]",
"10": "[表情:尴尬]",
"11": "[表情:发怒]",
"12": "[表情:调皮]",
"13": "[表情:呲牙]",
"14": "[表情:微笑]",
"15": "[表情:难过]",
"16": "[表情:酷]",
"18": "[表情:抓狂]",
"19": "[表情:吐]",
"20": "[表情:偷笑]",
"21": "[表情:可爱]",
"22": "[表情:白眼]",
"23": "[表情:傲慢]",
"24": "[表情:饥饿]",
"25": "[表情:困]",
"26": "[表情:惊恐]",
"27": "[表情:流汗]",
"28": "[表情:憨笑]",
"29": "[表情:悠闲]",
"30": "[表情:奋斗]",
"31": "[表情:咒骂]",
"32": "[表情:疑问]",
"33": "[表情:嘘]",
"34": "[表情:晕]",
"35": "[表情:折磨]",
"36": "[表情:衰]",
"37": "[表情:骷髅]",
"38": "[表情:敲打]",
"39": "[表情:再见]",
"41": "[表情:发抖]",
"42": "[表情:爱情]",
"43": "[表情:跳跳]",
"46": "[表情:猪头]",
"49": "[表情:拥抱]",
"53": "[表情:蛋糕]",
"56": "[表情:刀]",
"59": "[表情:便便]",
"60": "[表情:咖啡]",
"63": "[表情:玫瑰]",
"64": "[表情:凋谢]",
"66": "[表情:爱心]",
"67": "[表情:心碎]",
"74": "[表情:太阳]",
"75": "[表情:月亮]",
"76": "[表情:赞]",
"77": "[表情:踩]",
"78": "[表情:握手]",
"79": "[表情:胜利]",
"85": "[表情:飞吻]",
"86": "[表情:怄火]",
"89": "[表情:西瓜]",
"96": "[表情:冷汗]",
"97": "[表情:擦汗]",
"98": "[表情:抠鼻]",
"99": "[表情:鼓掌]",
"100": "[表情:糗大了]",
"101": "[表情:坏笑]",
"102": "[表情:左哼哼]",
"103": "[表情:右哼哼]",
"104": "[表情:哈欠]",
"105": "[表情:鄙视]",
"106": "[表情:委屈]",
"107": "[表情:快哭了]",
"108": "[表情:阴险]",
"109": "[表情:左亲亲]",
"110": "[表情:吓]",
"111": "[表情:可怜]",
"112": "[表情:菜刀]",
"114": "[表情:篮球]",
"116": "[表情:示爱]",
"118": "[表情:抱拳]",
"119": "[表情:勾引]",
"120": "[表情:拳头]",
"121": "[表情:差劲]",
"123": "[表情NO]",
"124": "[表情OK]",
"125": "[表情:转圈]",
"129": "[表情:挥手]",
"137": "[表情:鞭炮]",
"144": "[表情:喝彩]",
"146": "[表情:爆筋]",
"147": "[表情:棒棒糖]",
"169": "[表情:手枪]",
"171": "[表情:茶]",
"172": "[表情:眨眼睛]",
"173": "[表情:泪奔]",
"174": "[表情:无奈]",
"175": "[表情:卖萌]",
"176": "[表情:小纠结]",
"177": "[表情:喷血]",
"178": "[表情:斜眼笑]",
"179": "[表情doge]",
"181": "[表情:戳一戳]",
"182": "[表情:笑哭]",
"183": "[表情:我最美]",
"185": "[表情:羊驼]",
"187": "[表情:幽灵]",
"201": "[表情:点赞]",
"212": "[表情:托腮]",
"262": "[表情:脑阔疼]",
"263": "[表情:沧桑]",
"264": "[表情:捂脸]",
"265": "[表情:辣眼睛]",
"266": "[表情:哦哟]",
"267": "[表情:头秃]",
"268": "[表情:问号脸]",
"269": "[表情:暗中观察]",
"270": "[表情emm]",
"271": "[表情:吃瓜]",
"272": "[表情:呵呵哒]",
"273": "[表情:我酸了]",
"277": "[表情:滑稽狗头]",
"281": "[表情:翻白眼]",
"282": "[表情:敬礼]",
"283": "[表情:狂笑]",
"284": "[表情:面无表情]",
"285": "[表情:摸鱼]",
"286": "[表情:魔鬼笑]",
"287": "[表情:哦]",
"289": "[表情:睁眼]",
"293": "[表情:摸锦鲤]",
"294": "[表情:期待]",
"295": "[表情:拿到红包]",
"297": "[表情:拜谢]",
"298": "[表情:元宝]",
"299": "[表情:牛啊]",
"300": "[表情:胖三斤]",
"302": "[表情:左拜年]",
"303": "[表情:右拜年]",
"305": "[表情:右亲亲]",
"306": "[表情:牛气冲天]",
"307": "[表情:喵喵]",
"311": "[表情打call]",
"312": "[表情:变形]",
"314": "[表情:仔细分析]",
"317": "[表情:菜汪]",
"318": "[表情:崇拜]",
"319": "[表情:比心]",
"320": "[表情:庆祝]",
"323": "[表情:嫌弃]",
"324": "[表情:吃糖]",
"325": "[表情:惊吓]",
"326": "[表情:生气]",
"332": "[表情:举牌牌]",
"333": "[表情:烟花]",
"334": "[表情:虎虎生威]",
"336": "[表情:豹富]",
"337": "[表情:花朵脸]",
"338": "[表情:我想开了]",
"339": "[表情:舔屏]",
"341": "[表情:打招呼]",
"342": "[表情酸Q]",
"343": "[表情:我方了]",
"344": "[表情:大怨种]",
"345": "[表情:红包多多]",
"346": "[表情:你真棒棒]",
"347": "[表情:大展宏兔]",
"349": "[表情:坚强]",
"350": "[表情:贴贴]",
"351": "[表情:敲敲]",
"352": "[表情:咦]",
"353": "[表情:拜托]",
"354": "[表情:尊嘟假嘟]",
"355": "[表情:耶]",
"356": "[表情666]",
"357": "[表情:裂开]",
"392": "[表情:龙年快乐]",
"393": "[表情:新年中龙]",
"394": "[表情:新年大龙]",
"395": "[表情:略略略]",
"396": "[表情:龙年快乐]",
"424": "[表情:按钮]",
}
__all__ = [
"MetaEventType",
"MessageType",
"NoticeType",
"RealMessageType",
"MessageSentType",
"CommandType",
"ACCEPT_FORMAT",
"PLUGIN_NAME",
"QQ_FACE",
]

View File

@@ -0,0 +1 @@
"""处理器模块"""

View File

@@ -0,0 +1 @@
"""接收方向处理器"""

View File

@@ -0,0 +1,715 @@
"""消息处理器 - 将 Napcat OneBot 消息转换为 MessageEnvelope"""
from __future__ import annotations
import base64
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
import uuid
from mofox_wire import MessageBuilder
from src.common.logger import get_logger
from src.plugin_system.apis import config_api
from mofox_wire import (
MessageEnvelope,
SegPayload,
MessageInfoPayload,
UserInfoPayload,
GroupInfoPayload,
)
from ...event_models import ACCEPT_FORMAT, QQ_FACE, RealMessageType
from ..utils import *
if TYPE_CHECKING:
from ....plugin import NapcatAdapter
logger = get_logger("napcat_adapter.message_handler")
class MessageHandler:
"""处理来自 Napcat 的消息事件"""
def __init__(self, adapter: "NapcatAdapter"):
self.adapter = adapter
self.plugin_config: Optional[Dict[str, Any]] = None
def set_plugin_config(self, config: Dict[str, Any]) -> None:
"""设置插件配置"""
self.plugin_config = config
async def handle_raw_message(self, raw: Dict[str, Any]):
"""
处理原始消息并转换为 MessageEnvelope
Args:
raw: OneBot 原始消息数据
Returns:
MessageEnvelope (dict)
"""
message_type = raw.get("message_type")
message_id = str(raw.get("message_id", ""))
message_time = time.time()
msg_builder = MessageBuilder()
# 构造用户信息
sender_info = raw.get("sender", {})
(
msg_builder.direction("incoming")
.message_id(message_id)
.timestamp_ms(int(message_time * 1000))
.from_user(
user_id=str(sender_info.get("user_id", "")),
platform="qq",
nickname=sender_info.get("nickname", ""),
cardname=sender_info.get("card", ""),
user_avatar=sender_info.get("avatar", ""),
)
)
# 构造群组信息(如果是群消息)
if message_type == "group":
group_id = raw.get("group_id")
if group_id:
fetched_group_info = await get_group_info(group_id)
(
msg_builder.from_group(
group_id=str(group_id),
platform="qq",
name=(
fetched_group_info.get("group_name", "")
if fetched_group_info
else raw.get("group_name", "")
),
)
)
# 解析消息段
message_segments = raw.get("message", [])
seg_list: List[SegPayload] = []
for segment in message_segments:
seg_message = await self.handle_single_segment(segment, raw)
if seg_message:
seg_list.append(seg_message)
msg_builder.format_info(
content_format=[seg["type"] for seg in seg_list],
accept_format=ACCEPT_FORMAT,
)
msg_builder.seg_list(seg_list)
return msg_builder.build()
async def handle_single_segment(
self, segment: dict, raw_message: dict, in_reply: bool = False
) -> SegPayload | None:
"""
处理单一消息段并转换为 MessageEnvelope
Args:
segment: 单一原始消息段
raw_message: 完整的原始消息数据
Returns:
SegPayload | None
"""
seg_type = segment.get("type")
match seg_type:
case RealMessageType.text:
return await self._handle_text_message(segment)
case RealMessageType.image:
return await self._handle_image_message(segment)
case RealMessageType.face:
return await self._handle_face_message(segment)
case RealMessageType.at:
return await self._handle_at_message(segment, raw_message)
case RealMessageType.reply:
return await self._handle_reply_message(segment, raw_message, in_reply)
case RealMessageType.record:
return await self._handle_record_message(segment)
case RealMessageType.video:
return await self._handle_video_message(segment)
case RealMessageType.rps:
return await self._handle_rps_message(segment)
case RealMessageType.dice:
return await self._handle_dice_message(segment)
case RealMessageType.forward:
messages = await get_forward_message(segment, adapter=self.adapter)
if not messages:
logger.warning("转发消息内容为空或获取失败")
return None
return await self.handle_forward_message(messages)
case RealMessageType.json:
return await self._handle_json_message(segment)
case RealMessageType.file:
return await self._handle_file_message(segment)
case _:
logger.warning(f"Unsupported segment type: {seg_type}")
return None
# Utility methods for handling different message types
async def _handle_text_message(self, segment: dict) -> SegPayload:
"""处理纯文本消息"""
message_data = segment.get("data", {})
plain_text = message_data.get("text", "")
return {"type": "text", "data": plain_text}
async def _handle_face_message(self, segment: dict) -> SegPayload | None:
"""处理表情消息"""
message_data = segment.get("data", {})
face_raw_id = str(message_data.get("id", ""))
if face_raw_id in QQ_FACE:
face_content = QQ_FACE.get(face_raw_id, "[未知表情]")
return {"type": "text", "data": face_content}
else:
logger.warning(f"不支持的表情:{face_raw_id}")
return None
async def _handle_image_message(self, segment: dict) -> SegPayload | None:
"""处理图片消息与表情包消息"""
message_data = segment.get("data", {})
image_sub_type = message_data.get("sub_type")
try:
image_base64 = await get_image_base64(message_data.get("url", ""))
except Exception as e:
logger.error(f"图片消息处理失败: {str(e)}")
return None
if image_sub_type == 0:
return {"type": "image", "data": image_base64}
elif image_sub_type not in [4, 9]:
return {"type": "emoji", "data": image_base64}
else:
logger.warning(f"不支持的图片子类型:{image_sub_type}")
return None
async def _handle_at_message(self, segment: dict, raw_message: dict) -> SegPayload | None:
"""处理@消息"""
seg_data = segment.get("data", {})
if not seg_data:
return None
qq_id = seg_data.get("qq")
self_id = raw_message.get("self_id")
group_id = raw_message.get("group_id")
if str(self_id) == str(qq_id):
logger.debug("机器人被at")
self_info = await get_self_info()
if self_info:
return {"type": "at", "data": f"{self_info.get('nickname')}:{self_info.get('user_id')}"}
return None
else:
if qq_id and group_id:
member_info = await get_member_info(group_id=group_id, user_id=qq_id)
if member_info:
return {"type": "at", "data": f"{member_info.get('nickname')}:{member_info.get('user_id')}"}
return None
async def _handle_reply_message(self, segment: dict, raw_message: dict, in_reply: bool) -> SegPayload | None:
"""处理回复消息"""
if in_reply:
return None
seg_data = segment.get("data", {})
if not seg_data:
return None
message_id = seg_data.get("id")
if not message_id:
return None
message_detail = await get_message_detail(message_id)
if not message_detail:
logger.warning("获取被引用的消息详情失败")
return {"type": "text", "data": "[无法获取被引用的消息]"}
# 递归处理被引用的消息
reply_segments = []
for reply_seg in message_detail.get("message", []):
if isinstance(reply_seg, dict):
reply_result = await self.handle_single_segment(reply_seg, raw_message, in_reply=True)
if reply_result:
reply_segments.append(reply_result)
if not reply_segments:
reply_text = "[无法获取被引用的消息]"
else:
# 简化处理只取第一个segment的data
reply_text = reply_segments[0].get("data", "") if reply_segments else ""
sender_info = message_detail.get("sender", {})
sender_nickname = sender_info.get("nickname", "未知用户")
sender_id = sender_info.get("user_id")
if sender_id:
return {"type": "text", "data": f"[回复<{sender_nickname}({sender_id})>{reply_text}],说:"}
else:
return {"type": "text", "data": f"[回复<{sender_nickname}>{reply_text}],说:"}
async def _handle_record_message(self, segment: dict) -> SegPayload | None:
"""处理语音消息"""
message_data = segment.get("data", {})
file = message_data.get("file", "")
if not file:
logger.warning("语音消息缺少文件信息")
return None
try:
record_detail = await get_record_detail(file)
if not record_detail:
logger.warning("获取语音消息详情失败")
return None
audio_base64 = record_detail.get("base64", "")
except Exception as e:
logger.error(f"语音消息处理失败: {str(e)}")
return None
if not audio_base64:
logger.error("语音消息处理失败,未获取到音频数据")
return None
return {"type": "voice", "data": audio_base64}
async def _handle_video_message(self, segment: dict) -> SegPayload | None:
"""处理视频消息"""
message_data = segment.get("data", {})
video_url = message_data.get("url")
file_path = message_data.get("filePath") or message_data.get("file_path")
video_source = file_path if file_path else video_url
if not video_source:
logger.warning("视频消息缺少URL或文件路径信息")
return None
try:
if file_path and Path(file_path).exists():
# 本地文件处理
with open(file_path, "rb") as f:
video_data = f.read()
video_base64 = base64.b64encode(video_data).decode("utf-8")
logger.debug(f"视频文件大小: {len(video_data) / (1024 * 1024):.2f} MB")
return {
"type": "video",
"data": {
"base64": video_base64,
"filename": Path(file_path).name,
"size_mb": len(video_data) / (1024 * 1024),
},
}
elif video_url:
# URL下载处理
from ..video_handler import get_video_downloader
video_downloader = get_video_downloader()
download_result = await video_downloader.download_video(video_url)
if not download_result["success"]:
logger.warning(f"视频下载失败: {download_result.get('error', '未知错误')}")
return None
video_base64 = base64.b64encode(download_result["data"]).decode("utf-8")
logger.debug(f"视频下载成功,大小: {len(download_result['data']) / (1024 * 1024):.2f} MB")
return {
"type": "video",
"data": {
"base64": video_base64,
"filename": download_result.get("filename", "video.mp4"),
"size_mb": len(download_result["data"]) / (1024 * 1024),
"url": video_url,
},
}
else:
logger.warning("既没有有效的本地文件路径也没有有效的视频URL")
return None
except Exception as e:
logger.error(f"视频消息处理失败: {str(e)}")
return None
async def _handle_rps_message(self, segment: dict) -> SegPayload:
"""处理猜拳消息"""
message_data = segment.get("data", {})
res = message_data.get("result", "")
shape_map = {"1": "", "2": "剪刀"}
shape = shape_map.get(res, "石头")
return {"type": "text", "data": f"[发送了一个魔法猜拳表情,结果是:{shape}]"}
async def _handle_dice_message(self, segment: dict) -> SegPayload:
"""处理骰子消息"""
message_data = segment.get("data", {})
res = message_data.get("result", "")
return {"type": "text", "data": f"[扔了一个骰子,点数是{res}]"}
async def handle_forward_message(self, message_list: list) -> SegPayload | None:
"""
递归处理转发消息,并按照动态方式确定图片处理方式
Parameters:
message_list: list: 转发消息列表
"""
handled_message, image_count = await self._handle_forward_message(message_list, 0)
if not handled_message:
return None
if 0 < image_count < 5:
logger.debug("图片数量小于5开始解析图片为base64")
processed_message = await self._recursive_parse_image_seg(handled_message, True)
elif image_count > 0:
logger.debug("图片数量大于等于5开始解析图片为占位符")
processed_message = await self._recursive_parse_image_seg(handled_message, False)
else:
logger.debug("没有图片,直接返回")
processed_message = handled_message
forward_hint = {"type": "text", "data": "这是一条转发消息:\n"}
return {"type": "seglist", "data": [forward_hint, processed_message]}
async def _recursive_parse_image_seg(self, seg_data: SegPayload, to_image: bool) -> SegPayload:
# sourcery skip: merge-else-if-into-elif
if seg_data.get("type") == "seglist":
new_seg_list = []
for i_seg in seg_data.get("data", []):
parsed_seg = await self._recursive_parse_image_seg(i_seg, to_image)
new_seg_list.append(parsed_seg)
return {"type": "seglist", "data": new_seg_list}
if to_image:
if seg_data.get("type") == "image":
image_url = seg_data.get("data")
try:
encoded_image = await get_image_base64(image_url)
except Exception as e:
logger.error(f"图片处理失败: {str(e)}")
return {"type": "text", "data": "[图片]"}
return {"type": "image", "data": encoded_image}
if seg_data.get("type") == "emoji":
image_url = seg_data.get("data")
try:
encoded_image = await get_image_base64(image_url)
except Exception as e:
logger.error(f"图片处理失败: {str(e)}")
return {"type": "text", "data": "[表情包]"}
return {"type": "emoji", "data": encoded_image}
logger.debug(f"不处理类型: {seg_data.get('type')}")
return seg_data
if seg_data.get("type") == "image":
return {"type": "text", "data": "[图片]"}
if seg_data.get("type") == "emoji":
return {"type": "text", "data": "[动画表情]"}
logger.debug(f"不处理类型: {seg_data.get('type')}")
return seg_data
async def _handle_forward_message(self, message_list: list, layer: int) -> Tuple[SegPayload | None, int]:
# sourcery skip: low-code-quality
"""
递归处理实际转发消息
Parameters:
message_list: list: 转发消息列表首层对应messages字段后面对应content字段
layer: int: 当前层级
Returns:
seg_data: Seg: 处理后的消息段
image_count: int: 图片数量
"""
seg_list: List[SegPayload] = []
image_count = 0
if message_list is None:
return None, 0
for sub_message in message_list:
sender_info: dict = sub_message.get("sender", {})
user_nickname: str = sender_info.get("nickname", "QQ用户")
user_nickname_str = f"{user_nickname}】:"
break_seg: SegPayload = {"type": "text", "data": "\n"}
message_of_sub_message_list: List[Dict[str, Any]] = sub_message.get("message")
if not message_of_sub_message_list:
logger.warning("转发消息内容为空")
continue
message_of_sub_message = message_of_sub_message_list[0]
message_type = message_of_sub_message.get("type")
if message_type == RealMessageType.forward:
if layer >= 3:
full_seg_data: SegPayload = {
"type": "text",
"data": ("--" * layer) + f"{user_nickname}】:【转发消息】\n",
}
else:
sub_message_data = message_of_sub_message.get("data")
if not sub_message_data:
continue
contents = sub_message_data.get("content")
seg_data, count = await self._handle_forward_message(contents, layer + 1)
if seg_data is None:
continue
image_count += count
head_tip: SegPayload = {
"type": "text",
"data": ("--" * layer) + f"{user_nickname}】: 合并转发消息内容:\n",
}
full_seg_data = {"type": "seglist", "data": [head_tip, seg_data]}
seg_list.append(full_seg_data)
elif message_type == RealMessageType.text:
sub_message_data = message_of_sub_message.get("data")
if not sub_message_data:
continue
text_message = sub_message_data.get("text")
seg_data: SegPayload = {"type": "text", "data": text_message}
nickname_prefix = ("--" * layer) + user_nickname_str if layer > 0 else user_nickname_str
data_list: List[SegPayload] = [
{"type": "text", "data": nickname_prefix},
seg_data,
break_seg,
]
seg_list.append({"type": "seglist", "data": data_list})
elif message_type == RealMessageType.image:
image_count += 1
image_data = message_of_sub_message.get("data", {})
image_url = image_data.get("url")
if not image_url:
logger.warning("转发消息图片缺少URL")
continue
sub_type = image_data.get("sub_type")
if sub_type == 0:
seg_data = {"type": "image", "data": image_url}
else:
seg_data = {"type": "emoji", "data": image_url}
nickname_prefix = ("--" * layer) + user_nickname_str if layer > 0 else user_nickname_str
data_list = [
{"type": "text", "data": nickname_prefix},
seg_data,
break_seg,
]
full_seg_data = {"type": "seglist", "data": data_list}
seg_list.append(full_seg_data)
return {"type": "seglist", "data": seg_list}, image_count
async def _handle_file_message(self, segment: dict) -> SegPayload | None:
"""处理文件消息"""
message_data = segment.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 {"type": "file", "data": file_data}
async def _handle_json_message(self, segment: dict) -> SegPayload | None:
"""
处理JSON消息
Parameters:
segment: dict: 消息段
Returns:
SegPayload | None: 处理后的消息段
"""
message_data = segment.get("data", {})
json_data = message_data.get("data", "")
# 检查JSON消息格式
if not message_data or "data" not in message_data:
logger.warning("JSON消息格式不正确")
return {"type": "json", "data": str(message_data)}
try:
# 尝试将json_data解析为Python对象
nested_data = orjson.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:
return {"type": "file", "data": file_info}
# 检查是否是QQ小程序分享消息
if "app" in nested_data and "com.tencent.miniapp" in str(nested_data.get("app", "")):
logger.debug("检测到QQ小程序分享消息开始提取信息")
# 提取目标字段
extracted_info = {}
# 提取 meta.detail_1 中的信息
meta = nested_data.get("meta", {})
detail_1 = meta.get("detail_1", {})
if detail_1:
extracted_info["title"] = detail_1.get("title", "")
extracted_info["desc"] = detail_1.get("desc", "")
qqdocurl = detail_1.get("qqdocurl", "")
# 从qqdocurl中提取b23.tv短链接
if qqdocurl and "b23.tv" in qqdocurl:
# 查找b23.tv链接的起始位置
start_pos = qqdocurl.find("https://b23.tv/")
if start_pos != -1:
# 提取从https://b23.tv/开始的部分
b23_part = qqdocurl[start_pos:]
# 查找第一个?的位置,截取到?之前
question_pos = b23_part.find("?")
if question_pos != -1:
extracted_info["short_url"] = b23_part[:question_pos]
else:
extracted_info["short_url"] = b23_part
else:
extracted_info["short_url"] = qqdocurl
else:
extracted_info["short_url"] = qqdocurl
# 如果成功提取到关键信息,返回格式化的文本
if extracted_info.get("title") or extracted_info.get("desc") or extracted_info.get("short_url"):
content_parts = []
if extracted_info.get("title"):
content_parts.append(f"来源: {extracted_info['title']}")
if extracted_info.get("desc"):
content_parts.append(f"标题: {extracted_info['desc']}")
if extracted_info.get("short_url"):
content_parts.append(f"链接: {extracted_info['short_url']}")
formatted_content = "\n".join(content_parts)
return{
"type": "text",
"data": f"这是一条小程序分享消息,可以根据来源,考虑使用对应解析工具\n{formatted_content}",
}
# 检查是否是音乐分享 (QQ音乐类型)
if nested_data.get("view") == "music" and "com.tencent.music" in str(nested_data.get("app", "")):
meta = nested_data.get("meta", {})
music = meta.get("music", {})
if music:
tag = music.get("tag", "未知来源")
logger.debug(f"检测到【{tag}】音乐分享消息 (music view),开始提取信息")
title = music.get("title", "未知歌曲")
desc = music.get("desc", "未知艺术家")
jump_url = music.get("jumpUrl", "")
preview_url = music.get("preview", "")
artist = "未知艺术家"
song_title = title
if "网易云音乐" in tag:
artist = desc
elif "QQ音乐" in tag:
if " - " in title:
parts = title.split(" - ", 1)
song_title = parts[0]
artist = parts[1]
else:
artist = desc
formatted_content = (
f"这是一张来自【{tag}】的音乐分享卡片:\n"
f"歌曲: {song_title}\n"
f"艺术家: {artist}\n"
f"跳转链接: {jump_url}\n"
f"封面图: {preview_url}"
)
return {"type": "text", "data": formatted_content}
# 检查是否是新闻/图文分享 (网易云音乐可能伪装成这种)
elif nested_data.get("view") == "news" and "com.tencent.tuwen" in str(nested_data.get("app", "")):
meta = nested_data.get("meta", {})
news = meta.get("news", {})
if news and "网易云音乐" in news.get("tag", ""):
tag = news.get("tag")
logger.debug(f"检测到【{tag}】音乐分享消息 (news view),开始提取信息")
title = news.get("title", "未知歌曲")
desc = news.get("desc", "未知艺术家")
jump_url = news.get("jumpUrl", "")
preview_url = news.get("preview", "")
formatted_content = (
f"这是一张来自【{tag}】的音乐分享卡片:\n"
f"标题: {title}\n"
f"描述: {desc}\n"
f"跳转链接: {jump_url}\n"
f"封面图: {preview_url}"
)
return {"type": "text", "data": formatted_content}
# 如果没有提取到关键信息返回None
return None
except orjson.JSONDecodeError:
# 如果解析失败我们假设它不是我们关心的任何一种结构化JSON
# 而是普通的文本或者无法解析的格式。
logger.debug(f"无法将data字段解析为JSON: {json_data}")
return None
except Exception as e:
logger.error(f"处理JSON消息时发生未知错误: {e}")
return None
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

View File

@@ -0,0 +1,60 @@
"""元事件处理器"""
from __future__ import annotations
import time
import asyncio
from typing import TYPE_CHECKING, Any, Dict, Optional
from src.common.logger import get_logger
from ...event_models import MetaEventType
if TYPE_CHECKING:
from ....plugin import NapcatAdapter
logger = get_logger("napcat_adapter.meta_event_handler")
class MetaEventHandler:
"""处理 Napcat 元事件(心跳、生命周期)"""
def __init__(self, adapter: "NapcatAdapter"):
self.adapter = adapter
self.plugin_config: Optional[Dict[str, Any]] = None
self._interval_checking = False
def set_plugin_config(self, config: Dict[str, Any]) -> None:
"""设置插件配置"""
self.plugin_config = config
async def handle_meta_event(self, raw: Dict[str, Any]):
event_type = raw.get("meta_event_type")
if event_type == MetaEventType.lifecycle:
sub_type = raw.get("sub_type")
if sub_type == MetaEventType.Lifecycle.connect:
self_id = raw.get("self_id")
self.last_heart_beat = time.time()
logger.info(f"Bot {self_id} 连接成功")
# 不在连接时立即启动心跳检查,等第一个心跳包到达后再启动
elif event_type == MetaEventType.heartbeat:
if raw["status"].get("online") and raw["status"].get("good"):
self_id = raw.get("self_id")
if not self._interval_checking and self_id:
# 第一次收到心跳包时才启动心跳检查
asyncio.create_task(self.check_heartbeat(self_id))
self.last_heart_beat = time.time()
interval = raw.get("interval")
if interval:
self.interval = interval / 1000
else:
self_id = raw.get("self_id")
logger.warning(f"Bot {self_id} Napcat 端异常!")
async def check_heartbeat(self, id: int) -> None:
self._interval_checking = True
while True:
now_time = time.time()
if now_time - self.last_heart_beat > self.interval * 2:
logger.error(f"Bot {id} 可能发生了连接断开被下线或者Napcat卡死")
break
await asyncio.sleep(self.interval)

View File

@@ -0,0 +1,41 @@
"""通知事件处理器"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Dict, Optional
from src.common.logger import get_logger
if TYPE_CHECKING:
from ...plugin import NapcatAdapter
logger = get_logger("napcat_adapter.notice_handler")
class NoticeHandler:
"""处理 Napcat 通知事件(戳一戳、表情回复等)"""
def __init__(self, adapter: "NapcatAdapter"):
self.adapter = adapter
self.plugin_config: Optional[Dict[str, Any]] = None
def set_plugin_config(self, config: Dict[str, Any]) -> None:
"""设置插件配置"""
self.plugin_config = config
async def handle_notice(self, raw: Dict[str, Any]):
"""处理通知事件"""
# 简化版本:返回一个空的 MessageEnvelope
import time
import uuid
return {
"direction": "incoming",
"message_info": {
"platform": "qq",
"message_id": str(uuid.uuid4()),
"time": time.time(),
},
"message_segment": {"type": "text", "data": "[通知事件]"},
"timestamp_ms": int(time.time() * 1000),
}

View File

@@ -0,0 +1 @@
"""发送方向处理器"""

View File

@@ -0,0 +1,579 @@
"""发送处理器 - 将 MessageEnvelope 转换并发送到 Napcat"""
from __future__ import annotations
import random
import time
import uuid
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from mofox_wire import MessageEnvelope, SegPayload, GroupInfoPayload, UserInfoPayload, MessageInfoPayload
from src.common.logger import get_logger
from src.plugin_system.apis import config_api
from ...event_models import CommandType
from ..utils import convert_image_to_gif, get_image_format
logger = get_logger("napcat_adapter.send_handler")
if TYPE_CHECKING:
from ....plugin import NapcatAdapter
class SendHandler:
"""负责向 Napcat 发送消息"""
def __init__(self, adapter: "NapcatAdapter"):
self.adapter = adapter
self.plugin_config: Optional[Dict[str, Any]] = None
def set_plugin_config(self, config: Dict[str, Any]) -> None:
"""设置插件配置"""
self.plugin_config = config
async def handle_message(self, envelope: MessageEnvelope) -> None:
"""
处理来自核心的消息,将其转换为 Napcat 可接受的格式并发送
"""
logger.info("接收到来自MoFox-Bot的消息处理中")
if not envelope:
logger.warning("空的消息,跳过处理")
return
message_segment = envelope.get("message_segment")
if isinstance(message_segment, list):
segment: SegPayload = {"type": "seglist", "data": message_segment}
else:
segment = message_segment or {}
if segment:
seg_type = segment.get("type")
if seg_type == "command":
logger.info("处理命令")
return await self.send_command(envelope)
if seg_type == "adapter_command":
logger.info("处理适配器命令")
return await self.handle_adapter_command(envelope)
if seg_type == "adapter_response":
logger.info("收到adapter_response消息此消息应该由Bot端处理跳过")
return None
logger.info("处理普通消息")
return await self.send_normal_message(envelope)
async def send_normal_message(self, envelope: MessageEnvelope) -> None:
"""
处理普通消息发送
"""
logger.info("处理普通信息中")
message_info: MessageInfoPayload = envelope.get("message_info", {})
message_segment: SegPayload = envelope.get("message_segment", {}) # type: ignore[assignment]
if isinstance(message_segment, list):
seg_data: SegPayload = {"type": "seglist", "data": message_segment}
else:
seg_data = message_segment
group_info: Optional[GroupInfoPayload] = message_info.get("group_info")
user_info: Optional[UserInfoPayload] = message_info.get("user_info")
target_id: Optional[int] = None
action: Optional[str] = None
id_name: Optional[str] = None
processed_message: list = []
try:
processed_message = await self.handle_seg_recursive(seg_data, user_info or {})
except Exception as e:
logger.error(f"处理消息时发生错误: {e}")
return None
if not processed_message:
logger.critical("现在暂时不支持解析此回复!")
return None
if group_info and group_info.get("group_id"):
logger.debug("发送群聊消息")
target_id = int(group_info["group_id"])
action = "send_group_msg"
id_name = "group_id"
elif user_info and user_info.get("user_id"):
logger.debug("发送私聊消息")
target_id = int(user_info["user_id"])
action = "send_private_msg"
id_name = "user_id"
else:
logger.error("无法识别的消息类型")
return
logger.info("尝试发送到napcat")
logger.debug(
f"准备发送到napcat的消息体: action='{action}', {id_name}='{target_id}', message='{processed_message}'"
)
response = await self.send_message_to_napcat(
action or "",
{
id_name or "target_id": target_id,
"message": processed_message,
},
)
if response.get("status") == "ok":
logger.info("消息发送成功")
else:
logger.warning(f"消息发送失败napcat返回{str(response)}")
async def send_command(self, envelope: MessageEnvelope) -> None:
"""
处理命令类
"""
logger.info("处理命令中")
message_info: Dict[str, Any] = envelope.get("message_info", {})
group_info: Optional[Dict[str, Any]] = message_info.get("group_info")
segment: SegPayload = envelope.get("message_segment", {}) # type: ignore[assignment]
seg_data: Dict[str, Any] = segment.get("data", {}) if isinstance(segment, dict) else {}
command_name: Optional[str] = seg_data.get("name")
try:
args = seg_data.get("args", {})
if not isinstance(args, dict):
args = {}
if command_name == CommandType.GROUP_BAN.name:
command, args_dict = self.handle_ban_command(args, group_info)
elif command_name == CommandType.GROUP_WHOLE_BAN.name:
command, args_dict = self.handle_whole_ban_command(args, group_info)
elif command_name == CommandType.GROUP_KICK.name:
command, args_dict = self.handle_kick_command(args, group_info)
elif command_name == CommandType.SEND_POKE.name:
command, args_dict = self.handle_poke_command(args, group_info)
elif command_name == CommandType.DELETE_MSG.name:
command, args_dict = self.delete_msg_command(args)
elif command_name == CommandType.AI_VOICE_SEND.name:
command, args_dict = self.handle_ai_voice_send_command(args, group_info)
elif command_name == CommandType.SET_EMOJI_LIKE.name:
command, args_dict = self.handle_set_emoji_like_command(args)
elif command_name == CommandType.SEND_AT_MESSAGE.name:
command, args_dict = self.handle_at_message_command(args, group_info)
elif command_name == CommandType.SEND_LIKE.name:
command, args_dict = self.handle_send_like_command(args)
else:
logger.error(f"未知命令: {command_name}")
return
except Exception as e:
logger.error(f"处理命令时发生错误: {e}")
return None
if not command or not args_dict:
logger.error("命令或参数缺失")
return None
logger.info(f"准备向 Napcat 发送命令: command='{command}', args_dict='{args_dict}'")
response = await self.send_message_to_napcat(command, args_dict)
logger.info(f"收到 Napcat 的命令响应: {response}")
if response.get("status") == "ok":
logger.info(f"命令 {command_name} 执行成功")
else:
logger.warning(f"命令 {command_name} 执行失败napcat返回{str(response)}")
async def handle_adapter_command(self, envelope: MessageEnvelope) -> None:
"""
处理适配器命令类 - 用于直接向Napcat发送命令并返回结果
"""
logger.info("处理适配器命令中")
segment: SegPayload = envelope.get("message_segment", {}) # type: ignore[assignment]
seg_data: Dict[str, Any] = segment.get("data", {}) if isinstance(segment, dict) else {}
try:
action = seg_data.get("action")
params = seg_data.get("params", {})
request_id = seg_data.get("request_id")
timeout = float(seg_data.get("timeout", 20.0))
if not action:
logger.error("适配器命令缺少action参数")
return
logger.info(f"执行适配器命令: {action}")
if action == "get_cookies":
response = await self.send_message_to_napcat(action, params, timeout=40.0)
else:
response = await self.send_message_to_napcat(action, params, timeout=timeout)
try:
from src.plugin_system.apis.send_api import put_adapter_response
if request_id:
put_adapter_response(str(request_id), response)
except Exception as e:
logger.debug(f"回填 adapter 响应失败: {e}")
if response.get("status") == "ok":
logger.info(f"适配器命令 {action} 执行成功")
else:
logger.warning(f"适配器命令 {action} 执行失败napcat返回{str(response)}")
logger.debug(f"适配器命令 {action} 的完整响应: {response}")
except Exception as e:
logger.error(f"处理适配器命令时发生错误: {e}")
def get_level(self, seg_data: SegPayload) -> int:
if seg_data.get("type") == "seglist":
return 1 + max(self.get_level(seg) for seg in seg_data.get("data", []) if isinstance(seg, dict))
return 1
async def handle_seg_recursive(self, seg_data: SegPayload, user_info: UserInfoPayload) -> list:
payload: list = []
if seg_data.get("type") == "seglist":
if not seg_data.get("data"):
return []
for seg in seg_data["data"]:
if not isinstance(seg, dict):
continue
payload = await self.process_message_by_type(seg, payload, user_info)
else:
payload = await self.process_message_by_type(seg_data, payload, user_info)
return payload
async def process_message_by_type(self, seg: SegPayload, payload: list, user_info: UserInfoPayload) -> list:
new_payload = payload
seg_type = seg.get("type")
if seg_type == "reply":
target_id = seg.get("data")
target_id = str(target_id)
if target_id == "notice":
return payload
logger.info(target_id if isinstance(target_id, str) else "")
new_payload = self.build_payload(payload, await self.handle_reply_message(target_id, user_info), True)
elif seg_type == "text":
text = seg.get("data")
if not text:
return payload
new_payload = self.build_payload(payload, self.handle_text_message(str(text)), False)
elif seg_type == "face":
logger.warning("MoFox-Bot 发送了qq原生表情暂时不支持")
elif seg_type == "image":
image = seg.get("data")
new_payload = self.build_payload(payload, self.handle_image_message(str(image)), False)
elif seg_type == "emoji":
emoji = seg.get("data")
new_payload = self.build_payload(payload, self.handle_emoji_message(str(emoji)), False)
elif seg_type == "voice":
voice = seg.get("data")
new_payload = self.build_payload(payload, self.handle_voice_message(str(voice)), False)
elif seg_type == "voiceurl":
voice_url = seg.get("data")
new_payload = self.build_payload(payload, self.handle_voiceurl_message(str(voice_url)), False)
elif seg_type == "music":
song_id = seg.get("data")
new_payload = self.build_payload(payload, self.handle_music_message(str(song_id)), False)
elif seg_type == "videourl":
video_url = seg.get("data")
new_payload = self.build_payload(payload, self.handle_videourl_message(str(video_url)), False)
elif seg_type == "file":
file_path = seg.get("data")
new_payload = self.build_payload(payload, self.handle_file_message(str(file_path)), False)
elif seg_type == "seglist":
# 嵌套列表继续递归
nested_payload: list = []
for sub_seg in seg.get("data", []):
if not isinstance(sub_seg, dict):
continue
nested_payload = await self.process_message_by_type(sub_seg, nested_payload, user_info)
new_payload = self.build_payload(payload, nested_payload, False)
return new_payload
def build_payload(self, payload: list, addon: dict | list, is_reply: bool = False) -> list:
"""构建发送的消息体"""
if is_reply:
temp_list = []
if isinstance(addon, list):
temp_list.extend(addon)
else:
temp_list.append(addon)
for i in payload:
if isinstance(i, dict) and i.get("type") == "reply":
logger.debug("检测到多个回复,使用最新的回复")
continue
temp_list.append(i)
return temp_list
if isinstance(addon, list):
payload.extend(addon)
else:
payload.append(addon)
return payload
async def handle_reply_message(self, message_id: str, user_info: UserInfoPayload) -> dict | list:
"""处理回复消息"""
logger.debug(f"开始处理回复消息消息ID: {message_id}")
reply_seg = {"type": "reply", "data": {"id": message_id}}
# 检查是否启用引用艾特功能
if not config_api.get_plugin_config(self.plugin_config, "features.enable_reply_at", False):
logger.info("引用艾特功能未启用,仅发送普通回复")
return reply_seg
try:
msg_info_response = await self.send_message_to_napcat("get_msg", {"message_id": message_id})
logger.debug(f"获取消息 {message_id} 的详情响应: {msg_info_response}")
replied_user_id = None
if msg_info_response and msg_info_response.get("status") == "ok":
sender_info = msg_info_response.get("data", {}).get("sender")
if sender_info:
replied_user_id = sender_info.get("user_id")
if not replied_user_id:
logger.warning(f"无法获取消息 {message_id} 的发送者信息,跳过 @")
logger.info(f"最终返回的回复段: {reply_seg}")
return reply_seg
if random.random() < config_api.get_plugin_config(self.plugin_config, "features.reply_at_rate", 0.5):
at_seg = {"type": "at", "data": {"qq": str(replied_user_id)}}
text_seg = {"type": "text", "data": {"text": " "}}
result_seg = [reply_seg, at_seg, text_seg]
logger.info(f"最终返回的回复段: {result_seg}")
return result_seg
except Exception as e:
logger.error(f"处理引用回复并尝试@时出错: {e}")
logger.info(f"最终返回的回复段: {reply_seg}")
return reply_seg
logger.info(f"最终返回的回复段: {reply_seg}")
return reply_seg
def handle_text_message(self, message: str) -> dict:
"""处理文本消息"""
return {"type": "text", "data": {"text": message}}
def handle_image_message(self, encoded_image: str) -> dict:
"""处理图片消息"""
return {
"type": "image",
"data": {
"file": f"base64://{encoded_image}",
"subtype": 0,
},
}
def handle_emoji_message(self, encoded_emoji: str) -> dict:
"""处理表情消息"""
encoded_image = encoded_emoji
image_format = get_image_format(encoded_emoji)
if image_format != "gif":
encoded_image = convert_image_to_gif(encoded_emoji)
return {
"type": "image",
"data": {
"file": f"base64://{encoded_image}",
"subtype": 1,
"summary": "[动画表情]",
},
}
def handle_voice_message(self, encoded_voice: str) -> dict:
"""处理语音消息"""
use_tts = False
if self.plugin_config:
use_tts = config_api.get_plugin_config(self.plugin_config, "voice.use_tts", False)
if not use_tts:
logger.warning("未启用语音消息处理")
return {}
if not encoded_voice:
return {}
return {
"type": "record",
"data": {"file": f"base64://{encoded_voice}"},
}
def handle_voiceurl_message(self, voice_url: str) -> dict:
"""处理语音链接消息"""
return {
"type": "record",
"data": {"file": voice_url},
}
def handle_music_message(self, song_id: str) -> dict:
"""处理音乐消息"""
return {
"type": "music",
"data": {"type": "163", "id": song_id},
}
def handle_videourl_message(self, video_url: str) -> dict:
"""处理视频链接消息"""
return {
"type": "video",
"data": {"file": video_url},
}
def handle_file_message(self, file_path: str) -> dict:
"""处理文件消息"""
return {
"type": "file",
"data": {"file": f"file://{file_path}"},
}
def delete_msg_command(self, args: Dict[str, Any]) -> tuple[str, Dict[str, Any]]:
"""处理删除消息命令"""
return "delete_msg", {"message_id": args["message_id"]}
def handle_ban_command(self, args: Dict[str, Any], group_info: Optional[Dict[str, Any]]) -> tuple[str, Dict[str, Any]]:
"""处理封禁命令"""
duration: int = int(args["duration"])
user_id: int = int(args["qq_id"])
group_id: int = int(group_info["group_id"]) if group_info and group_info.get("group_id") else 0
if duration < 0:
raise ValueError("封禁时间必须大于等于0")
if not user_id or not group_id:
raise ValueError("封禁命令缺少必要参数")
if duration > 2592000:
raise ValueError("封禁时间不能超过30天")
return (
CommandType.GROUP_BAN.value,
{
"group_id": group_id,
"user_id": user_id,
"duration": duration,
},
)
def handle_whole_ban_command(self, args: Dict[str, Any], group_info: Optional[Dict[str, Any]]) -> tuple[str, Dict[str, Any]]:
"""处理全体禁言命令"""
enable = args["enable"]
assert isinstance(enable, bool), "enable参数必须是布尔值"
group_id: int = int(group_info["group_id"]) if group_info and group_info.get("group_id") else 0
if group_id <= 0:
raise ValueError("群组ID无效")
return (
CommandType.GROUP_WHOLE_BAN.value,
{
"group_id": group_id,
"enable": enable,
},
)
def handle_kick_command(self, args: Dict[str, Any], group_info: Optional[Dict[str, Any]]) -> tuple[str, Dict[str, Any]]:
"""处理群成员踢出命令"""
user_id: int = int(args["qq_id"])
group_id: int = int(group_info["group_id"]) if group_info and group_info.get("group_id") else 0
if group_id <= 0:
raise ValueError("群组ID无效")
if user_id <= 0:
raise ValueError("用户ID无效")
return (
CommandType.GROUP_KICK.value,
{
"group_id": group_id,
"user_id": user_id,
"reject_add_request": False,
},
)
def handle_poke_command(self, args: Dict[str, Any], group_info: Optional[Dict[str, Any]]) -> tuple[str, Dict[str, Any]]:
"""处理戳一戳命令"""
user_id: int = int(args["qq_id"])
group_id: Optional[int] = None
if group_info and group_info.get("group_id"):
group_id = int(group_info["group_id"])
if group_id <= 0:
raise ValueError("群组ID无效")
if user_id <= 0:
raise ValueError("用户ID无效")
return (
CommandType.SEND_POKE.value,
{
"group_id": group_id,
"user_id": user_id,
},
)
def handle_set_emoji_like_command(self, args: Dict[str, Any]) -> tuple[str, Dict[str, Any]]:
"""处理设置表情回应命令"""
logger.info(f"开始处理表情回应命令, 接收到参数: {args}")
try:
message_id = int(args["message_id"])
emoji_id = int(args["emoji_id"])
set_like = bool(args["set"])
except (KeyError, ValueError) as e:
logger.error(f"处理表情回应命令时发生错误: {e}, 原始参数: {args}")
raise ValueError(f"缺少必需参数或参数类型错误: {e}")
return (
CommandType.SET_EMOJI_LIKE.value,
{"message_id": message_id, "emoji_id": emoji_id, "set": set_like},
)
def handle_send_like_command(self, args: Dict[str, Any]) -> tuple[str, Dict[str, Any]]:
"""处理发送点赞命令的逻辑。"""
try:
user_id: int = int(args["qq_id"])
times: int = int(args["times"])
except (KeyError, ValueError):
raise ValueError("缺少必需参数: qq_id 或 times")
return (
CommandType.SEND_LIKE.value,
{"user_id": user_id, "times": times},
)
def handle_at_message_command(self, args: Dict[str, Any], group_info: Optional[Dict[str, Any]]) -> tuple[str, Dict[str, Any]]:
"""处理艾特并发送消息命令"""
at_user_id = args.get("qq_id")
text = args.get("text")
if not at_user_id or not text:
raise ValueError("艾特消息命令缺少 qq_id 或 text 参数")
if not group_info or not group_info.get("group_id"):
raise ValueError("艾特消息命令必须在群聊上下文中使用")
message_payload = [
{"type": "at", "data": {"qq": str(at_user_id)}},
{"type": "text", "data": {"text": " " + str(text)}},
]
return (
"send_group_msg",
{
"group_id": group_info["group_id"],
"message": message_payload,
},
)
def handle_ai_voice_send_command(self, args: Dict[str, Any], group_info: Optional[Dict[str, Any]]) -> tuple[str, Dict[str, Any]]:
"""
处理AI语音发送命令的逻辑。
并返回 NapCat 兼容的 (action, params) 元组。
"""
if not group_info or not group_info.get("group_id"):
raise ValueError("AI语音发送命令必须在群聊上下文中使用")
if not args:
raise ValueError("AI语音发送命令缺少参数")
group_id: int = int(group_info["group_id"])
character_id = args.get("character")
text_content = args.get("text")
if not character_id or not text_content:
raise ValueError(f"AI语音发送命令参数不完整: character='{character_id}', text='{text_content}'")
return (
CommandType.AI_VOICE_SEND.value,
{
"group_id": group_id,
"text": text_content,
"character": character_id,
},
)
async def send_message_to_napcat(self, action: str, params: dict, timeout: float = 20.0) -> dict:
"""通过 adapter API 发送到 napcat"""
try:
response = await self.adapter.send_napcat_api(action, params, timeout=timeout)
return response or {"status": "error", "message": "no response"}
except Exception as e:
logger.error(f"发送消息失败: {e}")
return {"status": "error", "message": str(e)}

View File

@@ -0,0 +1,411 @@
import asyncio
import base64
import io
import ssl
import time
import uuid
import weakref
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
import orjson
import urllib3
from PIL import Image
from src.common.logger import get_logger
if TYPE_CHECKING:
from ...plugin import NapcatAdapter
logger = get_logger("napcat_adapter")
# 简单的缓存实现,通过 JSON 文件实现磁盘一价存储
_CACHE_FILE = Path(__file__).resolve().parent / "napcat_cache.json"
_CACHE_LOCK = asyncio.Lock()
_CACHE: Dict[str, Dict[str, Dict[str, Any]]] = {
"group_info": {},
"group_detail_info": {},
"member_info": {},
"stranger_info": {},
"self_info": {},
}
# 各类信息的 TTL 缓存过期时间设置
GROUP_INFO_TTL = 300 # 5 min
GROUP_DETAIL_TTL = 300
MEMBER_INFO_TTL = 180
STRANGER_INFO_TTL = 300
SELF_INFO_TTL = 300
_adapter_ref: weakref.ReferenceType["NapcatAdapter"] | None = None
def register_adapter(adapter: "NapcatAdapter") -> None:
"""注册 NapcatAdapter 实例,以便 utils 模块可以获取 WebSocket"""
global _adapter_ref
_adapter_ref = weakref.ref(adapter)
logger.debug("Napcat adapter registered in utils for websocket access")
def _load_cache_from_disk() -> None:
if not _CACHE_FILE.exists():
return
try:
data = orjson.loads(_CACHE_FILE.read_bytes())
if isinstance(data, dict):
for key, section in _CACHE.items():
cached_section = data.get(key)
if isinstance(cached_section, dict):
section.update(cached_section)
except Exception as e:
logger.debug(f"Failed to load napcat cache: {e}")
def _save_cache_to_disk_locked() -> None:
"""重要提示:不要在持有 _CACHE_LOCK 时调用此函数"""
_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
_CACHE_FILE.write_bytes(orjson.dumps(_CACHE))
async def _get_cached(section: str, key: str, ttl: int) -> Any | None:
now = time.time()
async with _CACHE_LOCK:
entry = _CACHE.get(section, {}).get(key)
if not entry:
return None
ts = entry.get("ts", 0)
if ts and now - ts <= ttl:
return entry.get("data")
_CACHE.get(section, {}).pop(key, None)
try:
_save_cache_to_disk_locked()
except Exception:
pass
return None
async def _set_cached(section: str, key: str, data: Any) -> None:
async with _CACHE_LOCK:
_CACHE.setdefault(section, {})[key] = {"data": data, "ts": time.time()}
try:
_save_cache_to_disk_locked()
except Exception:
logger.debug("Write napcat cache failed", exc_info=True)
def _get_adapter(adapter: "NapcatAdapter | None" = None) -> "NapcatAdapter":
target = adapter
if target is None and _adapter_ref:
target = _adapter_ref()
if target is None:
raise RuntimeError(
"NapcatAdapter 未注册,请确保已调用 utils.register_adapter 注册"
)
return target
async def _call_adapter_api(
action: str,
params: Dict[str, Any],
adapter: "NapcatAdapter | None" = None,
timeout: float = 30.0,
) -> Optional[Dict[str, Any]]:
"""统一通过 adapter 发送和接收 API 调用"""
try:
target = _get_adapter(adapter)
# 确保 WS 已连接
target.get_ws_connection()
except Exception as e: # pragma: no cover - 难以在单元测试中查看
logger.error(f"WebSocket 未准备好,无法调用 API: {e}")
return None
try:
return await target.send_napcat_api(action, params, timeout=timeout)
except Exception as e:
logger.error(f"{action} 调用失败: {e}")
return None
# 加载缓存到内存一次,避免在每次调用缓存时重复加载
_load_cache_from_disk()
class SSLAdapter(urllib3.PoolManager):
def __init__(self, *args, **kwargs):
context = ssl.create_default_context()
context.set_ciphers("DEFAULT@SECLEVEL=1")
context.minimum_version = ssl.TLSVersion.TLSv1_2
kwargs["ssl_context"] = context
super().__init__(*args, **kwargs)
async def get_respose(
action: str,
params: Dict[str, Any],
adapter: "NapcatAdapter | None" = None,
timeout: float = 30.0,
):
return await _call_adapter_api(action, params, adapter=adapter, timeout=timeout)
async def get_group_info(
group_id: int,
*,
use_cache: bool = True,
force_refresh: bool = False,
adapter: "NapcatAdapter | None" = None,
) -> dict | None:
"""
获取群组基本信息
返回值可能是None需要调用方检查空值
"""
logger.debug("获取群组基本信息中")
cache_key = str(group_id)
if use_cache and not force_refresh:
cached = await _get_cached("group_info", cache_key, GROUP_INFO_TTL)
if cached is not None:
return cached
socket_response = await _call_adapter_api(
"get_group_info",
{"group_id": group_id},
adapter=adapter,
)
data = socket_response.get("data") if socket_response else None
if data is not None and use_cache:
await _set_cached("group_info", cache_key, data)
return data
async def get_group_detail_info(
group_id: int,
*,
use_cache: bool = True,
force_refresh: bool = False,
adapter: "NapcatAdapter | None" = None,
) -> dict | None:
"""
获取群组详细信息
返回值可能是None需要调用方检查空值
"""
logger.debug("获取群组详细信息中")
cache_key = str(group_id)
if use_cache and not force_refresh:
cached = await _get_cached("group_detail_info", cache_key, GROUP_DETAIL_TTL)
if cached is not None:
return cached
socket_response = await _call_adapter_api(
"get_group_detail_info",
{"group_id": group_id},
adapter=adapter,
)
data = socket_response.get("data") if socket_response else None
if data is not None and use_cache:
await _set_cached("group_detail_info", cache_key, data)
return data
async def get_member_info(
group_id: int,
user_id: int,
*,
use_cache: bool = True,
force_refresh: bool = False,
adapter: "NapcatAdapter | None" = None,
) -> dict | None:
"""
获取群组成员信息
返回值可能是None需要调用方检查空值
"""
logger.debug("获取群组成员信息中")
cache_key = f"{group_id}:{user_id}"
if use_cache and not force_refresh:
cached = await _get_cached("member_info", cache_key, MEMBER_INFO_TTL)
if cached is not None:
return cached
socket_response = await _call_adapter_api(
"get_group_member_info",
{"group_id": group_id, "user_id": user_id, "no_cache": True},
adapter=adapter,
)
data = socket_response.get("data") if socket_response else None
if data is not None and use_cache:
await _set_cached("member_info", cache_key, data)
return data
async def get_image_base64(url: str) -> str:
# sourcery skip: raise-specific-error
"""下载图片/视频并返回Base64"""
logger.debug(f"下载图片: {url}")
http = SSLAdapter()
try:
response = http.request("GET", url, timeout=10)
if response.status != 200:
raise Exception(f"HTTP Error: {response.status}")
image_bytes = response.data
return base64.b64encode(image_bytes).decode("utf-8")
except Exception as e:
logger.error(f"图片下载失败: {str(e)}")
raise
def convert_image_to_gif(image_base64: str) -> str:
# sourcery skip: extract-method
"""
将Base64编码的图片转换为GIF格式
Parameters:
image_base64: str: Base64编码的图片数据
Returns:
str: Base64编码的GIF图片数据
"""
logger.debug("转换图片为GIF格式")
try:
image_bytes = base64.b64decode(image_base64)
image = Image.open(io.BytesIO(image_bytes))
output_buffer = io.BytesIO()
image.save(output_buffer, format="GIF")
output_buffer.seek(0)
return base64.b64encode(output_buffer.read()).decode("utf-8")
except Exception as e:
logger.error(f"图片转换为GIF失败: {str(e)}")
return image_base64
async def get_self_info(
*,
use_cache: bool = True,
force_refresh: bool = False,
adapter: "NapcatAdapter | None" = None,
) -> dict | None:
"""
获取机器人信息
"""
logger.debug("获取机器人信息中")
cache_key = "self"
if use_cache and not force_refresh:
cached = await _get_cached("self_info", cache_key, SELF_INFO_TTL)
if cached is not None:
return cached
response = await _call_adapter_api("get_login_info", {}, adapter=adapter)
data = response.get("data") if response else None
if data is not None and use_cache:
await _set_cached("self_info", cache_key, data)
return data
def get_image_format(raw_data: str) -> str:
"""
从Base64编码的数据中确定图片的格式类型
Parameters:
raw_data: str: Base64编码的图片数据
Returns:
format: str: 图片的格式类型,如 'jpeg', 'png', 'gif'
"""
image_bytes = base64.b64decode(raw_data)
return Image.open(io.BytesIO(image_bytes)).format.lower()
async def get_stranger_info(
user_id: int,
*,
use_cache: bool = True,
force_refresh: bool = False,
adapter: "NapcatAdapter | None" = None,
) -> dict | None:
"""
获取陌生人信息
"""
logger.debug("获取陌生人信息中")
cache_key = str(user_id)
if use_cache and not force_refresh:
cached = await _get_cached("stranger_info", cache_key, STRANGER_INFO_TTL)
if cached is not None:
return cached
response = await _call_adapter_api(
"get_stranger_info", {"user_id": user_id}, adapter=adapter
)
data = response.get("data") if response else None
if data is not None and use_cache:
await _set_cached("stranger_info", cache_key, data)
return data
async def get_message_detail(
message_id: Union[str, int],
*,
adapter: "NapcatAdapter | None" = None,
) -> dict | None:
"""
获取消息详情,仅作为参考
"""
logger.debug("获取消息详情中")
response = await _call_adapter_api(
"get_msg",
{"message_id": message_id},
adapter=adapter,
timeout=30,
)
return response.get("data") if response else None
async def get_record_detail(
file: str,
file_id: Optional[str] = None,
*,
adapter: "NapcatAdapter | None" = None,
) -> dict | None:
"""
获取语音信息详情
"""
logger.debug("获取语音信息详情中")
response = await _call_adapter_api(
"get_record",
{"file": file, "file_id": file_id, "out_format": "wav"},
adapter=adapter,
timeout=30,
)
return response.get("data") if response else None
async def get_forward_message(
raw_message: dict, *, adapter: "NapcatAdapter | None" = None
) -> dict[str, Any] | None:
forward_message_data: dict = raw_message.get("data", {})
if not forward_message_data:
logger.warning("转发消息内容为空")
return None
forward_message_id = forward_message_data.get("id")
try:
response = await _call_adapter_api(
"get_forward_msg",
{"message_id": forward_message_id},
timeout=10.0,
adapter=adapter,
)
if response is None:
logger.error("获取转发消息失败,返回值为空")
return None
except TimeoutError:
logger.error("获取转发消息超时")
return None
except Exception as e:
logger.error(f"获取转发消息失败: {str(e)}")
return None
logger.debug(
f"转发消息原始格式:{orjson.dumps(response).decode('utf-8')[:80]}..."
if len(orjson.dumps(response).decode("utf-8")) > 80
else orjson.dumps(response).decode("utf-8")
)
response_data: Dict = response.get("data")
if not response_data:
logger.warning("转发消息内容为空或获取失败")
return None
return response_data.get("messages")

View File

@@ -0,0 +1,179 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
视频下载和处理模块
用于从QQ消息中下载视频并转发给Bot进行分析
"""
import asyncio
from pathlib import Path
from typing import Any, Dict, Optional
import aiohttp
from src.common.logger import get_logger
logger = get_logger("video_handler")
class VideoDownloader:
def __init__(self, max_size_mb: int = 100, download_timeout: int = 60):
self.max_size_mb = max_size_mb
self.download_timeout = download_timeout
self.supported_formats = {".mp4", ".avi", ".mov", ".mkv", ".flv", ".wmv", ".webm", ".m4v"}
def is_video_url(self, url: str) -> bool:
"""检查URL是否为视频文件"""
try:
# QQ视频URL可能没有扩展名所以先检查Content-Type
# 对于QQ视频我们先假设是视频稍后通过Content-Type验证
# 检查URL中是否包含视频相关的关键字
video_keywords = ["video", "mp4", "avi", "mov", "mkv", "flv", "wmv", "webm", "m4v"]
url_lower = url.lower()
# 如果URL包含视频关键字认为是视频
if any(keyword in url_lower for keyword in video_keywords):
return True
# 检查文件扩展名(传统方法)
path = Path(url.split("?")[0]) # 移除查询参数
if path.suffix.lower() in self.supported_formats:
return True
# 对于QQ等特殊平台,URL可能没有扩展名
# 我们允许这些URL通过,稍后通过HTTP头Content-Type验证
qq_domains = ["qpic.cn", "gtimg.cn", "qq.com", "tencent.com"]
if any(domain in url_lower for domain in qq_domains):
return True
return False
except Exception:
# 如果解析失败,默认允许尝试下载(稍后验证)
return True
def check_file_size(self, content_length: Optional[str]) -> bool:
"""检查文件大小是否在允许范围内"""
if content_length is None:
return True # 无法获取大小时允许下载
try:
size_bytes = int(content_length)
size_mb = size_bytes / (1024 * 1024)
return size_mb <= self.max_size_mb
except Exception:
return True
async def download_video(self, url: str, filename: Optional[str] = None) -> Dict[str, Any]:
"""
下载视频文件
Args:
url: 视频URL
filename: 可选的文件名
Returns:
dict: 下载结果包含success、data、filename、error等字段
"""
try:
logger.info(f"开始下载视频: {url}")
# 检查URL格式
if not self.is_video_url(url):
logger.warning(f"URL格式检查失败: {url}")
return {"success": False, "error": "不支持的视频格式", "url": url}
async with aiohttp.ClientSession() as session:
# 先发送HEAD请求检查文件大小
try:
async with session.head(url, timeout=aiohttp.ClientTimeout(total=10)) as response:
if response.status != 200:
logger.warning(f"HEAD请求失败状态码: {response.status}")
else:
content_length = response.headers.get("Content-Length")
if not self.check_file_size(content_length):
return {
"success": False,
"error": f"视频文件过大,超过{self.max_size_mb}MB限制",
"url": url,
}
except Exception as e:
logger.warning(f"HEAD请求失败: {e},继续尝试下载")
# 下载文件
async with session.get(url, timeout=aiohttp.ClientTimeout(total=self.download_timeout)) as response:
if response.status != 200:
return {"success": False, "error": f"下载失败HTTP状态码: {response.status}", "url": url}
# 检查Content-Type是否为视频
content_type = response.headers.get("Content-Type", "").lower()
if content_type:
# 检查是否为视频类型
video_mime_types = [
"video/",
"application/octet-stream",
"application/x-msvideo",
"video/x-msvideo",
]
is_video_content = any(mime in content_type for mime in video_mime_types)
if not is_video_content:
logger.warning(f"Content-Type不是视频格式: {content_type}")
# 如果不是明确的视频类型但可能是QQ的特殊格式继续尝试
if "text/" in content_type or "application/json" in content_type:
return {
"success": False,
"error": f"URL返回的不是视频内容Content-Type: {content_type}",
"url": url,
}
# 再次检查Content-Length
content_length = response.headers.get("Content-Length")
if not self.check_file_size(content_length):
return {"success": False, "error": f"视频文件过大,超过{self.max_size_mb}MB限制", "url": url}
# 读取文件内容
video_data = await response.read()
# 检查实际文件大小
actual_size_mb = len(video_data) / (1024 * 1024)
if actual_size_mb > self.max_size_mb:
return {
"success": False,
"error": f"视频文件过大,实际大小: {actual_size_mb:.2f}MB",
"url": url,
}
# 确定文件名
if filename is None:
filename = Path(url.split("?")[0]).name
if not filename or "." not in filename:
filename = "video.mp4"
logger.info(f"视频下载成功: {filename}, 大小: {actual_size_mb:.2f}MB")
return {
"success": True,
"data": video_data,
"filename": filename,
"size_mb": actual_size_mb,
"url": url,
}
except asyncio.TimeoutError:
return {"success": False, "error": "下载超时", "url": url}
except Exception as e:
logger.error(f"下载视频时出错: {e}")
return {"success": False, "error": str(e), "url": url}
# 全局实例
_video_downloader = None
def get_video_downloader(max_size_mb: int = 100, download_timeout: int = 60) -> VideoDownloader:
"""获取视频下载器实例"""
global _video_downloader
if _video_downloader is None:
_video_downloader = VideoDownloader(max_size_mb, download_timeout)
return _video_downloader