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"
|
||||
@@ -2,9 +2,6 @@
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Optional
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from src.common.logger import get_logger
|
||||
from src.platform_io.drivers.base import PlatformIODriver
|
||||
|
||||
@@ -438,12 +435,17 @@ class PlatformIOManager:
|
||||
|
||||
Returns:
|
||||
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
|
||||
if raw_dedupe_key is None and envelope.session_message is not None:
|
||||
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:
|
||||
return None
|
||||
|
||||
@@ -453,29 +455,6 @@ class PlatformIOManager:
|
||||
|
||||
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
|
||||
def _validate_binding_against_driver(binding: RouteBinding, driver: PlatformIODriver) -> None:
|
||||
"""校验路由绑定与驱动描述是否一致。
|
||||
|
||||
@@ -198,8 +198,9 @@ class InboundMessageEnvelope:
|
||||
driver_kind: 产出该消息的驱动类型。
|
||||
external_message_id: 可选的平台侧消息 ID,用于去重。
|
||||
dedupe_key: 可选的显式去重键。当外部消息没有稳定 ``message_id`` 时,
|
||||
可由上游驱动提供消息指纹。若这里为空,中间层仍可能继续回退到
|
||||
``session_message.message_id`` 或 ``payload`` 指纹。
|
||||
可由上游驱动提供稳定的技术性幂等键。若这里为空,中间层仅会继续
|
||||
回退到 ``external_message_id`` 或 ``session_message.message_id``,
|
||||
不会再根据 ``payload`` 内容猜测语义去重键。
|
||||
session_message: 可选的、已经完成规范化的 ``SessionMessage`` 对象。
|
||||
payload: 可选的原始字典载荷,供延迟转换或调试使用。
|
||||
metadata: 额外入站元数据,例如连接信息或追踪上下文。
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
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.chat.message_receive.message import SessionMessage
|
||||
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")
|
||||
|
||||
@@ -45,6 +60,251 @@ class MessageDict(TypedDict, total=False):
|
||||
|
||||
|
||||
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
|
||||
def _message_info_to_dict(message_info: MessageInfo) -> MessageInfoDict:
|
||||
"""
|
||||
@@ -92,7 +352,7 @@ class PluginMessageUtils:
|
||||
timestamp=str(session_message.timestamp.timestamp()), # 转换为时间戳字符串
|
||||
platform=session_message.platform,
|
||||
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_at=session_message.is_at,
|
||||
is_emoji=session_message.is_emoji,
|
||||
@@ -186,7 +446,7 @@ class PluginMessageUtils:
|
||||
# 构建原始消息组件序列(复用 MessageSequence.from_dict 方法)
|
||||
raw_message_data = message_dict["raw_message"]
|
||||
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:
|
||||
raise ValueError("消息字典中 'raw_message' 字段必须是一个列表")
|
||||
|
||||
|
||||
1
src/plugins/built_in/napcat_adapter/__init__.py
Normal file
1
src/plugins/built_in/napcat_adapter/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""NapCat 内置适配器插件包。"""
|
||||
414
src/plugins/built_in/napcat_adapter/codec_inbound.py
Normal file
414
src/plugins/built_in/napcat_adapter/codec_inbound.py
Normal 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]"
|
||||
192
src/plugins/built_in/napcat_adapter/codec_outbound.py
Normal file
192
src/plugins/built_in/napcat_adapter/codec_outbound.py
Normal 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'}]"}}
|
||||
398
src/plugins/built_in/napcat_adapter/config.py
Normal file
398
src/plugins/built_in/napcat_adapter/config.py
Normal 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
|
||||
9
src/plugins/built_in/napcat_adapter/constants.py
Normal file
9
src/plugins/built_in/napcat_adapter/constants.py
Normal 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"
|
||||
68
src/plugins/built_in/napcat_adapter/filters.py
Normal file
68
src/plugins/built_in/napcat_adapter/filters.py
Normal 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
224
src/plugins/built_in/napcat_adapter/qq_notice.py
Normal file
224
src/plugins/built_in/napcat_adapter/qq_notice.py
Normal 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
|
||||
170
src/plugins/built_in/napcat_adapter/qq_queries.py
Normal file
170
src/plugins/built_in/napcat_adapter/qq_queries.py
Normal 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
|
||||
85
src/plugins/built_in/napcat_adapter/runtime_state.py
Normal file
85
src/plugins/built_in/napcat_adapter/runtime_state.py
Normal 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
|
||||
322
src/plugins/built_in/napcat_adapter/transport.py
Normal file
322
src/plugins/built_in/napcat_adapter/transport.py
Normal 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
|
||||
Reference in New Issue
Block a user