feat: add NapCat built-in adapter with configuration, filters, and transport layer

- 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.
This commit is contained in:
DrSmoothl
2026-03-22 00:19:26 +08:00
parent 4e2e7a279e
commit baabe4463e
18 changed files with 2755 additions and 897 deletions

View File

@@ -0,0 +1,70 @@
from pathlib import Path
from typing import Any, Dict
import importlib
import sys
BUILT_IN_PLUGIN_ROOT = Path(__file__).resolve().parents[1] / "src" / "plugins" / "built_in"
if str(BUILT_IN_PLUGIN_ROOT) not in sys.path:
sys.path.insert(0, str(BUILT_IN_PLUGIN_ROOT))
NapCatOutboundCodec = importlib.import_module("napcat_adapter.codec_outbound").NapCatOutboundCodec
def test_napcat_outbound_codec_supports_binary_and_forward_segments() -> None:
codec = NapCatOutboundCodec()
raw_message = [
{"type": "text", "data": "hello"},
{"type": "image", "data": "", "hash": "h1", "binary_data_base64": "aW1hZ2U="},
{"type": "emoji", "data": "", "hash": "h2", "binary_data_base64": "ZW1vamk="},
{"type": "voice", "data": "", "hash": "h3", "binary_data_base64": "dm9pY2U="},
{
"type": "reply",
"data": {
"target_message_id": "origin-1",
"target_message_content": "origin text",
},
},
{
"type": "forward",
"data": [
{
"user_id": "42",
"user_nickname": "alice",
"user_cardname": "Alice",
"message_id": "fwd-1",
"content": [{"type": "text", "data": "node-text"}],
}
],
},
]
converted = codec.convert_segments(raw_message)
assert converted[0] == {"type": "text", "data": {"text": "hello"}}
assert converted[1]["type"] == "image"
assert converted[1]["data"]["file"] == "base64://aW1hZ2U="
assert converted[2]["type"] == "image"
assert converted[2]["data"]["subtype"] == 1
assert converted[3] == {"type": "record", "data": {"file": "base64://dm9pY2U="}}
assert converted[4] == {"type": "reply", "data": {"id": "origin-1"}}
assert converted[5]["type"] == "node"
assert converted[5]["data"]["name"] == "alice"
assert converted[5]["data"]["content"] == [{"type": "text", "data": {"text": "node-text"}}]
def test_napcat_outbound_codec_builds_private_action_from_route_metadata() -> None:
codec = NapCatOutboundCodec()
message: Dict[str, Any] = {
"message_info": {
"user_info": {"user_id": "10001", "user_nickname": "tester"},
"additional_config": {},
},
"raw_message": [{"type": "text", "data": "hello"}],
}
action_name, params = codec.build_outbound_action(message, {"target_user_id": "30001"})
assert action_name == "send_private_msg"
assert params == {"message": [{"type": "text", "data": {"text": "hello"}}], "user_id": "30001"}

View File

@@ -0,0 +1,91 @@
from pathlib import Path
from typing import List
import importlib
import sys
BUILT_IN_PLUGIN_ROOT = Path(__file__).resolve().parents[1] / "src" / "plugins" / "built_in"
if str(BUILT_IN_PLUGIN_ROOT) not in sys.path:
sys.path.insert(0, str(BUILT_IN_PLUGIN_ROOT))
NapCatPluginSettings = importlib.import_module("napcat_adapter.config").NapCatPluginSettings
class DummyLogger:
"""用于测试的轻量日志对象。"""
def __init__(self) -> None:
"""初始化测试日志对象。"""
self.warnings: List[str] = []
self.errors: List[str] = []
def warning(self, message: str) -> None:
"""记录警告日志。
Args:
message: 待记录的日志内容。
"""
self.warnings.append(message)
def error(self, message: str) -> None:
"""记录错误日志。
Args:
message: 待记录的日志内容。
"""
self.errors.append(message)
def test_parse_new_napcat_server_config() -> None:
logger = DummyLogger()
settings = NapCatPluginSettings.from_mapping(
{
"plugin": {"enabled": True, "config_version": "0.1.0"},
"napcat_server": {
"host": "localhost",
"port": 8095,
"token": "secret",
"heartbeat_interval": 45,
"reconnect_delay_sec": 7,
"action_timeout_sec": 18,
"connection_id": "main",
},
},
logger,
)
assert settings.should_connect() is True
assert settings.napcat_server.host == "localhost"
assert settings.napcat_server.port == 8095
assert settings.napcat_server.token == "secret"
assert settings.napcat_server.heartbeat_interval == 45.0
assert settings.napcat_server.reconnect_delay_sec == 7.0
assert settings.napcat_server.action_timeout_sec == 18.0
assert settings.napcat_server.connection_id == "main"
assert settings.napcat_server.build_ws_url() == "ws://localhost:8095"
assert settings.validate(logger) is True
def test_parse_legacy_connection_ws_url_fallback() -> None:
logger = DummyLogger()
settings = NapCatPluginSettings.from_mapping(
{
"plugin": {"enabled": True, "config_version": "0.1.0"},
"connection": {
"ws_url": "ws://127.0.0.1:3001",
"access_token": "legacy-token",
"heartbeat_sec": 35,
"action_timeout_sec": 12,
},
},
logger,
)
assert settings.napcat_server.host == "127.0.0.1"
assert settings.napcat_server.port == 3001
assert settings.napcat_server.token == "legacy-token"
assert settings.napcat_server.heartbeat_interval == 35.0
assert settings.napcat_server.action_timeout_sec == 12.0
assert settings.validate(logger) is True
assert logger.warnings

View File

@@ -0,0 +1,164 @@
"""Platform IO 入站去重策略测试。"""
from types import SimpleNamespace
from typing import Any, Dict, List, Optional
import pytest
from src.platform_io.drivers.base import PlatformIODriver
from src.platform_io.manager import PlatformIOManager
from src.platform_io.types import DeliveryReceipt, DeliveryStatus, DriverDescriptor, DriverKind, InboundMessageEnvelope, RouteBinding, RouteKey
def _build_envelope(
*,
dedupe_key: str | None = None,
external_message_id: str | None = None,
session_message_id: str | None = None,
payload: Optional[Dict[str, Any]] = None,
) -> InboundMessageEnvelope:
"""构造测试用入站信封。
Args:
dedupe_key: 显式去重键。
external_message_id: 平台侧消息 ID。
session_message_id: 规范化消息对象上的消息 ID。
payload: 原始载荷。
Returns:
InboundMessageEnvelope: 测试用入站消息信封。
"""
session_message = None
if session_message_id is not None:
session_message = SimpleNamespace(message_id=session_message_id)
return InboundMessageEnvelope(
route_key=RouteKey(platform="qq", account_id="10001", scope="main"),
driver_id="plugin.napcat",
driver_kind=DriverKind.PLUGIN,
dedupe_key=dedupe_key,
external_message_id=external_message_id,
session_message=session_message,
payload=payload,
)
class _StubPlatformIODriver(PlatformIODriver):
"""测试用 Platform IO 驱动。"""
async def send_message(
self,
message: Any,
route_key: RouteKey,
metadata: Optional[Dict[str, Any]] = None,
) -> DeliveryReceipt:
"""返回一个固定的成功回执。
Args:
message: 待发送的消息对象。
route_key: 本次发送使用的路由键。
metadata: 额外发送元数据。
Returns:
DeliveryReceipt: 固定的成功回执。
"""
return DeliveryReceipt(
internal_message_id=str(getattr(message, "message_id", "stub-message-id")),
route_key=route_key,
status=DeliveryStatus.SENT,
driver_id=self.driver_id,
driver_kind=self.descriptor.kind,
)
def _build_manager() -> PlatformIOManager:
"""构造带有最小 active owner 的 Broker 管理器。
Returns:
PlatformIOManager: 已注册测试驱动并绑定活动路由的 Broker。
"""
manager = PlatformIOManager()
driver = _StubPlatformIODriver(
DriverDescriptor(
driver_id="plugin.napcat",
kind=DriverKind.PLUGIN,
platform="qq",
account_id="10001",
scope="main",
)
)
manager.register_driver(driver)
manager.bind_route(
RouteBinding(
route_key=RouteKey(platform="qq", account_id="10001", scope="main"),
driver_id=driver.driver_id,
driver_kind=driver.descriptor.kind,
)
)
return manager
class TestPlatformIODedupe:
"""Platform IO 去重测试。"""
@pytest.mark.asyncio
async def test_accept_inbound_dedupes_by_external_message_id(self) -> None:
"""相同平台消息 ID 的重复入站应被抑制。"""
manager = _build_manager()
accepted_envelopes: List[InboundMessageEnvelope] = []
async def dispatcher(envelope: InboundMessageEnvelope) -> None:
"""记录被成功接收的入站消息。
Args:
envelope: 被 Broker 接受的入站消息。
"""
accepted_envelopes.append(envelope)
manager.set_inbound_dispatcher(dispatcher)
first_envelope = _build_envelope(
external_message_id="msg-1",
payload={"message": "hello"},
)
second_envelope = _build_envelope(
external_message_id="msg-1",
payload={"message": "hello"},
)
assert await manager.accept_inbound(first_envelope) is True
assert await manager.accept_inbound(second_envelope) is False
assert len(accepted_envelopes) == 1
@pytest.mark.asyncio
async def test_accept_inbound_without_stable_identity_does_not_guess_duplicate(self) -> None:
"""缺少稳定身份时,不应仅凭 payload 内容猜测重复消息。"""
manager = _build_manager()
accepted_envelopes: List[InboundMessageEnvelope] = []
async def dispatcher(envelope: InboundMessageEnvelope) -> None:
"""记录被成功接收的入站消息。
Args:
envelope: 被 Broker 接受的入站消息。
"""
accepted_envelopes.append(envelope)
manager.set_inbound_dispatcher(dispatcher)
first_envelope = _build_envelope(payload={"message": "same-payload"})
second_envelope = _build_envelope(payload={"message": "same-payload"})
assert await manager.accept_inbound(first_envelope) is True
assert await manager.accept_inbound(second_envelope) is True
assert len(accepted_envelopes) == 2
def test_build_inbound_dedupe_key_prefers_explicit_identity(self) -> None:
"""去重键应只来自显式或稳定的技术身份。"""
explicit_envelope = _build_envelope(dedupe_key="dedupe-1", external_message_id="msg-1")
session_message_envelope = _build_envelope(session_message_id="session-1")
payload_only_envelope = _build_envelope(payload={"message": "hello"})
assert PlatformIOManager._build_inbound_dedupe_key(explicit_envelope) == "qq:10001:main:dedupe-1"
assert PlatformIOManager._build_inbound_dedupe_key(session_message_envelope) == "qq:10001:main:session-1"
assert PlatformIOManager._build_inbound_dedupe_key(payload_only_envelope) is None

View File

@@ -0,0 +1,87 @@
from datetime import datetime
from pathlib import Path
import sys
from src.chat.message_receive.message import SessionMessage
from src.common.data_models.mai_message_data_model import GroupInfo, MessageInfo, UserInfo
from src.common.data_models.message_component_data_model import (
ForwardComponent,
ForwardNodeComponent,
ImageComponent,
MessageSequence,
ReplyComponent,
TextComponent,
VoiceComponent,
)
from src.plugin_runtime.host.message_utils import PluginMessageUtils
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
def test_plugin_message_utils_preserves_binary_components_and_reply_metadata() -> None:
message = SessionMessage(message_id="msg-1", timestamp=datetime.now(), platform="qq")
message.message_info = MessageInfo(
user_info=UserInfo(user_id="10001", user_nickname="tester"),
group_info=GroupInfo(group_id="20001", group_name="group"),
additional_config={"self_id": "999"},
)
message.session_id = "qq:20001:10001"
message.processed_plain_text = "binary payload"
message.display_message = "binary payload"
message.raw_message = MessageSequence(
components=[
TextComponent("hello"),
ImageComponent(binary_hash="", binary_data=b"image-bytes", content=""),
VoiceComponent(binary_hash="", binary_data=b"voice-bytes", content=""),
ReplyComponent(
target_message_id="origin-1",
target_message_content="origin text",
target_message_sender_id="42",
target_message_sender_nickname="alice",
target_message_sender_cardname="Alice",
),
ForwardNodeComponent(
forward_components=[
ForwardComponent(
user_nickname="bob",
user_id="43",
user_cardname="Bob",
message_id="forward-1",
content=[
TextComponent("node-text"),
ImageComponent(binary_hash="", binary_data=b"node-image", content=""),
],
)
]
),
]
)
message_dict = PluginMessageUtils._session_message_to_dict(message)
rebuilt_message = PluginMessageUtils._build_session_message_from_dict(dict(message_dict))
image_component = rebuilt_message.raw_message.components[1]
voice_component = rebuilt_message.raw_message.components[2]
reply_component = rebuilt_message.raw_message.components[3]
forward_component = rebuilt_message.raw_message.components[4]
assert isinstance(image_component, ImageComponent)
assert image_component.binary_data == b"image-bytes"
assert isinstance(voice_component, VoiceComponent)
assert voice_component.binary_data == b"voice-bytes"
assert isinstance(reply_component, ReplyComponent)
assert reply_component.target_message_id == "origin-1"
assert reply_component.target_message_content == "origin text"
assert reply_component.target_message_sender_id == "42"
assert reply_component.target_message_sender_nickname == "alice"
assert reply_component.target_message_sender_cardname == "Alice"
assert isinstance(forward_component, ForwardNodeComponent)
assert isinstance(forward_component.forward_components[0].content[1], ImageComponent)
assert forward_component.forward_components[0].content[1].binary_data == b"node-image"

View File

@@ -2,9 +2,6 @@
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Optional from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Optional
import hashlib
import json
from src.common.logger import get_logger from src.common.logger import get_logger
from src.platform_io.drivers.base import PlatformIODriver from src.platform_io.drivers.base import PlatformIODriver
@@ -438,12 +435,17 @@ class PlatformIOManager:
Returns: Returns:
Optional[str]: 若可以构造稳定去重键则返回该键,否则返回 ``None``。 Optional[str]: 若可以构造稳定去重键则返回该键,否则返回 ``None``。
Notes:
这里仅接受上游显式提供的稳定消息身份,例如 ``dedupe_key``、
平台侧 ``external_message_id`` 或已经完成规范化的
``session_message.message_id``。Broker 不再根据 ``payload`` 内容
猜测语义去重键,避免把“短时间内两条内容刚好完全相同”的合法消息
误判为重复入站。
""" """
raw_dedupe_key = envelope.dedupe_key or envelope.external_message_id raw_dedupe_key = envelope.dedupe_key or envelope.external_message_id
if raw_dedupe_key is None and envelope.session_message is not None: if raw_dedupe_key is None and envelope.session_message is not None:
raw_dedupe_key = envelope.session_message.message_id raw_dedupe_key = envelope.session_message.message_id
if raw_dedupe_key is None and envelope.payload is not None:
raw_dedupe_key = PlatformIOManager._build_payload_fingerprint(envelope.payload)
if raw_dedupe_key is None: if raw_dedupe_key is None:
return None return None
@@ -453,29 +455,6 @@ class PlatformIOManager:
return f"{envelope.route_key.to_dedupe_scope()}:{normalized_dedupe_key}" return f"{envelope.route_key.to_dedupe_scope()}:{normalized_dedupe_key}"
@staticmethod
def _build_payload_fingerprint(payload: Dict[str, Any]) -> Optional[str]:
"""根据消息载荷构造稳定指纹。
Args:
payload: 待构造指纹的原始载荷字典。
Returns:
Optional[str]: 若成功生成指纹则返回十六进制摘要,否则返回 ``None``。
"""
try:
serialized_payload = json.dumps(
payload,
default=str,
ensure_ascii=True,
separators=(",", ":"),
sort_keys=True,
)
except Exception:
return None
return hashlib.sha256(serialized_payload.encode()).hexdigest()
@staticmethod @staticmethod
def _validate_binding_against_driver(binding: RouteBinding, driver: PlatformIODriver) -> None: def _validate_binding_against_driver(binding: RouteBinding, driver: PlatformIODriver) -> None:
"""校验路由绑定与驱动描述是否一致。 """校验路由绑定与驱动描述是否一致。

View File

@@ -198,8 +198,9 @@ class InboundMessageEnvelope:
driver_kind: 产出该消息的驱动类型。 driver_kind: 产出该消息的驱动类型。
external_message_id: 可选的平台侧消息 ID用于去重。 external_message_id: 可选的平台侧消息 ID用于去重。
dedupe_key: 可选的显式去重键。当外部消息没有稳定 ``message_id`` 时, dedupe_key: 可选的显式去重键。当外部消息没有稳定 ``message_id`` 时,
可由上游驱动提供消息指纹。若这里为空,中间层仍可能继续回退到 可由上游驱动提供稳定的技术性幂等键。若这里为空,中间层仅会继续
``session_message.message_id`` 或 ``payload`` 指纹。 回退到 ``external_message_id`` 或 ``session_message.message_id``
不会再根据 ``payload`` 内容猜测语义去重键。
session_message: 可选的、已经完成规范化的 ``SessionMessage`` 对象。 session_message: 可选的、已经完成规范化的 ``SessionMessage`` 对象。
payload: 可选的原始字典载荷,供延迟转换或调试使用。 payload: 可选的原始字典载荷,供延迟转换或调试使用。
metadata: 额外入站元数据,例如连接信息或追踪上下文。 metadata: 额外入站元数据,例如连接信息或追踪上下文。

View File

@@ -1,10 +1,25 @@
from datetime import datetime from datetime import datetime
from typing import Dict, Any, TypedDict, Optional, List from typing import Any, Dict, List, Optional, TypedDict
import base64
import hashlib
from src.common.logger import get_logger from src.common.logger import get_logger
from src.chat.message_receive.message import SessionMessage from src.chat.message_receive.message import SessionMessage
from src.common.data_models.mai_message_data_model import UserInfo, GroupInfo, MessageInfo from src.common.data_models.mai_message_data_model import UserInfo, GroupInfo, MessageInfo
from src.common.data_models.message_component_data_model import MessageSequence from src.common.data_models.message_component_data_model import (
AtComponent,
DictComponent,
EmojiComponent,
ForwardComponent,
ForwardNodeComponent,
ImageComponent,
MessageSequence,
ReplyComponent,
StandardMessageComponents,
TextComponent,
VoiceComponent,
)
logger = get_logger("plugin_runtime.host.message_utils") logger = get_logger("plugin_runtime.host.message_utils")
@@ -45,6 +60,251 @@ class MessageDict(TypedDict, total=False):
class PluginMessageUtils: class PluginMessageUtils:
@staticmethod
def _message_sequence_to_dict(message_sequence: MessageSequence) -> List[Dict[str, Any]]:
"""将消息组件序列转换为插件运行时使用的字典结构。
Args:
message_sequence: 待转换的消息组件序列。
Returns:
List[Dict[str, Any]]: 供插件运行时协议使用的消息段字典列表。
"""
return [PluginMessageUtils._component_to_dict(component) for component in message_sequence.components]
@staticmethod
def _component_to_dict(component: StandardMessageComponents) -> Dict[str, Any]:
"""将单个消息组件转换为插件运行时字典结构。
Args:
component: 待转换的消息组件。
Returns:
Dict[str, Any]: 序列化后的消息组件字典。
"""
if isinstance(component, TextComponent):
return {"type": "text", "data": component.text}
if isinstance(component, ImageComponent):
serialized = {
"type": "image",
"data": component.content,
"hash": component.binary_hash,
}
if component.binary_data:
serialized["binary_data_base64"] = base64.b64encode(component.binary_data).decode("utf-8")
return serialized
if isinstance(component, EmojiComponent):
serialized = {
"type": "emoji",
"data": component.content,
"hash": component.binary_hash,
}
if component.binary_data:
serialized["binary_data_base64"] = base64.b64encode(component.binary_data).decode("utf-8")
return serialized
if isinstance(component, VoiceComponent):
serialized = {
"type": "voice",
"data": component.content,
"hash": component.binary_hash,
}
if component.binary_data:
serialized["binary_data_base64"] = base64.b64encode(component.binary_data).decode("utf-8")
return serialized
if isinstance(component, AtComponent):
return {
"type": "at",
"data": {
"target_user_id": component.target_user_id,
"target_user_nickname": component.target_user_nickname,
"target_user_cardname": component.target_user_cardname,
},
}
if isinstance(component, ReplyComponent):
return {
"type": "reply",
"data": {
"target_message_id": component.target_message_id,
"target_message_content": component.target_message_content,
"target_message_sender_id": component.target_message_sender_id,
"target_message_sender_nickname": component.target_message_sender_nickname,
"target_message_sender_cardname": component.target_message_sender_cardname,
},
}
if isinstance(component, ForwardNodeComponent):
return {
"type": "forward",
"data": [PluginMessageUtils._forward_component_to_dict(item) for item in component.forward_components],
}
return {"type": "dict", "data": component.data}
@staticmethod
def _forward_component_to_dict(component: ForwardComponent) -> Dict[str, Any]:
"""将单个转发节点组件转换为字典结构。
Args:
component: 待转换的转发节点组件。
Returns:
Dict[str, Any]: 序列化后的转发节点字典。
"""
return {
"user_id": component.user_id,
"user_nickname": component.user_nickname,
"user_cardname": component.user_cardname,
"message_id": component.message_id,
"content": [PluginMessageUtils._component_to_dict(item) for item in component.content],
}
@staticmethod
def _message_sequence_from_dict(raw_message_data: List[Dict[str, Any]]) -> MessageSequence:
"""从插件运行时字典结构恢复消息组件序列。
Args:
raw_message_data: 插件运行时消息段字典列表。
Returns:
MessageSequence: 恢复后的消息组件序列。
"""
components = [PluginMessageUtils._component_from_dict(item) for item in raw_message_data]
return MessageSequence(components=components)
@staticmethod
def _component_from_dict(item: Dict[str, Any]) -> StandardMessageComponents:
"""从插件运行时字典结构恢复单个消息组件。
Args:
item: 单个消息组件的字典表示。
Returns:
StandardMessageComponents: 恢复后的内部消息组件对象。
"""
item_type = str(item.get("type") or "").strip()
if item_type == "text":
return TextComponent(text=str(item.get("data") or ""))
if item_type == "image":
return PluginMessageUtils._build_binary_component(ImageComponent, item)
if item_type == "emoji":
return PluginMessageUtils._build_binary_component(EmojiComponent, item)
if item_type == "voice":
return PluginMessageUtils._build_binary_component(VoiceComponent, item)
if item_type == "at":
item_data = item.get("data", {})
if not isinstance(item_data, dict):
item_data = {}
return AtComponent(
target_user_id=str(item_data.get("target_user_id") or ""),
target_user_nickname=PluginMessageUtils._normalize_optional_string(item_data.get("target_user_nickname")),
target_user_cardname=PluginMessageUtils._normalize_optional_string(item_data.get("target_user_cardname")),
)
if item_type == "reply":
reply_data = item.get("data")
if isinstance(reply_data, dict):
return ReplyComponent(
target_message_id=str(reply_data.get("target_message_id") or ""),
target_message_content=PluginMessageUtils._normalize_optional_string(
reply_data.get("target_message_content")
),
target_message_sender_id=PluginMessageUtils._normalize_optional_string(
reply_data.get("target_message_sender_id")
),
target_message_sender_nickname=PluginMessageUtils._normalize_optional_string(
reply_data.get("target_message_sender_nickname")
),
target_message_sender_cardname=PluginMessageUtils._normalize_optional_string(
reply_data.get("target_message_sender_cardname")
),
)
return ReplyComponent(target_message_id=str(reply_data or ""))
if item_type == "forward":
forward_nodes: List[ForwardComponent] = []
raw_forward_nodes = item.get("data", [])
if isinstance(raw_forward_nodes, list):
for node in raw_forward_nodes:
if not isinstance(node, dict):
continue
raw_content = node.get("content", [])
node_components: List[StandardMessageComponents] = []
if isinstance(raw_content, list):
node_components = [
PluginMessageUtils._component_from_dict(content)
for content in raw_content
if isinstance(content, dict)
]
if not node_components:
node_components = [TextComponent(text="[empty forward node]")]
forward_nodes.append(
ForwardComponent(
user_nickname=str(node.get("user_nickname") or "未知用户"),
user_id=PluginMessageUtils._normalize_optional_string(node.get("user_id")),
user_cardname=PluginMessageUtils._normalize_optional_string(node.get("user_cardname")),
message_id=str(node.get("message_id") or ""),
content=node_components,
)
)
if not forward_nodes:
return DictComponent(data={"type": "forward", "data": item.get("data", [])})
return ForwardNodeComponent(forward_components=forward_nodes)
component_data = item.get("data")
if isinstance(component_data, dict):
return DictComponent(data=component_data)
return DictComponent(data=item)
@staticmethod
def _build_binary_component(component_cls: Any, item: Dict[str, Any]) -> StandardMessageComponents:
"""从字典构造带二进制负载的消息组件。
Args:
component_cls: 目标组件类型。
item: 消息组件字典。
Returns:
StandardMessageComponents: 构造后的组件对象。
"""
content = str(item.get("data") or "")
binary_hash = str(item.get("hash") or "")
raw_binary_base64 = item.get("binary_data_base64")
binary_data = b""
if isinstance(raw_binary_base64, str) and raw_binary_base64:
try:
binary_data = base64.b64decode(raw_binary_base64)
except Exception:
binary_data = b""
if not binary_hash and binary_data:
binary_hash = hashlib.sha256(binary_data).hexdigest()
return component_cls(binary_hash=binary_hash, content=content, binary_data=binary_data)
@staticmethod
def _normalize_optional_string(value: Any) -> Optional[str]:
"""将任意值规范化为可选字符串。
Args:
value: 待规范化的值。
Returns:
Optional[str]: 规范化后的字符串;若值为空则返回 ``None``。
"""
if value is None:
return None
normalized_value = str(value)
return normalized_value if normalized_value else None
@staticmethod @staticmethod
def _message_info_to_dict(message_info: MessageInfo) -> MessageInfoDict: def _message_info_to_dict(message_info: MessageInfo) -> MessageInfoDict:
""" """
@@ -92,7 +352,7 @@ class PluginMessageUtils:
timestamp=str(session_message.timestamp.timestamp()), # 转换为时间戳字符串 timestamp=str(session_message.timestamp.timestamp()), # 转换为时间戳字符串
platform=session_message.platform, platform=session_message.platform,
message_info=PluginMessageUtils._message_info_to_dict(session_message.message_info), message_info=PluginMessageUtils._message_info_to_dict(session_message.message_info),
raw_message=session_message.raw_message.to_dict(), # 复用 MessageSequence.to_dict() raw_message=PluginMessageUtils._message_sequence_to_dict(session_message.raw_message),
is_mentioned=session_message.is_mentioned, is_mentioned=session_message.is_mentioned,
is_at=session_message.is_at, is_at=session_message.is_at,
is_emoji=session_message.is_emoji, is_emoji=session_message.is_emoji,
@@ -186,7 +446,7 @@ class PluginMessageUtils:
# 构建原始消息组件序列(复用 MessageSequence.from_dict 方法) # 构建原始消息组件序列(复用 MessageSequence.from_dict 方法)
raw_message_data = message_dict["raw_message"] raw_message_data = message_dict["raw_message"]
if isinstance(raw_message_data, list): if isinstance(raw_message_data, list):
session_message.raw_message = MessageSequence.from_dict(raw_message_data) session_message.raw_message = PluginMessageUtils._message_sequence_from_dict(raw_message_data)
else: else:
raise ValueError("消息字典中 'raw_message' 字段必须是一个列表") raise ValueError("消息字典中 'raw_message' 字段必须是一个列表")

View File

@@ -0,0 +1 @@
"""NapCat 内置适配器插件包。"""

View File

@@ -0,0 +1,414 @@
"""NapCat 入站消息编解码。"""
from typing import Any, Dict, List, Mapping, Optional, Tuple
from uuid import uuid4
import hashlib
import json
import time
from napcat_adapter.qq_queries import NapCatQueryService
class NapCatInboundCodec:
"""NapCat 入站消息编码器。"""
def __init__(self, logger: Any, query_service: NapCatQueryService) -> None:
"""初始化入站消息编码器。
Args:
logger: 插件日志对象。
query_service: QQ 查询服务。
"""
self._logger = logger
self._query_service = query_service
async def build_message_dict(
self,
payload: Mapping[str, Any],
self_id: str,
sender_user_id: str,
sender: Mapping[str, Any],
) -> Dict[str, Any]:
"""构造 Host 侧可接受的 ``MessageDict``。
Args:
payload: NapCat 原始消息事件。
self_id: 当前机器人账号 ID。
sender_user_id: 发送者用户 ID。
sender: 发送者信息字典。
Returns:
Dict[str, Any]: 规范化后的 ``MessageDict``。
"""
message_type = str(payload.get("message_type") or "").strip() or "private"
group_id = str(payload.get("group_id") or "").strip()
group_name = str(payload.get("group_name") or "").strip() or (f"group_{group_id}" if group_id else "")
user_nickname = str(sender.get("nickname") or sender.get("card") or sender_user_id).strip() or sender_user_id
user_cardname = str(sender.get("card") or "").strip() or None
raw_message, is_at = await self.convert_segments(payload, self_id)
raw_message_text = str(payload.get("raw_message") or "").strip()
if not raw_message:
raw_message = [{"type": "text", "data": raw_message_text or "[unsupported]"}]
plain_text = self.build_plain_text(raw_message, raw_message_text)
timestamp_seconds = payload.get("time")
if not isinstance(timestamp_seconds, (int, float)):
timestamp_seconds = time.time()
additional_config: Dict[str, Any] = {"self_id": self_id, "napcat_message_type": message_type}
if group_id:
additional_config["platform_io_target_group_id"] = group_id
else:
additional_config["platform_io_target_user_id"] = sender_user_id
message_info: Dict[str, Any] = {
"user_info": {
"user_id": sender_user_id,
"user_nickname": user_nickname,
"user_cardname": user_cardname,
},
"additional_config": additional_config,
}
if group_id:
message_info["group_info"] = {"group_id": group_id, "group_name": group_name}
message_id = str(payload.get("message_id") or f"napcat-{uuid4().hex}").strip()
return {
"message_id": message_id,
"timestamp": str(float(timestamp_seconds)),
"platform": "qq",
"message_info": message_info,
"raw_message": raw_message,
"is_mentioned": is_at,
"is_at": is_at,
"is_emoji": False,
"is_picture": False,
"is_command": plain_text.startswith("/"),
"is_notify": False,
"session_id": "",
"processed_plain_text": plain_text,
"display_message": plain_text,
}
async def convert_segments(self, payload: Mapping[str, Any], self_id: str) -> Tuple[List[Dict[str, Any]], bool]:
"""将 OneBot 消息段转换为 Host 消息段结构。
Args:
payload: OneBot 原始消息事件。
self_id: 当前机器人账号 ID。
Returns:
Tuple[List[Dict[str, Any]], bool]: 转换后的消息段列表,以及是否 @ 到当前机器人。
"""
message_payload = payload.get("message")
if isinstance(message_payload, str):
normalized_text = message_payload.strip()
return ([{"type": "text", "data": normalized_text}] if normalized_text else []), False
if not isinstance(message_payload, list):
return [], False
converted_segments: List[Dict[str, Any]] = []
is_at = False
for segment in message_payload:
if not isinstance(segment, Mapping):
continue
segment_type = str(segment.get("type") or "").strip()
segment_data = segment.get("data", {})
if not isinstance(segment_data, Mapping):
segment_data = {}
if segment_type == "text":
if text_value := str(segment_data.get("text") or ""):
converted_segments.append({"type": "text", "data": text_value})
continue
if segment_type == "at":
if target_user_id := str(segment_data.get("qq") or "").strip():
converted_segments.append(
{
"type": "at",
"data": {
"target_user_id": target_user_id,
"target_user_nickname": None,
"target_user_cardname": None,
},
}
)
if self_id and target_user_id == self_id:
is_at = True
continue
if segment_type == "reply":
if reply_segment := await self._build_reply_segment(segment_data):
converted_segments.append(reply_segment)
continue
if segment_type == "face":
converted_segments.append({"type": "text", "data": "[face]"})
continue
if segment_type == "image":
converted_segments.append(await self._build_image_like_segment(segment_data, is_emoji=False))
continue
if segment_type == "record":
converted_segments.append(await self._build_record_segment(segment_data))
continue
if segment_type == "video":
converted_segments.append({"type": "text", "data": "[video]"})
continue
if segment_type == "file":
converted_segments.append({"type": "text", "data": "[file]"})
continue
if segment_type == "json":
converted_segments.append(self._build_json_text_segment(segment_data))
continue
if segment_type == "forward":
if forward_segment := await self._build_forward_segment(segment_data):
converted_segments.append(forward_segment)
continue
if segment_type in {"xml", "share"}:
converted_segments.append({"type": "text", "data": f"[{segment_type}]"})
return converted_segments, is_at
async def _build_reply_segment(self, segment_data: Mapping[str, Any]) -> Optional[Dict[str, Any]]:
"""构造回复消息段。
Args:
segment_data: OneBot ``reply`` 段的 ``data`` 字典。
Returns:
Optional[Dict[str, Any]]: 转换后的回复消息段;缺少消息 ID 时返回 ``None``。
"""
target_message_id = str(segment_data.get("id") or "").strip()
if not target_message_id:
return None
message_detail = await self._query_service.get_message_detail(target_message_id)
reply_payload: Dict[str, Any] = {"target_message_id": target_message_id}
if message_detail is not None:
sender = message_detail.get("sender", {})
if not isinstance(sender, Mapping):
sender = {}
reply_payload["target_message_content"] = str(message_detail.get("raw_message") or "").strip() or None
reply_payload["target_message_sender_id"] = str(
message_detail.get("user_id") or sender.get("user_id") or ""
).strip() or None
reply_payload["target_message_sender_nickname"] = str(sender.get("nickname") or "").strip() or None
reply_payload["target_message_sender_cardname"] = str(sender.get("card") or "").strip() or None
return {"type": "reply", "data": reply_payload}
async def _build_image_like_segment(
self,
segment_data: Mapping[str, Any],
is_emoji: bool,
) -> Dict[str, Any]:
"""构造图片或表情消息段。
Args:
segment_data: OneBot ``image`` 段的 ``data`` 字典。
is_emoji: 是否按表情组件处理。
Returns:
Dict[str, Any]: 转换后的图片或表情消息段。
"""
subtype = segment_data.get("sub_type")
actual_is_emoji = is_emoji or (isinstance(subtype, int) and subtype not in {0, 4, 9})
image_url = str(segment_data.get("url") or "").strip()
binary_data = await self._query_service.download_binary(image_url)
if not binary_data:
return {"type": "text", "data": "[emoji]" if actual_is_emoji else "[image]"}
return {
"type": "emoji" if actual_is_emoji else "image",
"data": "",
"hash": hashlib.sha256(binary_data).hexdigest(),
"binary_data_base64": self._encode_binary(binary_data),
}
async def _build_record_segment(self, segment_data: Mapping[str, Any]) -> Dict[str, Any]:
"""构造语音消息段。
Args:
segment_data: OneBot ``record`` 段的 ``data`` 字典。
Returns:
Dict[str, Any]: 转换后的语音或占位文本消息段。
"""
file_name = str(segment_data.get("file") or "").strip()
file_id = str(segment_data.get("file_id") or "").strip() or None
if not file_name:
return {"type": "text", "data": "[voice]"}
record_detail = await self._query_service.get_record_detail(file_name=file_name, file_id=file_id)
if record_detail is None:
return {"type": "text", "data": "[voice]"}
record_base64 = str(record_detail.get("base64") or "").strip()
if not record_base64:
return {"type": "text", "data": "[voice]"}
try:
binary_data = self._decode_binary(record_base64)
except Exception:
return {"type": "text", "data": "[voice]"}
return {
"type": "voice",
"data": "",
"hash": hashlib.sha256(binary_data).hexdigest(),
"binary_data_base64": self._encode_binary(binary_data),
}
async def _build_forward_segment(self, segment_data: Mapping[str, Any]) -> Optional[Dict[str, Any]]:
"""构造合并转发消息段。
Args:
segment_data: OneBot ``forward`` 段的 ``data`` 字典。
Returns:
Optional[Dict[str, Any]]: 转换后的合并转发消息段;失败时返回 ``None``。
"""
message_id = str(segment_data.get("id") or "").strip()
if not message_id:
return None
forward_detail = await self._query_service.get_forward_message(message_id)
if forward_detail is None:
return {"type": "text", "data": "[forward]"}
messages = forward_detail.get("messages", [])
if not isinstance(messages, list):
return {"type": "text", "data": "[forward]"}
forward_nodes: List[Dict[str, Any]] = []
for forward_message in messages:
if not isinstance(forward_message, Mapping):
continue
raw_content = forward_message.get("content", [])
content_segments = await self._convert_forward_content(raw_content, "")
sender = forward_message.get("sender", {})
if not isinstance(sender, Mapping):
sender = {}
forward_nodes.append(
{
"user_id": str(sender.get("user_id") or sender.get("uin") or "").strip() or None,
"user_nickname": str(sender.get("nickname") or sender.get("name") or "未知用户"),
"user_cardname": str(sender.get("card") or "").strip() or None,
"message_id": str(forward_message.get("message_id") or uuid4().hex),
"content": content_segments or [{"type": "text", "data": "[empty]"}],
}
)
if not forward_nodes:
return {"type": "text", "data": "[forward]"}
return {"type": "forward", "data": forward_nodes}
async def _convert_forward_content(self, raw_content: Any, self_id: str) -> List[Dict[str, Any]]:
"""转换转发节点内部的消息段列表。
Args:
raw_content: 转发节点原始内容。
self_id: 当前机器人账号 ID。
Returns:
List[Dict[str, Any]]: 转换后的消息段列表。
"""
pseudo_payload: Dict[str, Any] = {"message": raw_content}
segments, _ = await self.convert_segments(pseudo_payload, self_id)
return segments
def _build_json_text_segment(self, segment_data: Mapping[str, Any]) -> Dict[str, Any]:
"""将 JSON 卡片最佳努力转换为文本占位。
Args:
segment_data: OneBot ``json`` 段的 ``data`` 字典。
Returns:
Dict[str, Any]: 转换后的文本消息段。
"""
json_data = str(segment_data.get("data") or "").strip()
if not json_data:
return {"type": "text", "data": "[json]"}
try:
parsed_json = json.loads(json_data)
except Exception:
return {"type": "text", "data": "[json]"}
app_name = str(parsed_json.get("app") or "").strip()
prompt = ""
if isinstance(parsed_json.get("meta"), Mapping):
prompt = str(parsed_json["meta"].get("prompt") or "").strip()
text = prompt or app_name or "json"
return {"type": "text", "data": f"[json:{text}]"}
@staticmethod
def _encode_binary(binary_data: bytes) -> str:
"""将二进制内容编码为 Base64 字符串。
Args:
binary_data: 待编码的二进制内容。
Returns:
str: Base64 编码字符串。
"""
import base64
return base64.b64encode(binary_data).decode("utf-8")
@staticmethod
def _decode_binary(binary_base64: str) -> bytes:
"""将 Base64 字符串解码为二进制内容。
Args:
binary_base64: Base64 字符串。
Returns:
bytes: 解码后的二进制内容。
"""
import base64
return base64.b64decode(binary_base64)
def build_plain_text(self, raw_message: List[Dict[str, Any]], fallback_text: str) -> str:
"""从标准消息段中提取可展示的纯文本。
Args:
raw_message: 标准化后的消息段列表。
fallback_text: 当无法拼出文本时使用的回退文本。
Returns:
str: 用于 Host 展示和命令判断的纯文本内容。
"""
plain_text_parts: List[str] = []
for item in raw_message:
if not isinstance(item, Mapping):
continue
item_type = str(item.get("type") or "").strip()
item_data = item.get("data")
if item_type == "text":
plain_text_parts.append(str(item_data or ""))
elif item_type == "at" and isinstance(item_data, Mapping):
plain_text_parts.append(f"@{item_data.get('target_user_id') or ''}")
elif item_type == "reply":
plain_text_parts.append("[reply]")
elif item_type == "forward":
plain_text_parts.append("[forward]")
elif item_type in {"image", "emoji", "voice"}:
plain_text_parts.append(f"[{item_type}]")
plain_text = "".join(part for part in plain_text_parts if part).strip()
return plain_text or fallback_text or "[unsupported]"

View File

@@ -0,0 +1,192 @@
"""NapCat 出站消息编解码。"""
from typing import Any, Dict, List, Mapping, Tuple
class NapCatOutboundCodec:
"""NapCat 出站消息编码器。"""
def build_outbound_action(
self,
message: Mapping[str, Any],
route: Mapping[str, Any],
) -> Tuple[str, Dict[str, Any]]:
"""为 Host 出站消息构造 OneBot 动作。
Args:
message: Host 侧标准 ``MessageDict``。
route: Platform IO 路由信息。
Returns:
Tuple[str, Dict[str, Any]]: 动作名称与参数字典。
Raises:
ValueError: 当私聊出站缺少目标用户 ID 时抛出。
"""
message_info = message.get("message_info", {})
if not isinstance(message_info, Mapping):
message_info = {}
group_info = message_info.get("group_info", {})
if not isinstance(group_info, Mapping):
group_info = {}
additional_config = message_info.get("additional_config", {})
if not isinstance(additional_config, Mapping):
additional_config = {}
raw_message = message.get("raw_message", [])
segments = self.convert_segments(raw_message)
if target_group_id := str(
group_info.get("group_id") or additional_config.get("platform_io_target_group_id") or ""
).strip():
return "send_group_msg", {"group_id": target_group_id, "message": segments}
target_user_id = str(
additional_config.get("platform_io_target_user_id")
or additional_config.get("target_user_id")
or route.get("target_user_id")
or ""
).strip()
if not target_user_id:
raise ValueError("Outbound private message is missing target_user_id")
return "send_private_msg", {"message": segments, "user_id": target_user_id}
def convert_segments(self, raw_message: Any) -> List[Dict[str, Any]]:
"""将 Host 消息段转换为 OneBot 消息段。
Args:
raw_message: Host 侧 ``raw_message`` 字段。
Returns:
List[Dict[str, Any]]: OneBot 消息段列表。
"""
if not isinstance(raw_message, list):
return [{"type": "text", "data": {"text": ""}}]
outbound_segments: List[Dict[str, Any]] = []
for item in raw_message:
if not isinstance(item, Mapping):
continue
item_type = str(item.get("type") or "").strip()
item_data = item.get("data")
if item_type == "text":
text_value = str(item_data or "")
outbound_segments.append({"type": "text", "data": {"text": text_value}})
continue
if item_type == "at" and isinstance(item_data, Mapping):
if target_user_id := str(item_data.get("target_user_id") or "").strip():
outbound_segments.append({"type": "at", "data": {"qq": target_user_id}})
continue
if item_type == "reply":
if isinstance(item_data, Mapping):
target_message_id = str(item_data.get("target_message_id") or "").strip()
else:
target_message_id = str(item_data or "").strip()
if target_message_id:
outbound_segments.append({"type": "reply", "data": {"id": target_message_id}})
continue
if item_type == "image":
binary_base64 = str(item.get("binary_data_base64") or "").strip()
if binary_base64:
outbound_segments.append(
{
"type": "image",
"data": {"file": f"base64://{binary_base64}", "subtype": 0},
}
)
else:
outbound_segments.append({"type": "text", "data": {"text": "[image]"}})
continue
if item_type == "emoji":
binary_base64 = str(item.get("binary_data_base64") or "").strip()
if binary_base64:
outbound_segments.append(
{
"type": "image",
"data": {
"file": f"base64://{binary_base64}",
"subtype": 1,
"summary": "[动画表情]",
},
}
)
else:
outbound_segments.append({"type": "text", "data": {"text": "[emoji]"}})
continue
if item_type == "voice":
binary_base64 = str(item.get("binary_data_base64") or "").strip()
if binary_base64:
outbound_segments.append({"type": "record", "data": {"file": f"base64://{binary_base64}"}})
else:
outbound_segments.append({"type": "text", "data": {"text": "[voice]"}})
continue
if item_type == "forward" and isinstance(item_data, list):
outbound_segments.extend(self._build_forward_nodes(item_data))
continue
if item_type == "dict" and isinstance(item_data, Mapping):
if dict_segment := self._build_dict_component_segment(item_data):
outbound_segments.append(dict_segment)
continue
fallback_text = f"[unsupported:{item_type or 'unknown'}]"
outbound_segments.append({"type": "text", "data": {"text": fallback_text}})
if not outbound_segments:
outbound_segments.append({"type": "text", "data": {"text": ""}})
return outbound_segments
def _build_forward_nodes(self, forward_nodes: List[Any]) -> List[Dict[str, Any]]:
"""构造 NapCat 转发节点列表。
Args:
forward_nodes: 内部转发节点列表。
Returns:
List[Dict[str, Any]]: NapCat 转发节点列表。
"""
built_nodes: List[Dict[str, Any]] = []
for node in forward_nodes:
if not isinstance(node, Mapping):
continue
raw_content = node.get("content", [])
node_segments = self.convert_segments(raw_content)
built_nodes.append(
{
"type": "node",
"data": {
"name": str(node.get("user_nickname") or node.get("user_cardname") or "QQ用户"),
"uin": str(node.get("user_id") or ""),
"content": node_segments,
},
}
)
return built_nodes
def _build_dict_component_segment(self, item_data: Mapping[str, Any]) -> Dict[str, Any]:
"""尽力将 ``DictComponent`` 转换为 NapCat 消息段。
Args:
item_data: ``DictComponent`` 原始数据。
Returns:
Dict[str, Any]: NapCat 消息段;不支持时返回占位文本段。
"""
raw_type = str(item_data.get("type") or "").strip()
raw_payload = item_data.get("data", item_data)
if raw_type in {"file", "music", "video", "face"} and isinstance(raw_payload, Mapping):
return {"type": raw_type, "data": dict(raw_payload)}
if raw_type in {"image", "record", "reply", "at"} and isinstance(raw_payload, Mapping):
return {"type": raw_type, "data": dict(raw_payload)}
return {"type": "text", "data": {"text": f"[unsupported:{raw_type or 'dict'}]"}}

View File

@@ -0,0 +1,398 @@
"""NapCat 内置适配器配置解析。"""
from dataclasses import dataclass, field
from typing import Any, Dict, Mapping, Optional, Set, Tuple
from urllib.parse import urlparse
from napcat_adapter.constants import (
DEFAULT_ACTION_TIMEOUT_SEC,
DEFAULT_CHAT_LIST_TYPE,
DEFAULT_HEARTBEAT_INTERVAL_SEC,
DEFAULT_NAPCAT_HOST,
DEFAULT_NAPCAT_PORT,
DEFAULT_RECONNECT_DELAY_SEC,
SUPPORTED_CONFIG_VERSION,
)
@dataclass(frozen=True)
class NapCatPluginOptions:
"""插件级配置。"""
enabled: bool = False
config_version: str = ""
def should_connect(self) -> bool:
"""判断当前配置下是否应当启动连接。
Returns:
bool: 若插件连接已启用,则返回 ``True``。
"""
return self.enabled
@dataclass(frozen=True)
class NapCatServerConfig:
"""NapCat 正向 WebSocket 连接配置。"""
host: str = DEFAULT_NAPCAT_HOST
port: int = DEFAULT_NAPCAT_PORT
token: str = ""
heartbeat_interval: float = DEFAULT_HEARTBEAT_INTERVAL_SEC
reconnect_delay_sec: float = DEFAULT_RECONNECT_DELAY_SEC
action_timeout_sec: float = DEFAULT_ACTION_TIMEOUT_SEC
connection_id: str = ""
def build_ws_url(self) -> str:
"""构造正向 WebSocket 地址。
Returns:
str: 供适配器作为客户端连接的 NapCat WebSocket 地址。
"""
return f"ws://{self.host}:{self.port}"
@dataclass(frozen=True)
class NapCatChatConfig:
"""聊天名单配置。"""
group_list_type: str = DEFAULT_CHAT_LIST_TYPE
group_list: Set[str] = field(default_factory=set)
private_list_type: str = DEFAULT_CHAT_LIST_TYPE
private_list: Set[str] = field(default_factory=set)
ban_user_id: Set[str] = field(default_factory=set)
@dataclass(frozen=True)
class NapCatFilterConfig:
"""消息过滤配置。"""
ignore_self_message: bool = True
@dataclass(frozen=True)
class NapCatPluginSettings:
"""NapCat 插件完整配置。"""
plugin: NapCatPluginOptions = field(default_factory=NapCatPluginOptions)
napcat_server: NapCatServerConfig = field(default_factory=NapCatServerConfig)
chat: NapCatChatConfig = field(default_factory=NapCatChatConfig)
filters: NapCatFilterConfig = field(default_factory=NapCatFilterConfig)
@classmethod
def from_mapping(cls, raw_config: Mapping[str, Any], logger: Any) -> "NapCatPluginSettings":
"""从 Runner 注入的原始配置字典解析插件配置。
Args:
raw_config: Runner 注入的原始配置内容。
logger: 插件日志对象。
Returns:
NapCatPluginSettings: 规范化后的插件配置。
"""
plugin_section = _as_mapping(raw_config.get("plugin"))
server_section = _as_mapping(raw_config.get("napcat_server"))
legacy_connection_section = _as_mapping(raw_config.get("connection"))
chat_section = _as_mapping(raw_config.get("chat"))
filters_section = _as_mapping(raw_config.get("filters"))
if not server_section and legacy_connection_section:
logger.warning("NapCat 适配器检测到旧版 [connection] 配置段,请尽快迁移到 [napcat_server]")
server_section = legacy_connection_section
legacy_host, legacy_port = _read_legacy_host_port(server_section, legacy_connection_section, logger)
parsed_host = _read_string(server_section, "host") or legacy_host or DEFAULT_NAPCAT_HOST
parsed_port = _read_positive_int(
mapping=server_section,
key="port",
default=legacy_port or DEFAULT_NAPCAT_PORT,
logger=logger,
setting_name="napcat_server.port",
)
return cls(
plugin=NapCatPluginOptions(
enabled=_read_bool(plugin_section, "enabled", False),
config_version=_read_string(plugin_section, "config_version"),
),
napcat_server=NapCatServerConfig(
host=parsed_host,
port=parsed_port,
token=_read_string(server_section, "token") or _read_string(server_section, "access_token"),
heartbeat_interval=_read_positive_float(
mapping=server_section,
key="heartbeat_interval",
default=_read_positive_float(
mapping=server_section,
key="heartbeat_sec",
default=DEFAULT_HEARTBEAT_INTERVAL_SEC,
logger=logger,
setting_name="napcat_server.heartbeat_interval",
),
logger=logger,
setting_name="napcat_server.heartbeat_interval",
),
reconnect_delay_sec=_read_positive_float(
mapping=server_section,
key="reconnect_delay_sec",
default=DEFAULT_RECONNECT_DELAY_SEC,
logger=logger,
setting_name="napcat_server.reconnect_delay_sec",
),
action_timeout_sec=_read_positive_float(
mapping=server_section,
key="action_timeout_sec",
default=DEFAULT_ACTION_TIMEOUT_SEC,
logger=logger,
setting_name="napcat_server.action_timeout_sec",
),
connection_id=_read_string(server_section, "connection_id"),
),
chat=NapCatChatConfig(
group_list_type=_read_list_mode(
mapping=chat_section,
key="group_list_type",
default=DEFAULT_CHAT_LIST_TYPE,
logger=logger,
setting_name="chat.group_list_type",
),
group_list=_read_string_set(chat_section, "group_list"),
private_list_type=_read_list_mode(
mapping=chat_section,
key="private_list_type",
default=DEFAULT_CHAT_LIST_TYPE,
logger=logger,
setting_name="chat.private_list_type",
),
private_list=_read_string_set(chat_section, "private_list"),
ban_user_id=_read_string_set(chat_section, "ban_user_id"),
),
filters=NapCatFilterConfig(
ignore_self_message=_read_bool(filters_section, "ignore_self_message", True),
),
)
def should_connect(self) -> bool:
"""判断当前配置下是否应当启动连接。
Returns:
bool: 若插件连接已启用,则返回 ``True``。
"""
return self.plugin.should_connect()
def validate(self, logger: Any) -> bool:
"""校验当前配置是否满足启动连接的前提条件。
Args:
logger: 插件日志对象。
Returns:
bool: 若配置满足启动连接的前提条件,则返回 ``True``。
"""
config_version = self.plugin.config_version
if not config_version:
logger.error(
f"NapCat 适配器配置缺少 plugin.config_version当前插件要求版本 {SUPPORTED_CONFIG_VERSION}"
)
return False
if config_version != SUPPORTED_CONFIG_VERSION:
logger.error(
"NapCat 适配器配置版本不兼容: "
f"当前为 {config_version},当前插件要求 {SUPPORTED_CONFIG_VERSION}"
)
return False
if not self.napcat_server.host:
logger.warning("NapCat 适配器已启用,但 napcat_server.host 为空")
return False
if self.napcat_server.port <= 0:
logger.warning("NapCat 适配器已启用,但 napcat_server.port 不是正整数")
return False
return True
def _as_mapping(value: Any) -> Dict[str, Any]:
"""将任意值安全转换为字典。
Args:
value: 待转换的值。
Returns:
Dict[str, Any]: 若原值是映射,则返回普通字典;否则返回空字典。
"""
return dict(value) if isinstance(value, Mapping) else {}
def _read_bool(mapping: Mapping[str, Any], key: str, default: bool) -> bool:
"""安全读取布尔配置值。
Args:
mapping: 待读取的配置字典。
key: 目标键名。
default: 读取失败时的默认值。
Returns:
bool: 解析后的布尔值。
"""
value = mapping.get(key, default)
return value if isinstance(value, bool) else default
def _read_string(mapping: Mapping[str, Any], key: str) -> str:
"""安全读取字符串配置值。
Args:
mapping: 待读取的配置字典。
key: 目标键名。
Returns:
str: 去除首尾空白后的字符串值。
"""
value = mapping.get(key)
return "" if value is None else str(value).strip()
def _read_positive_float(
mapping: Mapping[str, Any],
key: str,
default: float,
logger: Any,
setting_name: str,
) -> float:
"""安全读取正浮点数配置值。
Args:
mapping: 待读取的配置字典。
key: 目标键名。
default: 读取失败时的默认值。
logger: 插件日志对象。
setting_name: 用于日志输出的完整配置名。
Returns:
float: 合法的正浮点数;否则返回默认值。
"""
value = mapping.get(key, default)
if isinstance(value, (int, float)) and float(value) > 0:
return float(value)
if key in mapping:
logger.warning(f"NapCat 适配器配置项取值无效,已回退到默认值: {setting_name}={value!r},默认值为 {default}")
return default
def _read_positive_int(
mapping: Mapping[str, Any],
key: str,
default: int,
logger: Any,
setting_name: str,
) -> int:
"""安全读取正整数配置值。
Args:
mapping: 待读取的配置字典。
key: 目标键名。
default: 读取失败时的默认值。
logger: 插件日志对象。
setting_name: 用于日志输出的完整配置名。
Returns:
int: 合法的正整数;否则返回默认值。
"""
value = mapping.get(key, default)
if isinstance(value, int) and value > 0:
return value
if isinstance(value, str) and value.isdigit() and int(value) > 0:
return int(value)
if key in mapping:
logger.warning(f"NapCat 适配器配置项取值无效,已回退到默认值: {setting_name}={value!r},默认值为 {default}")
return default
def _read_list_mode(
mapping: Mapping[str, Any],
key: str,
default: str,
logger: Any,
setting_name: str,
) -> str:
"""安全读取名单模式配置值。
Args:
mapping: 待读取的配置字典。
key: 目标键名。
default: 读取失败时的默认值。
logger: 插件日志对象。
setting_name: 用于日志输出的完整配置名。
Returns:
str: 合法的名单模式字符串。
"""
value = mapping.get(key, default)
if isinstance(value, str):
normalized_value = value.strip()
if normalized_value in {"whitelist", "blacklist"}:
return normalized_value
if key in mapping:
logger.warning(f"NapCat 适配器配置项取值无效,已回退到默认值: {setting_name}={value!r},默认值为 {default}")
return default
def _read_string_set(mapping: Mapping[str, Any], key: str) -> Set[str]:
"""安全读取字符串集合配置值。
Args:
mapping: 待读取的配置字典。
key: 目标键名。
Returns:
Set[str]: 规范化后的字符串集合。
"""
value = mapping.get(key, [])
if not isinstance(value, list):
return set()
normalized_values: Set[str] = set()
for item in value:
item_text = "" if item is None else str(item).strip()
if item_text:
normalized_values.add(item_text)
return normalized_values
def _read_legacy_host_port(
server_section: Mapping[str, Any],
legacy_connection_section: Mapping[str, Any],
logger: Any,
) -> Tuple[str, Optional[int]]:
"""从旧版 ``ws_url`` 配置中提取主机与端口。
Args:
server_section: 新版 ``napcat_server`` 配置段。
legacy_connection_section: 旧版 ``connection`` 配置段。
logger: 插件日志对象。
Returns:
Tuple[str, Optional[int]]: 解析到的主机与端口;若未找到,则返回空主机与 ``None``。
"""
legacy_ws_url = _read_string(server_section, "ws_url") or _read_string(legacy_connection_section, "ws_url")
if not legacy_ws_url:
return "", None
parsed_url = urlparse(legacy_ws_url)
parsed_host = parsed_url.hostname or ""
parsed_port = parsed_url.port
logger.warning(
"NapCat 适配器检测到旧版 ws_url 配置,已临时兼容解析,请尽快迁移到 napcat_server.host/port"
)
if parsed_url.path not in {"", "/"}:
logger.warning("NapCat 适配器旧版 ws_url 包含路径,新的 napcat_server 配置不会保留该路径")
return parsed_host, parsed_port

View File

@@ -0,0 +1,9 @@
"""NapCat 内置适配器共享常量。"""
SUPPORTED_CONFIG_VERSION = "0.1.0"
DEFAULT_NAPCAT_HOST = "127.0.0.1"
DEFAULT_NAPCAT_PORT = 3001
DEFAULT_RECONNECT_DELAY_SEC = 5.0
DEFAULT_HEARTBEAT_INTERVAL_SEC = 30.0
DEFAULT_ACTION_TIMEOUT_SEC = 15.0
DEFAULT_CHAT_LIST_TYPE = "whitelist"

View File

@@ -0,0 +1,68 @@
"""NapCat 入站消息过滤。"""
from typing import Any, Set
from napcat_adapter.config import NapCatChatConfig
class NapCatChatFilter:
"""NapCat 聊天名单过滤器。"""
def __init__(self, logger: Any) -> None:
"""初始化聊天名单过滤器。
Args:
logger: 插件日志对象。
"""
self._logger = logger
def is_inbound_chat_allowed(
self,
sender_user_id: str,
group_id: str,
chat_config: NapCatChatConfig,
) -> bool:
"""检查入站消息是否通过聊天名单过滤。
Args:
sender_user_id: 发送者用户 ID。
group_id: 群聊 ID私聊时为空字符串。
chat_config: 当前生效的聊天配置。
Returns:
bool: 若消息允许继续进入 Host则返回 ``True``。
"""
if sender_user_id in chat_config.ban_user_id:
self._logger.warning(f"NapCat 用户 {sender_user_id} 在全局禁止名单中,消息被丢弃")
return False
if group_id:
if not self._is_id_allowed_by_list_policy(group_id, chat_config.group_list_type, chat_config.group_list):
self._logger.warning(f"NapCat 群聊 {group_id} 未通过聊天名单过滤,消息被丢弃")
return False
return True
if not self._is_id_allowed_by_list_policy(
sender_user_id,
chat_config.private_list_type,
chat_config.private_list,
):
self._logger.warning(f"NapCat 私聊用户 {sender_user_id} 未通过聊天名单过滤,消息被丢弃")
return False
return True
@staticmethod
def _is_id_allowed_by_list_policy(target_id: str, list_type: str, configured_ids: Set[str]) -> bool:
"""根据白名单或黑名单规则判断目标 ID 是否允许通过。
Args:
target_id: 待检查的目标 ID。
list_type: 名单模式,仅支持 ``whitelist`` 或 ``blacklist``。
configured_ids: 配置中的 ID 集合。
Returns:
bool: 若目标 ID 允许通过,则返回 ``True``。
"""
if list_type == "whitelist":
return target_id in configured_ids
return target_id not in configured_ids

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,224 @@
"""NapCat QQ 平台通知与元事件处理。"""
from typing import Any, Dict, Mapping, Optional
from uuid import uuid4
import time
from napcat_adapter.qq_queries import NapCatQueryService
class NapCatNoticeCodec:
"""NapCat QQ 通知事件编码器。"""
def __init__(self, logger: Any, query_service: NapCatQueryService) -> None:
"""初始化通知事件编码器。
Args:
logger: 插件日志对象。
query_service: QQ 查询服务。
"""
self._logger = logger
self._query_service = query_service
async def build_notice_message_dict(self, payload: Mapping[str, Any]) -> Optional[Dict[str, Any]]:
"""将 NapCat ``notice`` 事件转换为 Host 可接受的消息字典。
Args:
payload: NapCat 推送的原始通知事件。
Returns:
Optional[Dict[str, Any]]: 成功时返回标准 ``MessageDict``;无法识别时返回 ``None``。
"""
notice_type = str(payload.get("notice_type") or "").strip()
if not notice_type:
return None
group_id = str(payload.get("group_id") or "").strip()
user_id = str(payload.get("user_id") or payload.get("operator_id") or "").strip()
self_id = str(payload.get("self_id") or "").strip()
user_info = await self._build_user_info(group_id=group_id, user_id=user_id)
group_info = await self._build_group_info(group_id)
notice_text = self._build_notice_text(payload, user_info.get("user_nickname", user_id or "系统"))
if not notice_text:
return None
additional_config: Dict[str, Any] = {
"self_id": self_id,
"napcat_notice_type": notice_type,
"napcat_notice_sub_type": str(payload.get("sub_type") or "").strip(),
"napcat_notice_payload": dict(payload),
}
if group_id:
additional_config["platform_io_target_group_id"] = group_id
elif user_id:
additional_config["platform_io_target_user_id"] = user_id
message_info: Dict[str, Any] = {"user_info": user_info, "additional_config": additional_config}
if group_info is not None:
message_info["group_info"] = group_info
timestamp_seconds = payload.get("time")
if not isinstance(timestamp_seconds, (int, float)):
timestamp_seconds = time.time()
return {
"message_id": f"napcat-notice-{uuid4().hex}",
"timestamp": str(float(timestamp_seconds)),
"platform": "qq",
"message_info": message_info,
"raw_message": [{"type": "text", "data": notice_text}],
"is_mentioned": False,
"is_at": False,
"is_emoji": False,
"is_picture": False,
"is_command": False,
"is_notify": True,
"session_id": "",
"processed_plain_text": notice_text,
"display_message": notice_text,
}
async def handle_meta_event(self, payload: Mapping[str, Any]) -> None:
"""处理 ``meta_event`` 事件的日志与状态观测。
Args:
payload: NapCat 推送的原始元事件。
"""
meta_event_type = str(payload.get("meta_event_type") or "").strip()
self_id = str(payload.get("self_id") or "").strip() or "unknown"
if meta_event_type == "lifecycle":
sub_type = str(payload.get("sub_type") or "").strip()
if sub_type == "connect":
self._logger.info(f"NapCat 元事件Bot {self_id} 已建立连接")
else:
self._logger.debug(f"NapCat 生命周期事件: self_id={self_id} sub_type={sub_type}")
return
if meta_event_type == "heartbeat":
status = payload.get("status", {})
if not isinstance(status, Mapping):
status = {}
is_online = bool(status.get("online", False))
is_good = bool(status.get("good", False))
interval_ms = payload.get("interval")
self._logger.debug(
f"NapCat 心跳事件: self_id={self_id} online={is_online} good={is_good} interval={interval_ms}"
)
if not is_online:
self._logger.warning(f"NapCat 心跳显示 Bot {self_id} 已离线")
elif not is_good:
self._logger.warning(f"NapCat 心跳显示 Bot {self_id} 状态异常")
async def _build_user_info(self, group_id: str, user_id: str) -> Dict[str, Optional[str]]:
"""构造通知消息的用户信息。
Args:
group_id: 群号;私聊或系统通知时为空字符串。
user_id: 事件关联用户号。
Returns:
Dict[str, Optional[str]]: 规范化后的用户信息字典。
"""
if not user_id:
return {
"user_id": "notice",
"user_nickname": "系统通知",
"user_cardname": None,
}
member_info: Optional[Dict[str, Any]]
if group_id:
member_info = await self._query_service.get_group_member_info(group_id, user_id)
else:
member_info = await self._query_service.get_stranger_info(user_id)
if member_info is None:
return {
"user_id": user_id,
"user_nickname": user_id,
"user_cardname": None,
}
return {
"user_id": user_id,
"user_nickname": str(member_info.get("nickname") or user_id),
"user_cardname": self._normalize_optional_string(member_info.get("card")),
}
async def _build_group_info(self, group_id: str) -> Optional[Dict[str, str]]:
"""构造通知消息的群信息。
Args:
group_id: 群号。
Returns:
Optional[Dict[str, str]]: 群信息字典;若不是群通知则返回 ``None``。
"""
if not group_id:
return None
group_info = await self._query_service.get_group_info(group_id)
group_name = str(group_info.get("group_name") or f"group_{group_id}") if group_info else f"group_{group_id}"
return {"group_id": group_id, "group_name": group_name}
def _build_notice_text(self, payload: Mapping[str, Any], actor_name: str) -> str:
"""根据 NapCat 通知事件生成可读文本。
Args:
payload: 原始通知事件。
actor_name: 事件操作者显示名。
Returns:
str: 生成的可读通知文本。
"""
notice_type = str(payload.get("notice_type") or "").strip()
sub_type = str(payload.get("sub_type") or "").strip()
target_id = str(payload.get("target_id") or "").strip()
if notice_type in {"group_recall", "friend_recall"}:
return f"{actor_name} 撤回了一条消息"
if notice_type == "notify" and sub_type == "poke":
target_text = f" -> {target_id}" if target_id else ""
return f"{actor_name} 发起了戳一戳{target_text}"
if notice_type == "notify" and sub_type == "group_name":
return f"{actor_name} 修改了群名称"
if notice_type == "group_ban" and sub_type == "ban":
duration = payload.get("duration")
return f"{actor_name} 触发了群禁言,时长 {duration}"
if notice_type == "group_ban" and sub_type == "lift_ban":
return f"{actor_name} 触发了解除禁言"
if notice_type == "group_upload":
file_info = payload.get("file", {})
file_name = ""
if isinstance(file_info, Mapping):
file_name = str(file_info.get("name") or "").strip()
return f"{actor_name} 上传了文件{f'{file_name}' if file_name else ''}"
if notice_type == "group_increase":
return f"{actor_name} 加入了群聊"
if notice_type == "group_decrease":
return f"{actor_name} 离开了群聊"
if notice_type == "group_admin":
return f"{actor_name} 的群管理员状态发生变化"
if notice_type == "essence":
return f"{actor_name} 触发了精华消息事件"
if notice_type == "group_msg_emoji_like":
return f"{actor_name} 给一条消息添加了表情回应"
return f"[notice] {notice_type}.{sub_type}".strip(".")
@staticmethod
def _normalize_optional_string(value: Any) -> Optional[str]:
"""将任意值规范化为可选字符串。
Args:
value: 待规范化的值。
Returns:
Optional[str]: 规范化后的字符串;若值为空则返回 ``None``。
"""
if value is None:
return None
normalized_value = str(value).strip()
return normalized_value if normalized_value else None

View File

@@ -0,0 +1,170 @@
"""NapCat QQ 平台查询能力。"""
from typing import TYPE_CHECKING, Any, Dict, Optional
import asyncio
if TYPE_CHECKING:
from napcat_adapter.transport import NapCatTransportClient
try:
from aiohttp import ClientSession, ClientTimeout
AIOHTTP_AVAILABLE = True
except ImportError:
ClientSession = None # type: ignore[assignment]
ClientTimeout = None # type: ignore[assignment]
AIOHTTP_AVAILABLE = False
class NapCatQueryService:
"""NapCat QQ 平台查询服务。"""
def __init__(self, logger: Any, transport: "NapCatTransportClient") -> None:
"""初始化查询服务。
Args:
logger: 插件日志对象。
transport: NapCat 传输层客户端。
"""
self._logger = logger
self._transport = transport
async def get_login_info(self) -> Optional[Dict[str, Any]]:
"""获取当前登录账号信息。
Returns:
Optional[Dict[str, Any]]: 登录信息字典;失败时返回 ``None``。
"""
return await self._call_query("get_login_info", {})
async def get_group_info(self, group_id: str) -> Optional[Dict[str, Any]]:
"""获取群信息。
Args:
group_id: 群号。
Returns:
Optional[Dict[str, Any]]: 群信息字典;失败时返回 ``None``。
"""
return await self._call_query("get_group_info", {"group_id": group_id})
async def get_group_member_info(self, group_id: str, user_id: str) -> Optional[Dict[str, Any]]:
"""获取群成员信息。
Args:
group_id: 群号。
user_id: 用户号。
Returns:
Optional[Dict[str, Any]]: 群成员信息字典;失败时返回 ``None``。
"""
return await self._call_query(
"get_group_member_info",
{"group_id": group_id, "user_id": user_id, "no_cache": True},
)
async def get_stranger_info(self, user_id: str) -> Optional[Dict[str, Any]]:
"""获取陌生人信息。
Args:
user_id: 用户号。
Returns:
Optional[Dict[str, Any]]: 陌生人信息字典;失败时返回 ``None``。
"""
return await self._call_query("get_stranger_info", {"user_id": user_id})
async def get_message_detail(self, message_id: str) -> Optional[Dict[str, Any]]:
"""获取消息详情。
Args:
message_id: 消息 ID。
Returns:
Optional[Dict[str, Any]]: 消息详情字典;失败时返回 ``None``。
"""
return await self._call_query("get_msg", {"message_id": message_id})
async def get_forward_message(self, message_id: str) -> Optional[Dict[str, Any]]:
"""获取合并转发消息详情。
Args:
message_id: 转发消息 ID。
Returns:
Optional[Dict[str, Any]]: 合并转发消息详情;失败时返回 ``None``。
"""
return await self._call_query("get_forward_msg", {"message_id": message_id})
async def get_record_detail(self, file_name: str, file_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""获取语音文件详情。
Args:
file_name: 语音文件名。
file_id: 可选文件 ID。
Returns:
Optional[Dict[str, Any]]: 语音详情字典;失败时返回 ``None``。
"""
params: Dict[str, Any] = {"file": file_name, "out_format": "wav"}
if file_id:
params["file_id"] = file_id
return await self._call_query("get_record", params)
async def download_binary(self, url: str) -> Optional[bytes]:
"""下载远程二进制资源。
Args:
url: 资源 URL。
Returns:
Optional[bytes]: 下载到的二进制内容;失败时返回 ``None``。
"""
if not url:
return None
if not AIOHTTP_AVAILABLE or ClientSession is None or ClientTimeout is None:
self._logger.warning("NapCat 查询层缺少 aiohttp无法下载远程资源")
return None
try:
timeout = ClientTimeout(total=15)
async with ClientSession(timeout=timeout) as session:
async with session.get(url) as response:
if response.status != 200:
self._logger.warning(f"NapCat 远程资源下载失败: status={response.status} url={url}")
return None
return await response.read()
except asyncio.CancelledError:
raise
except Exception as exc:
self._logger.warning(f"NapCat 远程资源下载失败: {exc}")
return None
async def _call_query(self, action_name: str, params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""调用 OneBot 查询动作并提取 ``data`` 字段。
Args:
action_name: OneBot 动作名。
params: 动作参数。
Returns:
Optional[Dict[str, Any]]: 查询结果中的 ``data`` 字段;失败时返回 ``None``。
"""
try:
response = await self._transport.call_action(action_name, params)
except asyncio.CancelledError:
raise
except Exception as exc:
self._logger.warning(f"NapCat 查询动作执行失败: action={action_name} error={exc}")
return None
if str(response.get("status") or "").lower() != "ok":
self._logger.warning(
f"NapCat 查询动作返回失败: action={action_name} "
f"message={response.get('wording') or response.get('message') or 'unknown'}"
)
return None
response_data = response.get("data")
return response_data if isinstance(response_data, dict) else None

View File

@@ -0,0 +1,85 @@
"""NapCat 运行时路由状态管理。"""
from typing import Any, Optional
from napcat_adapter.config import NapCatServerConfig
class NapCatRuntimeStateManager:
"""NapCat 适配器路由状态上报器。"""
def __init__(self, adapter_capability: Any, logger: Any) -> None:
"""初始化运行时状态管理器。
Args:
adapter_capability: SDK 提供的适配器能力对象。
logger: 插件日志对象。
"""
self._adapter_capability = adapter_capability
self._logger = logger
self._runtime_state_connected: bool = False
self._reported_account_id: Optional[str] = None
self._reported_scope: Optional[str] = None
async def report_connected(self, account_id: str, server_config: NapCatServerConfig) -> bool:
"""向 Host 上报当前连接已就绪。
Args:
account_id: 当前 NapCat 连接对应的机器人账号 ID。
server_config: 当前生效的 NapCat 服务端配置。
Returns:
bool: 若 Host 接受了运行时状态更新,则返回 ``True``。
"""
normalized_account_id = str(account_id).strip()
if not normalized_account_id:
return False
scope = server_config.connection_id or None
if (
self._runtime_state_connected
and self._reported_account_id == normalized_account_id
and self._reported_scope == scope
):
return True
accepted = False
try:
accepted = await self._adapter_capability.update_runtime_state(
connected=True,
account_id=normalized_account_id,
scope=server_config.connection_id,
metadata={"ws_url": server_config.build_ws_url()},
)
except Exception as exc:
self._logger.warning(f"NapCat 适配器上报连接就绪状态失败: {exc}")
return False
if not accepted:
self._logger.warning("NapCat 适配器连接已建立,但 Host 未接受运行时状态更新")
return False
self._runtime_state_connected = True
self._reported_account_id = normalized_account_id
self._reported_scope = scope
self._logger.info(
f"NapCat 适配器已激活路由: platform=qq account_id={normalized_account_id} "
f"scope={self._reported_scope or '*'}"
)
return True
async def report_disconnected(self) -> None:
"""向 Host 上报当前连接已断开,并撤销适配器路由。"""
if not self._runtime_state_connected:
self._reported_account_id = None
self._reported_scope = None
return
try:
await self._adapter_capability.update_runtime_state(connected=False)
except Exception as exc:
self._logger.warning(f"NapCat 适配器上报断开状态失败: {exc}")
finally:
self._runtime_state_connected = False
self._reported_account_id = None
self._reported_scope = None

View File

@@ -0,0 +1,322 @@
"""NapCat 正向 WebSocket 传输层。"""
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Optional, Set, cast
from uuid import uuid4
import asyncio
import contextlib
import json
from napcat_adapter.config import NapCatServerConfig
if TYPE_CHECKING:
from aiohttp import ClientWebSocketResponse as AiohttpClientWebSocketResponse
try:
from aiohttp import ClientSession, ClientTimeout, WSMsgType
AIOHTTP_AVAILABLE = True
except ImportError:
ClientSession = cast(Any, None)
ClientTimeout = cast(Any, None)
WSMsgType = cast(Any, None)
AIOHTTP_AVAILABLE = False
if not TYPE_CHECKING:
AiohttpClientWebSocketResponse = Any
class NapCatTransportClient:
"""NapCat 正向 WebSocket 客户端。"""
def __init__(
self,
logger: Any,
on_connection_opened: Callable[[], Awaitable[None]],
on_connection_closed: Callable[[], Awaitable[None]],
on_payload: Callable[[Dict[str, Any]], Awaitable[None]],
) -> None:
"""初始化传输层客户端。
Args:
logger: 插件日志对象。
on_connection_opened: 连接建立后的异步回调。
on_connection_closed: 连接断开后的异步回调。
on_payload: 收到非 echo 载荷后的异步回调。
"""
self._logger = logger
self._on_connection_opened = on_connection_opened
self._on_connection_closed = on_connection_closed
self._on_payload = on_payload
self._server_config: Optional[NapCatServerConfig] = None
self._connection_task: Optional[asyncio.Task[None]] = None
self._pending_actions: Dict[str, asyncio.Future[Dict[str, Any]]] = {}
self._background_tasks: Set[asyncio.Task[Any]] = set()
self._send_lock = asyncio.Lock()
self._ws: Optional[AiohttpClientWebSocketResponse] = None
self._stop_requested: bool = False
self._connection_active: bool = False
@classmethod
def is_available(cls) -> bool:
"""判断当前环境是否安装了传输层依赖。
Returns:
bool: 若已安装 ``aiohttp``,则返回 ``True``。
"""
return AIOHTTP_AVAILABLE
def configure(self, server_config: NapCatServerConfig) -> None:
"""更新当前传输层使用的 NapCat 服务端配置。
Args:
server_config: 最新生效的 NapCat 服务端配置。
"""
self._server_config = server_config
async def start(self) -> None:
"""启动 NapCat 正向 WebSocket 连接循环。
Raises:
RuntimeError: 当缺少配置或依赖时抛出。
"""
if not self.is_available():
raise RuntimeError("NapCat 适配器依赖 aiohttp但当前环境未安装该依赖")
if self._server_config is None:
raise RuntimeError("NapCat 适配器尚未配置 napcat_server")
if self._connection_task is not None and not self._connection_task.done():
return
self._stop_requested = False
self._connection_task = asyncio.create_task(self._connection_loop(), name="napcat_adapter.connection")
async def stop(self) -> None:
"""停止当前连接并清理所有后台任务。"""
self._stop_requested = True
connection_task = self._connection_task
self._connection_task = None
ws = self._ws
if ws is not None and not ws.closed:
with contextlib.suppress(Exception):
await ws.close()
self._ws = None
if connection_task is not None:
connection_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await connection_task
await self._cancel_background_tasks()
await self._notify_connection_closed()
self._fail_pending_actions("NapCat connection closed")
async def call_action(self, action_name: str, params: Dict[str, Any]) -> Dict[str, Any]:
"""发送 OneBot 动作并等待对应的 echo 响应。
Args:
action_name: OneBot 动作名称。
params: 动作参数。
Returns:
Dict[str, Any]: NapCat 返回的原始响应字典。
Raises:
RuntimeError: 当连接不可用时抛出。
"""
ws = self._ws
server_config = self._server_config
if ws is None or ws.closed or server_config is None:
raise RuntimeError("NapCat is not connected")
echo_id = uuid4().hex
loop = asyncio.get_running_loop()
response_future: asyncio.Future[Dict[str, Any]] = loop.create_future()
self._pending_actions[echo_id] = response_future
request_payload = {"action": action_name, "params": params, "echo": echo_id}
try:
async with self._send_lock:
await ws.send_str(json.dumps(request_payload, ensure_ascii=False))
return await asyncio.wait_for(response_future, timeout=server_config.action_timeout_sec)
finally:
self._pending_actions.pop(echo_id, None)
async def _connection_loop(self) -> None:
"""维护单个 WebSocket 连接,并在断开后按配置重连。"""
assert ClientSession is not None
assert ClientTimeout is not None
while not self._stop_requested:
server_config = self._server_config
if server_config is None:
return
ws_url = server_config.build_ws_url()
timeout = ClientTimeout(total=None, connect=10)
try:
async with ClientSession(headers=self._build_headers(server_config), timeout=timeout) as session:
async with session.ws_connect(ws_url, heartbeat=server_config.heartbeat_interval or None) as ws:
self._ws = ws
self._logger.info(f"NapCat 适配器已连接: {ws_url}")
await self._receive_loop(ws)
except asyncio.CancelledError:
raise
except Exception as exc:
self._logger.warning(f"NapCat 适配器连接失败: {exc}")
finally:
self._ws = None
await self._notify_connection_closed()
self._fail_pending_actions("NapCat connection interrupted")
if self._stop_requested:
break
await asyncio.sleep(server_config.reconnect_delay_sec)
async def _receive_loop(self, ws: AiohttpClientWebSocketResponse) -> None:
"""持续消费 WebSocket 消息并分发处理。
Args:
ws: 当前活跃的 WebSocket 连接对象。
"""
assert WSMsgType is not None
bootstrap_task = self._create_background_task(
self._notify_connection_opened(),
"napcat_adapter.bootstrap",
)
try:
async for ws_message in ws:
if ws_message.type != WSMsgType.TEXT:
if ws_message.type in {WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.ERROR}:
break
continue
payload = self._parse_json_message(ws_message.data)
if payload is None:
continue
if echo_id := str(payload.get("echo") or "").strip():
self._resolve_pending_action(echo_id, payload)
continue
self._create_background_task(self._on_payload(payload), "napcat_adapter.payload")
finally:
if bootstrap_task is not None and not bootstrap_task.done():
bootstrap_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await bootstrap_task
def _create_background_task(self, coroutine: Awaitable[Any], name: str) -> asyncio.Task[Any]:
"""创建并跟踪一个后台任务。
Args:
coroutine: 待执行的协程对象。
name: 任务名。
Returns:
asyncio.Task[Any]: 已创建的后台任务。
"""
task = asyncio.create_task(coroutine, name=name)
self._background_tasks.add(task)
task.add_done_callback(self._handle_background_task_completion)
return task
def _handle_background_task_completion(self, task: asyncio.Task[Any]) -> None:
"""处理后台任务结束后的清理与异常记录。
Args:
task: 已结束的后台任务。
"""
self._background_tasks.discard(task)
if task.cancelled():
return
exception = task.exception()
if exception is not None:
self._logger.error(f"NapCat 适配器后台任务异常: {exception}", exc_info=True)
async def _cancel_background_tasks(self) -> None:
"""取消所有仍在运行的后台任务。"""
background_tasks = list(self._background_tasks)
for task in background_tasks:
task.cancel()
if background_tasks:
with contextlib.suppress(Exception):
await asyncio.gather(*background_tasks, return_exceptions=True)
self._background_tasks.clear()
async def _notify_connection_opened(self) -> None:
"""在连接建立后触发上层回调。"""
if self._connection_active:
return
self._connection_active = True
try:
await self._on_connection_opened()
except Exception as exc:
self._logger.warning(f"NapCat 适配器连接建立回调失败: {exc}")
async def _notify_connection_closed(self) -> None:
"""在连接断开后触发上层回调。"""
if not self._connection_active:
return
self._connection_active = False
try:
await self._on_connection_closed()
except Exception as exc:
self._logger.warning(f"NapCat 适配器断连回调失败: {exc}")
def _resolve_pending_action(self, echo_id: str, payload: Dict[str, Any]) -> None:
"""解析等待中的动作响应。
Args:
echo_id: 动作请求对应的 echo 标识。
payload: NapCat 返回的响应载荷。
"""
response_future = self._pending_actions.get(echo_id)
if response_future is None or response_future.done():
return
response_future.set_result(payload)
def _fail_pending_actions(self, error_message: str) -> None:
"""让所有等待中的动作以异常方式结束。
Args:
error_message: 写入异常中的错误信息。
"""
for response_future in self._pending_actions.values():
if not response_future.done():
response_future.set_exception(RuntimeError(error_message))
self._pending_actions.clear()
def _build_headers(self, server_config: NapCatServerConfig) -> Dict[str, str]:
"""构造连接 NapCat 所需的请求头。
Args:
server_config: 当前生效的 NapCat 服务端配置。
Returns:
Dict[str, str]: WebSocket 握手请求头。
"""
return {"Authorization": f"Bearer {server_config.token}"} if server_config.token else {}
def _parse_json_message(self, data: Any) -> Optional[Dict[str, Any]]:
"""解析 WebSocket 文本消息中的 JSON 数据。
Args:
data: WebSocket 收到的原始文本数据。
Returns:
Optional[Dict[str, Any]]: 成功时返回字典,失败时返回 ``None``。
"""
try:
payload = json.loads(str(data))
except Exception as exc:
self._logger.warning(f"NapCat 适配器解析 JSON 载荷失败: {exc}")
return None
return payload if isinstance(payload, dict) else None