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"