- Implemented configuration parsing for NapCat adapter including server, chat, and filter settings. - Added message filtering logic to handle inbound chat messages based on user and group lists. - Developed a transport layer for WebSocket communication with the NapCat server. - Created a query service for fetching user and group information from the QQ platform. - Implemented runtime state management to report connection status to the host. - Added notice handling for various QQ platform events.
381 lines
14 KiB
Python
381 lines
14 KiB
Python
"""内置 NapCat 适配器插件。
|
||
|
||
当前实现维持 MVP 范围,目标是跑通基础消息收发链路:
|
||
1. 作为客户端连接 NapCat / OneBot v11 WebSocket 服务。
|
||
2. 将入站消息事件转换为 Host 侧的 ``MessageDict``。
|
||
3. 将 Host 出站消息转换为 OneBot 动作并发送。
|
||
|
||
当前范围刻意收敛为:
|
||
- 单连接
|
||
- 文本、@、reply 基础转发
|
||
- 暂不处理 ``notice`` / ``meta_event`` 的完整语义归一化
|
||
- 暂不支持图片、语音、文件等复杂媒体
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any, Dict, Mapping, Optional
|
||
|
||
import asyncio
|
||
|
||
from maibot_sdk import Adapter, MaiBotPlugin
|
||
|
||
from napcat_adapter.codec_inbound import NapCatInboundCodec
|
||
from napcat_adapter.codec_outbound import NapCatOutboundCodec
|
||
from napcat_adapter.config import NapCatPluginSettings
|
||
from napcat_adapter.filters import NapCatChatFilter
|
||
from napcat_adapter.qq_notice import NapCatNoticeCodec
|
||
from napcat_adapter.qq_queries import NapCatQueryService
|
||
from napcat_adapter.runtime_state import NapCatRuntimeStateManager
|
||
from napcat_adapter.transport import NapCatTransportClient
|
||
|
||
|
||
@Adapter(platform="qq", protocol="napcat", send_method="send_to_platform")
|
||
class NapCatAdapterPlugin(MaiBotPlugin):
|
||
"""NapCat 适配器 MVP 实现。"""
|
||
|
||
def __init__(self) -> None:
|
||
"""初始化 NapCat 适配器插件实例。"""
|
||
super().__init__()
|
||
self._plugin_config: Dict[str, Any] = {}
|
||
self._settings: Optional[NapCatPluginSettings] = None
|
||
self._inbound_codec: Optional[NapCatInboundCodec] = None
|
||
self._outbound_codec = NapCatOutboundCodec()
|
||
self._chat_filter: Optional[NapCatChatFilter] = None
|
||
self._query_service: Optional[NapCatQueryService] = None
|
||
self._notice_codec: Optional[NapCatNoticeCodec] = None
|
||
self._runtime_state: Optional[NapCatRuntimeStateManager] = None
|
||
self._transport: Optional[NapCatTransportClient] = None
|
||
|
||
def set_plugin_config(self, config: Dict[str, Any]) -> None:
|
||
"""设置插件配置内容。
|
||
|
||
Args:
|
||
config: Runner 注入的 ``config.toml`` 解析结果。
|
||
"""
|
||
self._plugin_config = config if isinstance(config, dict) else {}
|
||
|
||
async def on_load(self) -> None:
|
||
"""在插件加载时根据配置决定是否启动连接。"""
|
||
await self._restart_connection_if_needed()
|
||
|
||
async def on_unload(self) -> None:
|
||
"""在插件卸载时关闭连接并清理运行时状态。"""
|
||
await self._stop_connection()
|
||
|
||
async def on_config_update(self, new_config: Dict[str, Any], version: str) -> None:
|
||
"""在配置更新后重载连接状态。
|
||
|
||
Args:
|
||
new_config: 最新的插件配置。
|
||
version: 配置版本号。
|
||
"""
|
||
self.set_plugin_config(new_config)
|
||
if version:
|
||
self.ctx.logger.debug(f"NapCat 适配器收到配置更新通知: {version}")
|
||
await self._restart_connection_if_needed()
|
||
|
||
async def send_to_platform(
|
||
self,
|
||
message: Dict[str, Any],
|
||
route: Optional[Dict[str, Any]] = None,
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
**kwargs: Any,
|
||
) -> Dict[str, Any]:
|
||
"""将 Host 出站消息发送到 NapCat。
|
||
|
||
Args:
|
||
message: Host 侧标准 ``MessageDict``。
|
||
route: Platform IO 生成的路由信息。
|
||
metadata: Platform IO 附带的投递元数据。
|
||
**kwargs: 预留的扩展参数。
|
||
|
||
Returns:
|
||
Dict[str, Any]: 标准化后的发送结果。
|
||
"""
|
||
del metadata
|
||
del kwargs
|
||
|
||
self._ensure_runtime_components()
|
||
transport = self._transport
|
||
if transport is None:
|
||
return {"success": False, "error": "NapCat transport is not initialized"}
|
||
|
||
try:
|
||
action_name, params = self._outbound_codec.build_outbound_action(message, route or {})
|
||
response = await transport.call_action(action_name, params)
|
||
except Exception as exc:
|
||
return {"success": False, "error": str(exc)}
|
||
|
||
if str(response.get("status", "")).lower() != "ok":
|
||
return {
|
||
"success": False,
|
||
"error": str(response.get("wording") or response.get("message") or "NapCat send failed"),
|
||
"metadata": {"retcode": response.get("retcode")},
|
||
}
|
||
|
||
response_data = response.get("data", {})
|
||
external_message_id = ""
|
||
if isinstance(response_data, Mapping):
|
||
external_message_id = str(response_data.get("message_id") or "")
|
||
|
||
return {
|
||
"success": True,
|
||
"external_message_id": external_message_id or None,
|
||
"metadata": {"action": action_name},
|
||
}
|
||
|
||
def _ensure_runtime_components(self) -> None:
|
||
"""确保运行时依赖对象已经完成初始化。"""
|
||
if self._chat_filter is None:
|
||
self._chat_filter = NapCatChatFilter(self.ctx.logger)
|
||
|
||
if self._transport is None:
|
||
self._transport = NapCatTransportClient(
|
||
logger=self.ctx.logger,
|
||
on_connection_opened=self._bootstrap_adapter_runtime_state,
|
||
on_connection_closed=self._handle_transport_disconnected,
|
||
on_payload=self._handle_transport_payload,
|
||
)
|
||
|
||
if self._query_service is None:
|
||
self._query_service = NapCatQueryService(self.ctx.logger, self._transport)
|
||
|
||
if self._inbound_codec is None:
|
||
self._inbound_codec = NapCatInboundCodec(self.ctx.logger, self._query_service)
|
||
|
||
if self._notice_codec is None:
|
||
self._notice_codec = NapCatNoticeCodec(self.ctx.logger, self._query_service)
|
||
|
||
if self._runtime_state is None:
|
||
self._runtime_state = NapCatRuntimeStateManager(self.ctx.adapter, self.ctx.logger)
|
||
|
||
def _reload_settings(self) -> NapCatPluginSettings:
|
||
"""重新解析当前插件配置。
|
||
|
||
Returns:
|
||
NapCatPluginSettings: 最新的规范化配置。
|
||
"""
|
||
self._settings = NapCatPluginSettings.from_mapping(self._plugin_config, self.ctx.logger)
|
||
return self._settings
|
||
|
||
async def _restart_connection_if_needed(self) -> None:
|
||
"""根据当前配置重启连接循环。"""
|
||
self._ensure_runtime_components()
|
||
settings = self._reload_settings()
|
||
|
||
await self._stop_connection()
|
||
if not settings.should_connect():
|
||
self.ctx.logger.info("NapCat 适配器保持空闲状态,因为插件或配置未启用")
|
||
return
|
||
if not settings.validate(self.ctx.logger):
|
||
return
|
||
|
||
transport = self._transport
|
||
assert transport is not None
|
||
if not transport.is_available():
|
||
self.ctx.logger.error("NapCat 适配器依赖 aiohttp,但当前环境未安装该依赖")
|
||
return
|
||
|
||
transport.configure(settings.napcat_server)
|
||
await transport.start()
|
||
|
||
async def _stop_connection(self) -> None:
|
||
"""停止当前连接。"""
|
||
transport = self._transport
|
||
if transport is not None:
|
||
await transport.stop()
|
||
return
|
||
|
||
runtime_state = self._runtime_state
|
||
if runtime_state is not None:
|
||
await runtime_state.report_disconnected()
|
||
|
||
async def _handle_transport_payload(self, payload: Dict[str, Any]) -> None:
|
||
"""处理来自传输层的非 echo 载荷。
|
||
|
||
Args:
|
||
payload: NapCat 推送的原始事件数据。
|
||
"""
|
||
post_type = str(payload.get("post_type") or "").strip()
|
||
if post_type == "message":
|
||
await self._handle_inbound_message(payload)
|
||
return
|
||
if post_type == "notice":
|
||
await self._handle_notice_event(payload)
|
||
return
|
||
if post_type == "meta_event":
|
||
await self._handle_meta_event(payload)
|
||
|
||
async def _handle_inbound_message(self, payload: Dict[str, Any]) -> None:
|
||
"""处理单条 NapCat 入站消息并注入 Host。
|
||
|
||
Args:
|
||
payload: NapCat / OneBot 推送的原始消息事件。
|
||
"""
|
||
self._ensure_runtime_components()
|
||
settings = self._settings or self._reload_settings()
|
||
chat_filter = self._chat_filter
|
||
inbound_codec = self._inbound_codec
|
||
runtime_state = self._runtime_state
|
||
assert chat_filter is not None
|
||
assert inbound_codec is not None
|
||
assert runtime_state is not None
|
||
|
||
self_id = str(payload.get("self_id") or "").strip()
|
||
if self_id:
|
||
await runtime_state.report_connected(self_id, settings.napcat_server)
|
||
|
||
sender = payload.get("sender", {})
|
||
if not isinstance(sender, Mapping):
|
||
sender = {}
|
||
|
||
sender_user_id = str(payload.get("user_id") or sender.get("user_id") or "").strip()
|
||
if not sender_user_id:
|
||
return
|
||
|
||
group_id = str(payload.get("group_id") or "").strip()
|
||
if self_id and sender_user_id == self_id and settings.filters.ignore_self_message:
|
||
return
|
||
if not chat_filter.is_inbound_chat_allowed(sender_user_id, group_id, settings.chat):
|
||
return
|
||
|
||
message_dict = await inbound_codec.build_message_dict(payload, self_id, sender_user_id, sender)
|
||
route_metadata: Dict[str, Any] = {}
|
||
if self_id:
|
||
route_metadata["self_id"] = self_id
|
||
if settings.napcat_server.connection_id:
|
||
route_metadata["connection_id"] = settings.napcat_server.connection_id
|
||
|
||
external_message_id = str(payload.get("message_id") or "").strip()
|
||
accepted = await self.ctx.adapter.receive_external_message(
|
||
message_dict,
|
||
route_metadata=route_metadata,
|
||
external_message_id=external_message_id,
|
||
dedupe_key=external_message_id,
|
||
)
|
||
if not accepted:
|
||
self.ctx.logger.debug(f"Host 丢弃了 NapCat 入站消息: {external_message_id or '无消息 ID'}")
|
||
|
||
async def _handle_notice_event(self, payload: Dict[str, Any]) -> None:
|
||
"""处理 NapCat ``notice`` 事件并注入 Host。
|
||
|
||
Args:
|
||
payload: NapCat 推送的通知事件。
|
||
"""
|
||
self._ensure_runtime_components()
|
||
notice_codec = self._notice_codec
|
||
runtime_state = self._runtime_state
|
||
settings = self._settings or self._reload_settings()
|
||
assert notice_codec is not None
|
||
assert runtime_state is not None
|
||
|
||
self_id = str(payload.get("self_id") or "").strip()
|
||
if self_id:
|
||
await runtime_state.report_connected(self_id, settings.napcat_server)
|
||
|
||
message_dict = await notice_codec.build_notice_message_dict(payload)
|
||
if message_dict is None:
|
||
return
|
||
|
||
route_metadata: Dict[str, Any] = {}
|
||
if self_id:
|
||
route_metadata["self_id"] = self_id
|
||
if settings.napcat_server.connection_id:
|
||
route_metadata["connection_id"] = settings.napcat_server.connection_id
|
||
|
||
external_message_id = str(payload.get("message_id") or payload.get("notice_type") or "").strip()
|
||
accepted = await self.ctx.adapter.receive_external_message(
|
||
message_dict,
|
||
route_metadata=route_metadata,
|
||
external_message_id=external_message_id or None,
|
||
dedupe_key=external_message_id or None,
|
||
)
|
||
if not accepted:
|
||
self.ctx.logger.debug(f"Host 丢弃了 NapCat 通知事件: {external_message_id or '无消息 ID'}")
|
||
|
||
async def _handle_meta_event(self, payload: Dict[str, Any]) -> None:
|
||
"""处理 NapCat ``meta_event`` 事件。
|
||
|
||
Args:
|
||
payload: NapCat 推送的元事件。
|
||
"""
|
||
self._ensure_runtime_components()
|
||
notice_codec = self._notice_codec
|
||
runtime_state = self._runtime_state
|
||
settings = self._settings or self._reload_settings()
|
||
assert notice_codec is not None
|
||
assert runtime_state is not None
|
||
|
||
self_id = str(payload.get("self_id") or "").strip()
|
||
if self_id:
|
||
await runtime_state.report_connected(self_id, settings.napcat_server)
|
||
|
||
await notice_codec.handle_meta_event(payload)
|
||
|
||
async def _bootstrap_adapter_runtime_state(self) -> None:
|
||
"""在连接建立后主动获取账号信息并激活适配器路由。"""
|
||
transport = self._transport
|
||
query_service = self._query_service
|
||
runtime_state = self._runtime_state
|
||
settings = self._settings or self._reload_settings()
|
||
if transport is None or query_service is None or runtime_state is None:
|
||
return
|
||
|
||
max_attempts = 3
|
||
last_error: Optional[Exception] = None
|
||
for attempt in range(1, max_attempts + 1):
|
||
try:
|
||
login_info = await query_service.get_login_info()
|
||
self_id = self._extract_self_id_from_login_response(login_info)
|
||
await runtime_state.report_connected(self_id, settings.napcat_server)
|
||
return
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc:
|
||
last_error = exc
|
||
self.ctx.logger.warning(
|
||
f"NapCat 适配器获取登录信息失败,第 {attempt}/{max_attempts} 次重试: {exc}"
|
||
)
|
||
if attempt < max_attempts:
|
||
await asyncio.sleep(1.0)
|
||
|
||
if last_error is not None:
|
||
self.ctx.logger.error(f"NapCat 适配器未能完成路由激活,连接将保持只接收状态: {last_error}")
|
||
|
||
async def _handle_transport_disconnected(self) -> None:
|
||
"""处理传输层断开事件。"""
|
||
runtime_state = self._runtime_state
|
||
if runtime_state is not None:
|
||
await runtime_state.report_disconnected()
|
||
|
||
@staticmethod
|
||
def _extract_self_id_from_login_response(response: Optional[Dict[str, Any]]) -> str:
|
||
"""从 ``get_login_info`` 查询结果中提取当前账号 ID。
|
||
|
||
Args:
|
||
response: NapCat 返回的登录信息字典。
|
||
|
||
Returns:
|
||
str: 规范化后的账号 ID 字符串。
|
||
|
||
Raises:
|
||
ValueError: 当响应中缺少有效账号 ID 时抛出。
|
||
"""
|
||
if not isinstance(response, Mapping):
|
||
raise ValueError("get_login_info 响应缺少 data 字段")
|
||
|
||
self_id = str(response.get("user_id") or "").strip()
|
||
if not self_id:
|
||
raise ValueError("get_login_info 响应缺少有效的 user_id")
|
||
return self_id
|
||
|
||
|
||
def create_plugin() -> NapCatAdapterPlugin:
|
||
"""创建插件实例。
|
||
|
||
Returns:
|
||
NapCatAdapterPlugin: NapCat 内置适配器插件实例。
|
||
"""
|
||
return NapCatAdapterPlugin()
|