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:
70
pytests/test_napcat_adapter_codec.py
Normal file
70
pytests/test_napcat_adapter_codec.py
Normal 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"}
|
||||
91
pytests/test_napcat_adapter_config.py
Normal file
91
pytests/test_napcat_adapter_config.py
Normal 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
|
||||
164
pytests/test_platform_io_dedupe.py
Normal file
164
pytests/test_platform_io_dedupe.py
Normal 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
|
||||
87
pytests/test_plugin_message_utils_runtime.py
Normal file
87
pytests/test_plugin_message_utils_runtime.py
Normal 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"
|
||||
Reference in New Issue
Block a user