重构并增强Napcat适配器的功能

- 更新了`BaseAdapter`以简化子进程处理。
- 对`AdapterManager`进行了重构,以便根据适配器的`run_in_subprocess`属性来管理适配器。
- 增强了`NapcatAdapter`,以利用新的`CoreSinkManager`实现更优的进程管理。
- 在`utils.py`中实现了针对群组和成员信息的缓存机制。
- 改进了`message_handler.py`中的消息处理,以支持各种消息类型和格式。
- 已将插件配置版本更新至7.8.3。
This commit is contained in:
Windpicker-owo
2025-11-25 19:55:36 +08:00
parent 1ebdc37b22
commit 6b3b2a8245
38 changed files with 2082 additions and 3277 deletions

View File

@@ -17,12 +17,13 @@ from typing import Any, ClassVar, Dict, List, Optional
import orjson
import websockets
from mofox_bus import CoreMessageSink, MessageEnvelope, WebSocketAdapterOptions
from mofox_bus import CoreSink, MessageEnvelope, WebSocketAdapterOptions
from src.common.logger import get_logger
from src.plugin_system import 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
@@ -41,22 +42,16 @@ class NapcatAdapter(BaseAdapter):
platform = "qq"
run_in_subprocess = False
subprocess_entry = None
def __init__(self, core_sink: CoreMessageSink, plugin: Optional[BasePlugin] = None):
def __init__(self, core_sink: CoreSink, plugin: Optional[BasePlugin] = None):
"""初始化 Napcat 适配器"""
# 从插件配置读取 WebSocket URL
if plugin:
mode = config_api.get_plugin_config(plugin.config, "napcat_server.mode", "reverse")
host = config_api.get_plugin_config(plugin.config, "napcat_server.host", "localhost")
port = config_api.get_plugin_config(plugin.config, "napcat_server.port", 8095)
url = config_api.get_plugin_config(plugin.config, "napcat_server.url", "")
access_token = config_api.get_plugin_config(plugin.config, "napcat_server.access_token", "")
if mode == "forward" and url:
ws_url = url
else:
ws_url = f"ws://{host}:{port}"
ws_url = f"ws://{host}:{port}"
headers = {}
if access_token:
@@ -69,8 +64,6 @@ class NapcatAdapter(BaseAdapter):
transport = WebSocketAdapterOptions(
url=ws_url,
headers=headers if headers else None,
incoming_parser=self._parse_napcat_message,
outgoing_encoder=self._encode_napcat_response,
)
super().__init__(core_sink, plugin=plugin, transport=transport)
@@ -89,6 +82,9 @@ class NapcatAdapter(BaseAdapter):
# 注意_ws 继承自 BaseAdapter是 WebSocketLike 协议类型
self._napcat_ws = None # 可选的额外连接引用
# 注册 utils 内部使用的适配器实例,便于工具方法自动获取 WS
handler_utils.register_adapter(self)
async def on_adapter_loaded(self) -> None:
"""适配器加载时的初始化"""
logger.info("Napcat 适配器正在启动...")
@@ -114,22 +110,6 @@ class NapcatAdapter(BaseAdapter):
logger.info("Napcat 适配器已关闭")
def _parse_napcat_message(self, raw: str | bytes) -> Any:
"""解析 Napcat/OneBot 消息"""
try:
if isinstance(raw, bytes):
data = orjson.loads(raw)
else:
data = orjson.loads(raw)
return data
except Exception as e:
logger.error(f"解析 Napcat 消息失败: {e}")
raise
def _encode_napcat_response(self, envelope: MessageEnvelope) -> bytes:
"""编码响应消息为 Napcat 格式(暂未使用,通过 API 调用发送)"""
return orjson.dumps(envelope)
async def from_platform_message(self, raw: Dict[str, Any]) -> MessageEnvelope: # type: ignore[override]
"""
将 Napcat/OneBot 原始消息转换为 MessageEnvelope
@@ -178,20 +158,6 @@ class NapcatAdapter(BaseAdapter):
"""
await self.send_handler.handle_message(envelope)
def _create_empty_envelope(self) -> MessageEnvelope: # type: ignore[return]
"""创建一个空的消息信封(用于不需要处理的事件)"""
import time
return {
"direction": "incoming",
"message_info": {
"platform": self.platform,
"message_id": str(uuid.uuid4()),
"time": time.time(),
},
"message_segment": {"type": "text", "data": "[系统事件]"},
"timestamp_ms": int(time.time() * 1000),
}
async def send_napcat_api(self, action: str, params: Dict[str, Any], timeout: float = 30.0) -> Dict[str, Any]:
"""
发送 Napcat API 请求并等待响应
@@ -260,18 +226,12 @@ class NapcatAdapterPlugin(BasePlugin):
config_schema: ClassVar[dict] = {
"plugin": {
"name": {"type": str, "default": "napcat_adapter_plugin"},
"version": {"type": str, "default": "2.0.0"},
"version": {"type": str, "default": "1.0.0"},
"enabled": {"type": bool, "default": True},
},
"napcat_server": {
"mode": {
"type": str,
"default": "reverse",
"description": "连接模式reverse=反向连接(作为服务器), forward=正向连接(作为客户端)",
},
"host": {"type": str, "default": "localhost"},
"port": {"type": int, "default": 8095},
"url": {"type": str, "default": "", "description": "正向连接时的完整URL"},
"access_token": {"type": str, "default": ""},
},
"features": {
@@ -284,34 +244,18 @@ class NapcatAdapterPlugin(BasePlugin):
},
}
def __init__(self, plugin_dir: str = "", metadata: Any = None):
# 如果没有提供参数,创建一个默认的元数据
if metadata is None:
from src.plugin_system.base.plugin_metadata import PluginMetadata
metadata = PluginMetadata(
name=self.plugin_name,
version=self.plugin_version,
author=self.plugin_author,
description=self.plugin_description,
usage="",
dependencies=[],
python_dependencies=[],
)
if not plugin_dir:
from pathlib import Path
plugin_dir = str(Path(__file__).parent)
super().__init__(plugin_dir, metadata)
def __init__(self):
self._adapter: Optional[NapcatAdapter] = None
async def on_plugin_loaded(self):
"""插件加载时启动适配器"""
logger.info("Napcat 适配器插件正在加载...")
# 获取核心 Sink
from src.common.core_sink import get_core_sink
core_sink = get_core_sink()
# 从 CoreSinkManager 获取 InProcessCoreSink
from src.common.core_sink_manager import get_core_sink_manager
core_sink_manager = get_core_sink_manager()
core_sink = core_sink_manager.get_in_process_sink()
# 创建并启动适配器
self._adapter = NapcatAdapter(core_sink, plugin=self)

View File

@@ -5,11 +5,28 @@ from __future__ import annotations
import time
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from mofox_bus import MessageBuilder
from src.common.logger import get_logger
from src.plugin_system.apis import config_api
from mofox_bus import (
MessageEnvelope,
SegPayload,
MessageInfoPayload,
UserInfoPayload,
GroupInfoPayload,
)
from ...event_models import ACCEPT_FORMAT, QQ_FACE
from ..utils import (
get_group_info,
get_image_base64,
get_self_info,
get_member_info,
get_message_detail,
)
if TYPE_CHECKING:
from ...plugin import NapcatAdapter
from ....plugin import NapcatAdapter
logger = get_logger("napcat_adapter.message_handler")
@@ -28,99 +45,190 @@ class MessageHandler:
async def handle_raw_message(self, raw: Dict[str, Any]):
"""
处理原始消息并转换为 MessageEnvelope
Args:
raw: OneBot 原始消息数据
Returns:
MessageEnvelope (dict)
"""
from mofox_bus import MessageEnvelope, SegPayload, MessageInfoPayload, UserInfoPayload, GroupInfoPayload
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", {})
user_info: UserInfoPayload = {
"platform": "qq",
"user_id": str(sender_info.get("user_id", "")),
"user_nickname": sender_info.get("nickname", ""),
"user_cardname": sender_info.get("card", ""),
"user_avatar": sender_info.get("avatar", ""),
}
(
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", ""),
)
)
# 构造群组信息(如果是群消息)
group_info: Optional[GroupInfoPayload] = None
if message_type == "group":
group_id = raw.get("group_id")
if group_id:
group_info = {
"platform": "qq",
"group_id": str(group_id),
"group_name": "", # 可以通过 API 获取
}
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 seg in message_segments:
seg_type = seg.get("type", "")
seg_data = seg.get("data", {})
# 转换为 SegPayload
if seg_type == "text":
seg_list.append({
"type": "text",
"data": seg_data.get("text", "")
})
elif seg_type == "image":
# 这里需要下载图片并转换为 base64简化版本
seg_list.append({
"type": "image",
"data": seg_data.get("url", "") # 实际应该转换为 base64
})
elif seg_type == "at":
seg_list.append({
"type": "at",
"data": f"{seg_data.get('qq', '')}"
})
# 其他消息类型...
# 构造 MessageInfoPayload
message_info = {
"platform": "qq",
"message_id": message_id,
"time": message_time,
"user_info": user_info,
"format_info": {
"content_format": ["text", "image"], # 根据实际消息类型设置
"accept_format": ["text", "image", "emoji", "voice"],
},
}
# 添加群组信息(如果存在)
if group_info:
message_info["group_info"] = group_info
for segment in message_segments:
seg_message = await self.handle_single_segment(segment, raw)
if seg_message:
seg_list.append(seg_message)
# 构造 MessageEnvelope
envelope = {
"direction": "incoming",
"message_info": message_info,
"message_segment": {"type": "seglist", "data": seg_list} if len(seg_list) > 1 else (seg_list[0] if seg_list else {"type": "text", "data": ""}),
"raw_message": raw.get("raw_message", ""),
"platform": "qq",
"message_id": message_id,
"timestamp_ms": int(message_time * 1000),
}
msg_builder.format_info(
content_format=[seg["type"] for seg in seg_list],
accept_format=ACCEPT_FORMAT,
)
return envelope
return msg_builder.build()
async def handle_single_segment(
self, segment: dict, raw_message: dict, in_reply: bool = False
) -> SegPayload | List[SegPayload] | None:
"""
处理单一消息段并转换为 MessageEnvelope
Args:
segment: 单一原始消息段
raw_message: 完整的原始消息数据
Returns:
SegPayload | List[SegPayload] | None
"""
seg_type = segment.get("type")
seg_data: dict = segment.get("data", {})
match seg_type:
case "text":
return {"type": "text", "data": seg_data.get("text", "")}
case "image":
image_sub_type = seg_data.get("sub_type")
try:
image_base64 = await get_image_base64(seg_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
case "face":
message_data: dict = segment.get("data", {})
face_raw_id: str = str(message_data.get("id"))
if face_raw_id in QQ_FACE:
face_content: str = QQ_FACE.get(face_raw_id, "[未知表情]")
return {"type": "text", "data": face_content}
else:
logger.warning(f"不支持的表情:{face_raw_id}")
return None
case "at":
if seg_data:
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:
# 返回包含昵称和用户ID的at格式便于后续处理
return {
"type": "at",
"data": f"{self_info.get('nickname')}:{self_info.get('user_id')}",
}
else:
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:
# 返回包含昵称和用户ID的at格式便于后续处理
return {
"type": "at",
"data": f"{member_info.get('nickname')}:{member_info.get('user_id')}",
}
else:
return None
case "emoji":
seg_data = segment.get("id", "")
case "reply":
if not in_reply:
message_id = None
if seg_data:
message_id = seg_data.get("id")
else:
return None
message_detail = await get_message_detail(message_id)
if not message_detail:
logger.warning("获取被引用的消息详情失败")
return None
reply_message = await self.handle_single_segment(
message_detail, raw_message, in_reply=True
)
if reply_message is None:
reply_message = [
{"type": "text", "data": "[无法获取被引用的消息]"}
]
sender_info: dict = message_detail.get("sender", {})
sender_nickname: str = sender_info.get("nickname", "")
sender_id = sender_info.get("user_id")
seg_message: List[SegPayload] = []
if not sender_nickname:
logger.warning("无法获取被引用的人的昵称,返回默认值")
seg_message.append(
{
"type": "text",
"data": f"[回复<未知用户>{reply_message}],说:",
}
)
else:
if sender_id:
seg_message.append(
{
"type": "text",
"data": f"[回复<{sender_nickname}({sender_id})>{reply_message}],说:",
}
)
else:
seg_message.append(
{
"type": "text",
"data": f"[回复<{sender_nickname}>{reply_message}],说:",
}
)
return seg_message
case "voice":
seg_data = segment.get("url", "")
case _:
logger.warning(f"Unsupported segment type: {seg_type}")

View File

@@ -0,0 +1,361 @@
import asyncio
import base64
import io
import ssl
import time
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_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

View File

@@ -3,7 +3,7 @@ import random
import time
import websockets as Server
import uuid
from mofox_bus import (
from maim_message import (
UserInfo,
GroupInfo,
Seg,