feat(napcat_adapter): 添加请求处理程序、发送处理程序、视频处理程序以及实用函数
- 实现了request_handler.py来处理对核心的请求。 - 创建了send_handler.py文件,用于处理并向Napcat发送消息。 - 添加了video_handler.py文件,用于从QQ消息中下载和处理视频文件。 - 开发了utils.py,用于缓存和实现与Napcat操作相关的实用函数。 - 为群组、成员和自身信息引入了带有生存时间(TTL)设置的缓存机制。 - 新模块中增强了错误处理和日志记录功能。
This commit is contained in:
427
src/plugins/built_in/napcat_adapter/README.md
Normal file
427
src/plugins/built_in/napcat_adapter/README.md
Normal 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(视频)消息段
|
||||
- [ ] json(JSON卡片)消息段
|
||||
- [ ] 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_info(MessageInfoPayload)
|
||||
# 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 个工作日
|
||||
16
src/plugins/built_in/napcat_adapter/__init__.py
Normal file
16
src/plugins/built_in/napcat_adapter/__init__.py
Normal 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,
|
||||
},
|
||||
)
|
||||
270
src/plugins/built_in/napcat_adapter/plugin.py
Normal file
270
src/plugins/built_in/napcat_adapter/plugin.py
Normal 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 API(send_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)]
|
||||
1
src/plugins/built_in/napcat_adapter/src/__init__.py
Normal file
1
src/plugins/built_in/napcat_adapter/src/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""工具模块"""
|
||||
310
src/plugins/built_in/napcat_adapter/src/event_models.py
Normal file
310
src/plugins/built_in/napcat_adapter/src/event_models.py
Normal 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",
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
"""处理器模块"""
|
||||
@@ -0,0 +1 @@
|
||||
"""接收方向处理器"""
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""发送方向处理器"""
|
||||
@@ -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)}
|
||||
|
||||
411
src/plugins/built_in/napcat_adapter/src/handlers/utils.py
Normal file
411
src/plugins/built_in/napcat_adapter/src/handlers/utils.py
Normal 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")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user