Files
lserp_cs_6.0/插件库/astrbot_plugin_lserp/bridge_protocol.py
2026-08-14 14:28:28 +08:00

1274 lines
46 KiB
Python

from __future__ import annotations
import asyncio
import ctypes
import json
import math
import os
import re
import struct
import threading
import time
import uuid
from collections import OrderedDict
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, BinaryIO, Callable
PROTOCOL_VERSION = "1.0"
MAX_MESSAGE_BYTES = 1024 * 1024
SAFE_METHODS = frozenset({"health", "capabilities.list", "context.get", "command.plan"})
SAFE_COMMAND = re.compile(r"^[a-zA-Z0-9_.:-]{1,128}$")
SAFE_PIPE = re.compile(r"^lserp\.agent\.([0-9]{1,10})\.([a-f0-9]{32})$")
SAFE_IDENTIFIER = re.compile(r"^[a-zA-Z0-9_.:-]{8,128}$")
SAFE_BRIDGE_INSTANCE_ID = re.compile(r"^[a-f0-9]{32}$")
SAFE_SESSION_SCOPE_TOKEN = re.compile(r"^[a-f0-9]{32}$")
SAFE_CODE = re.compile(r"^[a-zA-Z0-9_.:-]{1,128}$")
SAFE_HASH = re.compile(r"^[a-f0-9]{64}$")
SAFE_VERSION = re.compile(r"^[0-9]+(?:\.[0-9]+){1,3}$")
PLAN_PROJECTION_FIELDS = frozenset(
{
"planId",
"commandName",
"commandVersion",
"moduleCode",
"risk",
"createdAtUtc",
"expiresAtUtc",
"valid",
"executionAllowed",
"inputFingerprint",
"outcomeCode",
"title",
"preview",
"data",
"warnings",
}
)
PLAN_RISKS = frozenset({"read", "navigate", "draft", "write", "critical"})
EXECUTABLE_PLAN_RISKS = frozenset({"navigate", "write", "critical"})
CONTEXT_RESPONSE_FIELDS = frozenset(
{
"userId",
"userName",
"accountBook",
"subSystemId",
"databaseScopeFingerprint",
"subSystemName",
"isAdministrator",
"activeModule",
"openModuleCount",
"openModulesTruncated",
"openModules",
}
)
CONTEXT_MODULE_FIELDS = frozenset(
{"moduleCode", "navigationCode", "moduleName"}
)
CONTEXT_MAX_OPEN_MODULES = 50
CAPABILITIES_RESPONSE_FIELDS = frozenset({"commands"})
CAPABILITY_DESCRIPTOR_FIELDS = frozenset(
{
"name",
"version",
"description",
"schemaVersion",
"inputSchema",
"risk",
"requiresConfirmation",
"requiresIdempotencyKey",
}
)
CAPABILITIES_MAX_COMMANDS = 128
SCHEMA_MAX_DEPTH = 8
SCHEMA_MAX_NODES = 512
SCHEMA_MAX_PROPERTIES = 128
SAFE_SCHEMA_PROPERTY = re.compile(r"^[A-Za-z][A-Za-z0-9]{0,63}$")
ALLOWED_RESPONSE_FIELDS = frozenset(
{
"protocolVersion",
"requestId",
"correlationId",
"success",
"code",
"message",
"data",
}
)
ALLOWED_DISCOVERY_FIELDS = frozenset(
{
"protocolVersion",
"pipeName",
"processId",
"startedAtUtc",
"bridgeInstanceId",
}
)
DISCOVERY_MAX_BYTES = 64 * 1024
MAX_BOUND_BRIDGE_INSTANCES = 128
MAX_PROCESS_START_DRIFT_SECONDS = 1.0
_WINDOWS_EPOCH_FILETIME = 116_444_736_000_000_000
_PIPE_CLOSED_ERRORS = frozenset({6, 109, 232, 233})
class ErpBridgeError(RuntimeError):
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
@dataclass(frozen=True)
class ErpBridgeDiscovery:
pipe_name: str
process_id: int
started_at_utc: datetime
bridge_instance_id: str
def _strict_json_loads(text: str, label: str) -> Any:
if not isinstance(text, str) or not text.strip():
raise ErpBridgeError("bridge_protocol_error", f"{label}不能为空。")
def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise ErpBridgeError(
"bridge_protocol_error", f"{label}包含重复 JSON 字段。"
)
result[key] = value
return result
def reject_non_finite_number(_value: str) -> None:
raise ValueError("non-finite JSON number")
try:
return json.loads(
text,
object_pairs_hook=reject_duplicate_keys,
parse_constant=reject_non_finite_number,
)
except ErpBridgeError:
raise
except (json.JSONDecodeError, RecursionError, ValueError) as error:
raise ErpBridgeError(
"bridge_protocol_error", f"{label}不是严格 JSON。"
) from error
def _required_string(
source: dict[str, Any], name: str, maximum_length: int
) -> str:
value = source.get(name)
if (
not isinstance(value, str)
or not value.strip()
or len(value) > maximum_length
):
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 桥消息字段格式无效:{name}。"
)
return value
def parse_response(
text: str, expected_request_id: str, expected_correlation_id: str
) -> dict[str, Any]:
response = _strict_json_loads(text, "ERP 桥响应")
if not isinstance(response, dict):
raise ErpBridgeError("bridge_protocol_error", "ERP 桥响应必须是 JSON 对象。")
if not set(response).issubset(ALLOWED_RESPONSE_FIELDS):
raise ErpBridgeError("bridge_protocol_error", "ERP 桥响应包含未知字段。")
protocol = _required_string(response, "protocolVersion", 16)
request_id = _required_string(response, "requestId", 128)
correlation_id = _required_string(response, "correlationId", 128)
code = _required_string(response, "code", 128)
success = response.get("success")
data = response.get("data")
message = response.get("message")
if (
protocol != PROTOCOL_VERSION
or SAFE_IDENTIFIER.fullmatch(request_id) is None
or SAFE_IDENTIFIER.fullmatch(correlation_id) is None
or SAFE_CODE.fullmatch(code) is None
or request_id != expected_request_id
or correlation_id != expected_correlation_id
or not isinstance(success, bool)
or not isinstance(data, dict)
or (message is not None and not isinstance(message, str))
or (isinstance(message, str) and len(message) > 2000)
):
raise ErpBridgeError(
"bridge_protocol_error", "ERP 桥返回了无效或不匹配的协议消息。"
)
if not success:
raise ErpBridgeError(code, message or "ERP 桥调用失败。")
return data
def _parse_plan_utc(value: Any, name: str) -> datetime:
if not isinstance(value, str) or not value or len(value) > 64:
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 计划时间字段格式无效:{name}。"
)
try:
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
parsed = datetime.fromisoformat(normalized)
except ValueError as error:
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 计划时间字段格式无效:{name}。"
) from error
if parsed.tzinfo is None:
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 计划时间字段必须包含时区:{name}。"
)
return parsed.astimezone(timezone.utc)
def _validate_plan_projection(plan: Any) -> dict[str, Any]:
"""Validate the exact ERP-to-AstrBot plan projection for protocol 1.0."""
if not isinstance(plan, dict) or set(plan) != PLAN_PROJECTION_FIELDS:
raise ErpBridgeError(
"bridge_protocol_error", "ERP 计划字段不完整或包含未知字段。"
)
plan_id = plan.get("planId")
command = plan.get("commandName")
version = plan.get("commandVersion")
module_code = plan.get("moduleCode")
risk = plan.get("risk")
fingerprint = plan.get("inputFingerprint")
outcome_code = plan.get("outcomeCode")
valid = plan.get("valid")
execution_allowed = plan.get("executionAllowed")
title = plan.get("title")
preview = plan.get("preview")
data = plan.get("data")
warnings = plan.get("warnings")
if (
not isinstance(plan_id, str)
or re.fullmatch(r"[A-Fa-f0-9]{32}", plan_id) is None
or not isinstance(command, str)
or SAFE_COMMAND.fullmatch(command) is None
or not isinstance(version, str)
or SAFE_VERSION.fullmatch(version) is None
or not isinstance(module_code, str)
or SAFE_CODE.fullmatch(module_code) is None
or not isinstance(risk, str)
or risk not in PLAN_RISKS
or not isinstance(fingerprint, str)
or SAFE_HASH.fullmatch(fingerprint) is None
or not isinstance(outcome_code, str)
or SAFE_CODE.fullmatch(outcome_code) is None
or type(valid) is not bool
or type(execution_allowed) is not bool
or execution_allowed != (valid and risk in EXECUTABLE_PLAN_RISKS)
):
raise ErpBridgeError(
"bridge_protocol_error", "ERP 计划标识、版本、风险或执行语义无效。"
)
created_at = _parse_plan_utc(plan.get("createdAtUtc"), "createdAtUtc")
expires_at = _parse_plan_utc(plan.get("expiresAtUtc"), "expiresAtUtc")
lifetime_seconds = (expires_at - created_at).total_seconds()
if lifetime_seconds <= 0 or lifetime_seconds > 15 * 60:
raise ErpBridgeError(
"bridge_protocol_error", "ERP 计划有效期必须大于 0 且不超过 15 分钟。"
)
if title is not None and (
not isinstance(title, str)
or not title.strip()
or len(title) > 500
or any(ord(character) < 32 for character in title)
):
raise ErpBridgeError("bridge_protocol_error", "ERP 计划标题格式无效。")
if preview is not None and not isinstance(preview, dict):
raise ErpBridgeError("bridge_protocol_error", "ERP 计划预览必须是对象或 null。")
if not isinstance(data, dict):
raise ErpBridgeError("bridge_protocol_error", "ERP 计划 data 必须是对象。")
if (
not isinstance(warnings, list)
or len(warnings) > 64
or any(
not isinstance(item, str)
or not item.strip()
or len(item) > 1000
or any(ord(character) < 32 for character in item)
for item in warnings
)
):
raise ErpBridgeError("bridge_protocol_error", "ERP 计划警告列表格式无效。")
if title != data.get("title") or preview != data.get("preview"):
raise ErpBridgeError(
"bridge_protocol_error", "ERP 计划展示字段与服务端 data 投影不一致。"
)
data_outcome = data.get("outcomeCode")
if data_outcome is not None and (
not isinstance(data_outcome, str)
or SAFE_CODE.fullmatch(data_outcome) is None
):
raise ErpBridgeError("bridge_protocol_error", "ERP 计划结果码格式无效。")
expected_outcome = (
data_outcome if data_outcome is not None else "plan_ready" if valid else "plan_invalid"
)
if outcome_code != expected_outcome:
raise ErpBridgeError(
"bridge_protocol_error", "ERP 计划结果码与服务端 data 投影不一致。"
)
return plan
def _validate_context_text(value: Any, name: str, maximum_length: int) -> str:
if (
not isinstance(value, str)
or not value.strip()
or len(value) > maximum_length
or any(ord(character) < 32 for character in value)
):
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 上下文字段格式无效:{name}。"
)
return value
def _validate_context_code(value: Any, name: str) -> str:
text = _validate_context_text(value, name, 128)
if SAFE_CODE.fullmatch(text) is None:
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 上下文标识格式无效:{name}。"
)
return text
def _validate_context_module(value: Any, name: str) -> dict[str, Any]:
if not isinstance(value, dict) or set(value) != CONTEXT_MODULE_FIELDS:
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 上下文模块字段无效:{name}。"
)
_validate_context_code(value.get("moduleCode"), f"{name}.moduleCode")
_validate_context_code(
value.get("navigationCode"), f"{name}.navigationCode"
)
_validate_context_text(value.get("moduleName"), f"{name}.moduleName", 500)
return value
def _validate_context_projection(data: Any) -> dict[str, Any]:
"""Validate the exact ERP context before exposing it to the model."""
if not isinstance(data, dict) or set(data) != CONTEXT_RESPONSE_FIELDS:
raise ErpBridgeError(
"bridge_protocol_error", "ERP 上下文字段不完整或包含未知字段。"
)
_validate_context_code(data.get("userId"), "userId")
_validate_context_text(data.get("userName"), "userName", 500)
_validate_context_text(data.get("accountBook"), "accountBook", 500)
_validate_context_code(data.get("subSystemId"), "subSystemId")
database_scope = _validate_context_text(
data.get("databaseScopeFingerprint"),
"databaseScopeFingerprint",
64,
)
if SAFE_HASH.fullmatch(database_scope) is None:
raise ErpBridgeError(
"bridge_protocol_error", "ERP 数据库作用域指纹格式无效。"
)
_validate_context_text(data.get("subSystemName"), "subSystemName", 500)
if type(data.get("isAdministrator")) is not bool:
raise ErpBridgeError(
"bridge_protocol_error", "ERP 上下文管理员标识格式无效。"
)
open_count = data.get("openModuleCount")
truncated = data.get("openModulesTruncated")
open_modules = data.get("openModules")
if (
not isinstance(open_count, int)
or isinstance(open_count, bool)
or open_count < 0
or open_count > 100_000
or type(truncated) is not bool
or not isinstance(open_modules, list)
or len(open_modules) > CONTEXT_MAX_OPEN_MODULES
or open_count < len(open_modules)
or truncated != (open_count > len(open_modules))
):
raise ErpBridgeError(
"bridge_protocol_error", "ERP 上下文打开模块统计不一致。"
)
validated_open_modules = [
_validate_context_module(item, f"openModules[{index}]")
for index, item in enumerate(open_modules)
]
active = data.get("activeModule")
if active is not None:
validated_active = _validate_context_module(active, "activeModule")
if open_count == 0 or (
not truncated and validated_active not in validated_open_modules
):
raise ErpBridgeError(
"bridge_protocol_error", "ERP 活动模块与已打开模块不一致。"
)
return data
def _validate_capability_text(value: Any, name: str, maximum_length: int) -> str:
if (
not isinstance(value, str)
or not value.strip()
or len(value) > maximum_length
or any(ord(character) < 32 for character in value)
):
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 能力字段格式无效:{name}。"
)
return value
def _validate_schema_integer(
value: Any,
name: str,
minimum: int,
maximum: int,
) -> int:
if (
not isinstance(value, int)
or isinstance(value, bool)
or value < minimum
or value > maximum
):
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 输入 Schema 约束无效:{name}。"
)
return value
def _validate_schema_number(value: Any, name: str) -> float | int:
try:
numeric = float(value)
except (OverflowError, TypeError, ValueError):
numeric = math.nan
if (
not isinstance(value, (int, float))
or isinstance(value, bool)
or not math.isfinite(numeric)
or not (-1e18 <= numeric <= 1e18)
):
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 输入 Schema 数值约束无效:{name}。"
)
return value
def _validate_input_schema(
schema: Any,
name: str,
depth: int = 0,
node_budget: list[int] | None = None,
) -> dict[str, Any]:
if node_budget is None:
node_budget = [0]
node_budget[0] += 1
if (
depth > SCHEMA_MAX_DEPTH
or node_budget[0] > SCHEMA_MAX_NODES
or not isinstance(schema, dict)
):
raise ErpBridgeError(
"bridge_protocol_error", "ERP 输入 Schema 结构超出安全边界。"
)
schema_type = schema.get("type")
if schema_type == "object":
if set(schema) != {
"type",
"properties",
"required",
"additionalProperties",
}:
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 对象 Schema 字段无效:{name}。"
)
properties = schema.get("properties")
required = schema.get("required")
if (
not isinstance(properties, dict)
or len(properties) > SCHEMA_MAX_PROPERTIES
or not isinstance(required, list)
or len(required) > len(properties)
or schema.get("additionalProperties") is not False
):
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 对象 Schema 约束无效:{name}。"
)
property_names = list(properties)
if any(
not isinstance(item, str)
or SAFE_SCHEMA_PROPERTY.fullmatch(item) is None
for item in property_names
):
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 对象 Schema 属性名无效:{name}。"
)
if (
any(not isinstance(item, str) for item in required)
or len(set(required)) != len(required)
or any(item not in properties for item in required)
):
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 对象 Schema 必填字段无效:{name}。"
)
for property_name, child in properties.items():
_validate_input_schema(
child,
f"{name}.properties.{property_name}",
depth + 1,
node_budget,
)
return schema
if schema_type == "string":
_validate_capability_text(
schema.get("description"), f"{name}.description", 1000
)
allowed = {"type", "description", "minLength", "maxLength", "format"}
if not set(schema).issubset(allowed):
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 字符串 Schema 字段无效:{name}。"
)
minimum = (
_validate_schema_integer(schema["minLength"], f"{name}.minLength", 0, 1_000_000)
if "minLength" in schema
else 0
)
maximum = (
_validate_schema_integer(schema["maxLength"], f"{name}.maxLength", 0, 1_000_000)
if "maxLength" in schema
else None
)
if maximum is not None and maximum < minimum:
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 字符串 Schema 长度无效:{name}。"
)
if "format" in schema and schema.get("format") not in {
"date",
"date-time",
"local-date-time",
}:
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 字符串 Schema 格式无效:{name}。"
)
return schema
if schema_type == "number":
_validate_capability_text(
schema.get("description"), f"{name}.description", 1000
)
if not set(schema).issubset(
{"type", "description", "minimum", "maximum"}
) or "minimum" not in schema:
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 数值 Schema 字段无效:{name}。"
)
minimum_number = _validate_schema_number(
schema.get("minimum"), f"{name}.minimum"
)
maximum_number = (
_validate_schema_number(schema.get("maximum"), f"{name}.maximum")
if "maximum" in schema
else None
)
if maximum_number is not None and maximum_number < minimum_number:
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 数值 Schema 范围无效:{name}。"
)
return schema
if schema_type == "boolean":
_validate_capability_text(
schema.get("description"), f"{name}.description", 1000
)
if set(schema) != {"type", "description"}:
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 布尔 Schema 字段无效:{name}。"
)
return schema
if schema_type == "array":
if not set(schema).issubset(
{"type", "items", "minItems", "maxItems"}
) or not {"type", "items", "minItems"}.issubset(schema):
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 数组 Schema 字段无效:{name}。"
)
minimum_items = _validate_schema_integer(
schema.get("minItems"), f"{name}.minItems", 0, 10_000
)
maximum_items = (
_validate_schema_integer(
schema.get("maxItems"), f"{name}.maxItems", 0, 10_000
)
if "maxItems" in schema
else None
)
if maximum_items is not None and maximum_items < minimum_items:
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 数组 Schema 数量无效:{name}。"
)
_validate_input_schema(
schema.get("items"), f"{name}.items", depth + 1, node_budget
)
return schema
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 输入 Schema 类型不受支持:{name}。"
)
def _validate_capabilities_projection(data: Any) -> dict[str, Any]:
"""Validate permission-filtered command descriptors before model exposure."""
if not isinstance(data, dict) or set(data) != CAPABILITIES_RESPONSE_FIELDS:
raise ErpBridgeError(
"bridge_protocol_error", "ERP 能力响应字段不完整或包含未知字段。"
)
commands = data.get("commands")
if not isinstance(commands, list) or len(commands) > CAPABILITIES_MAX_COMMANDS:
raise ErpBridgeError(
"bridge_protocol_error", "ERP 能力命令数量超出安全边界。"
)
seen: set[str] = set()
for index, descriptor in enumerate(commands):
label = f"commands[{index}]"
if (
not isinstance(descriptor, dict)
or set(descriptor) != CAPABILITY_DESCRIPTOR_FIELDS
):
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 命令描述字段无效:{label}。"
)
command_name = _validate_capability_text(
descriptor.get("name"), f"{label}.name", 128
)
if SAFE_COMMAND.fullmatch(command_name) is None:
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 命令名称格式无效:{label}。"
)
normalized_name = command_name.lower()
if normalized_name in seen:
raise ErpBridgeError(
"bridge_protocol_error", "ERP 能力响应包含重复命令。"
)
seen.add(normalized_name)
version = _validate_capability_text(
descriptor.get("version"), f"{label}.version", 32
)
schema_version = _validate_capability_text(
descriptor.get("schemaVersion"), f"{label}.schemaVersion", 32
)
if (
SAFE_VERSION.fullmatch(version) is None
or SAFE_VERSION.fullmatch(schema_version) is None
):
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 命令版本格式无效:{label}。"
)
_validate_capability_text(
descriptor.get("description"), f"{label}.description", 1000
)
risk = descriptor.get("risk")
confirmation = descriptor.get("requiresConfirmation")
idempotency = descriptor.get("requiresIdempotencyKey")
if (
risk not in PLAN_RISKS
or type(confirmation) is not bool
or type(idempotency) is not bool
or (risk in {"write", "critical"} and not (confirmation and idempotency))
or (risk not in {"write", "critical"} and (confirmation or idempotency))
):
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 命令风险语义无效:{label}。"
)
input_schema = _validate_input_schema(
descriptor.get("inputSchema"), f"{label}.inputSchema"
)
if input_schema.get("type") != "object":
raise ErpBridgeError(
"bridge_protocol_error", f"ERP 命令输入根 Schema 无效:{label}。"
)
return data
def _bind_plan_correlation(
method: str,
data: dict[str, Any],
correlation_id: str,
) -> dict[str, Any]:
"""Project the validated ERP correlation into plan tool data.
The value comes from the trusted request envelope, never from model input or
response data. Non-plan calls keep their existing contract unchanged.
"""
if method != "command.plan":
return data
if set(data) != {"plan"}:
raise ErpBridgeError(
"bridge_protocol_error", "ERP 计划响应字段不完整或包含未知字段。"
)
_validate_plan_projection(data.get("plan"))
projected = dict(data)
projected["bridgeCorrelationId"] = correlation_id
return projected
def _project_response_data(
method: str,
data: dict[str, Any],
correlation_id: str,
) -> dict[str, Any]:
if method == "context.get":
return _validate_context_projection(data)
if method == "capabilities.list":
return _validate_capabilities_projection(data)
return _bind_plan_correlation(method, data, correlation_id)
def _validate_connected_server(
discovery: ErpBridgeDiscovery,
server_process_id: int | None,
process_started_at_utc: datetime | None,
) -> None:
if (
not isinstance(server_process_id, int)
or isinstance(server_process_id, bool)
or server_process_id != discovery.process_id
or process_started_at_utc is None
or not _process_start_matches(discovery, process_started_at_utc)
):
raise ErpBridgeError(
"bridge_server_identity_mismatch",
"ERP 命令桥服务进程身份不匹配,连接已关闭。",
)
def _process_start_matches(
discovery: ErpBridgeDiscovery,
actual_started_at_utc: datetime,
) -> bool:
return (
abs(
(actual_started_at_utc - discovery.started_at_utc).total_seconds()
)
<= MAX_PROCESS_START_DRIFT_SECONDS
)
def parse_discovery(text: str) -> ErpBridgeDiscovery:
value = _strict_json_loads(text, "ERP 桥发现文件")
if not isinstance(value, dict):
raise ErpBridgeError(
"bridge_protocol_error", "ERP 桥发现文件必须是 JSON 对象。"
)
if set(value) != ALLOWED_DISCOVERY_FIELDS:
raise ErpBridgeError(
"bridge_protocol_error", "ERP 桥发现文件字段不完整或包含未知字段。"
)
protocol = _required_string(value, "protocolVersion", 16)
pipe_name = _required_string(value, "pipeName", 64)
process_id = value.get("processId")
started_text = _required_string(value, "startedAtUtc", 64)
bridge_instance_id = _required_string(value, "bridgeInstanceId", 32)
pipe_match = SAFE_PIPE.fullmatch(pipe_name)
if (
protocol != PROTOCOL_VERSION
or not isinstance(process_id, int)
or isinstance(process_id, bool)
or process_id <= 0
or process_id > 2_147_483_647
or pipe_match is None
or int(pipe_match.group(1)) != process_id
or SAFE_BRIDGE_INSTANCE_ID.fullmatch(bridge_instance_id) is None
or pipe_match.group(2) != bridge_instance_id
):
raise ErpBridgeError(
"bridge_protocol_error", "ERP 桥发现文件字段格式无效。"
)
try:
normalized = started_text[:-1] + "+00:00" if started_text.endswith("Z") else started_text
started_at = datetime.fromisoformat(normalized)
except ValueError as error:
raise ErpBridgeError(
"bridge_protocol_error", "ERP 桥发现文件启动时间无效。"
) from error
if started_at.tzinfo is None:
raise ErpBridgeError(
"bridge_protocol_error", "ERP 桥发现文件启动时间必须包含时区。"
)
return ErpBridgeDiscovery(
pipe_name=pipe_name,
process_id=process_id,
started_at_utc=started_at.astimezone(timezone.utc),
bridge_instance_id=bridge_instance_id,
)
def read_exactly(stream: BinaryIO, length: int) -> bytes:
if length < 0:
raise ErpBridgeError("bridge_protocol_error", "消息长度无效。")
chunks: list[bytes] = []
remaining = length
while remaining:
chunk = stream.read(remaining)
if not chunk:
raise ErpBridgeError("bridge_disconnected", "ERP 桥在完整响应到达前关闭。")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def write_frame(stream: BinaryIO, body: bytes) -> None:
if not body or len(body) > MAX_MESSAGE_BYTES:
raise ErpBridgeError("bridge_protocol_error", "桥请求长度无效或超过 1 MB。")
_write_all(stream, struct.pack("<I", len(body)))
_write_all(stream, body)
stream.flush()
def _write_all(stream: BinaryIO, body: bytes) -> None:
remaining = memoryview(body)
while remaining:
written = stream.write(remaining)
if not isinstance(written, int) or written <= 0:
raise ErpBridgeError("bridge_disconnected", "ERP 桥在请求写完前关闭。")
remaining = remaining[written:]
def read_frame(stream: BinaryIO) -> bytes:
(length,) = struct.unpack("<I", read_exactly(stream, 4))
if length <= 0 or length > MAX_MESSAGE_BYTES:
raise ErpBridgeError("bridge_protocol_error", "ERP 桥响应长度无效或超过 1 MB。")
return read_exactly(stream, length)
def _available_pipe_bytes(stream: BinaryIO) -> int:
try:
import msvcrt
handle = msvcrt.get_osfhandle(stream.fileno())
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
peek_named_pipe = kernel32.PeekNamedPipe
peek_named_pipe.argtypes = [
ctypes.c_void_p,
ctypes.c_void_p,
ctypes.c_uint32,
ctypes.POINTER(ctypes.c_uint32),
ctypes.POINTER(ctypes.c_uint32),
ctypes.POINTER(ctypes.c_uint32),
]
peek_named_pipe.restype = ctypes.c_int
available = ctypes.c_uint32(0)
succeeded = peek_named_pipe(
ctypes.c_void_p(handle),
None,
0,
None,
ctypes.byref(available),
None,
)
if not succeeded:
error_code = ctypes.get_last_error()
if error_code in _PIPE_CLOSED_ERRORS:
raise ErpBridgeError(
"bridge_disconnected", "ERP 桥在完整响应到达前关闭。"
)
raise ErpBridgeError("bridge_unavailable", "无法读取 ERP 命名管道。")
return int(available.value)
except ErpBridgeError:
raise
except (AttributeError, OSError, ValueError) as error:
raise ErpBridgeError("bridge_unavailable", "无法检查 ERP 命名管道状态。") from error
def read_exactly_until(
stream: BinaryIO,
length: int,
deadline: float,
available_bytes: Callable[[BinaryIO], int] = _available_pipe_bytes,
) -> bytes:
if length < 0:
raise ErpBridgeError("bridge_protocol_error", "消息长度无效。")
chunks: list[bytes] = []
remaining = length
while remaining:
if time.monotonic() >= deadline:
raise ErpBridgeError("bridge_timeout", "调用 ERP 命令桥超时。")
available = available_bytes(stream)
if available <= 0:
time.sleep(0.01)
continue
chunk = stream.read(min(remaining, available))
if not chunk:
raise ErpBridgeError("bridge_disconnected", "ERP 桥在完整响应到达前关闭。")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def read_frame_until(
stream: BinaryIO,
deadline: float,
available_bytes: Callable[[BinaryIO], int] = _available_pipe_bytes,
) -> bytes:
(length,) = struct.unpack(
"<I", read_exactly_until(stream, 4, deadline, available_bytes)
)
if length <= 0 or length > MAX_MESSAGE_BYTES:
raise ErpBridgeError("bridge_protocol_error", "ERP 桥响应长度无效或超过 1 MB。")
return read_exactly_until(stream, length, deadline, available_bytes)
class LocalErpBridgeClient:
"""AstrBot 的本机只读/计划客户端;代码层面不提供 execute 方法。"""
def __init__(
self,
discovery_directory: str = "",
connect_timeout_ms: int = 5000,
call_timeout_ms: int = 30_000,
) -> None:
self.discovery_directory = discovery_directory.strip()
self.connect_timeout_ms = max(250, min(int(connect_timeout_ms), 30_000))
self.call_timeout_ms = max(1000, min(int(call_timeout_ms), 300_000))
self._bridge_instance_lock = threading.Lock()
self._bound_bridge_instances: OrderedDict[
str, tuple[int, datetime, str]
] = OrderedDict()
async def call(
self,
method: str,
payload: dict[str, Any] | None = None,
*,
client_session_id: str,
target_process_id: int | None = None,
target_started_at_unix_seconds: int | None = None,
correlation_id: str | None = None,
session_scope_token: str | None = None,
) -> dict[str, Any]:
if method not in SAFE_METHODS:
raise ErpBridgeError(
"method_not_allowed",
"AstrBot 插件只允许读取 ERP 状态和生成计划,不能执行计划。",
)
if not isinstance(client_session_id, str) or SAFE_IDENTIFIER.fullmatch(
client_session_id
) is None:
raise ErpBridgeError(
"client_session_invalid", "AstrBot ERP 桥客户端会话格式无效。"
)
if target_process_id is not None and (
not isinstance(target_process_id, int)
or isinstance(target_process_id, bool)
or target_process_id <= 0
or target_process_id > 2_147_483_647
):
raise ErpBridgeError(
"bridge_process_id_invalid", "ERP 目标进程 ID 格式无效。"
)
if target_started_at_unix_seconds is not None and (
target_process_id is None
or not isinstance(target_started_at_unix_seconds, int)
or isinstance(target_started_at_unix_seconds, bool)
or target_started_at_unix_seconds <= 0
or target_started_at_unix_seconds > 253_402_300_799
):
raise ErpBridgeError(
"bridge_process_binding_invalid",
"ERP 目标进程启动时间指纹无效。",
)
if correlation_id is not None and (
not isinstance(correlation_id, str)
or SAFE_IDENTIFIER.fullmatch(correlation_id) is None
):
raise ErpBridgeError(
"correlation_id_invalid", "ERP 桥关联 ID 格式无效。"
)
if session_scope_token is not None and (
not isinstance(session_scope_token, str)
or SAFE_SESSION_SCOPE_TOKEN.fullmatch(session_scope_token) is None
):
raise ErpBridgeError(
"bridge_session_scope_token_invalid",
"ERP 会话作用域令牌格式无效。",
)
effective_payload = {} if payload is None else payload
if not isinstance(effective_payload, dict):
raise ErpBridgeError("invalid_input", "ERP 桥 payload 必须是 JSON 对象。")
if method == "command.plan":
if set(effective_payload) != {"command", "input"}:
raise ErpBridgeError("invalid_input", "计划 payload 字段不完整或包含未知字段。")
command = effective_payload.get("command")
if not isinstance(command, str) or not SAFE_COMMAND.fullmatch(command):
raise ErpBridgeError("invalid_command", "命令名格式无效。")
command_input = effective_payload.get("input")
if not isinstance(command_input, dict):
raise ErpBridgeError("invalid_input", "命令 input 必须是 JSON 对象。")
elif effective_payload:
raise ErpBridgeError("invalid_input", "该 ERP 桥方法不接受 payload 字段。")
return await asyncio.to_thread(
self._call_sync,
method,
effective_payload,
client_session_id,
target_process_id,
target_started_at_unix_seconds,
correlation_id,
session_scope_token,
)
def _call_sync(
self,
method: str,
payload: dict[str, Any],
client_session_id: str,
target_process_id: int | None,
target_started_at_unix_seconds: int | None,
requested_correlation_id: str | None,
session_scope_token: str | None,
) -> dict[str, Any]:
if os.name != "nt":
raise ErpBridgeError("windows_required", "本机 ERP 命名管道只支持 Windows。")
call_deadline = time.monotonic() + self.call_timeout_ms / 1000
discovery = self._find_discovery(
target_process_id, target_started_at_unix_seconds
)
self._bind_bridge_instance(client_session_id, discovery)
request_id = uuid.uuid4().hex
correlation_id = requested_correlation_id or uuid.uuid4().hex
request = {
"protocolVersion": PROTOCOL_VERSION,
"requestId": request_id,
"correlationId": correlation_id,
"clientSessionId": client_session_id,
"method": method,
"payload": payload,
}
if session_scope_token is not None:
request["sessionScopeToken"] = session_scope_token
try:
encoded = json.dumps(
request,
ensure_ascii=False,
separators=(",", ":"),
allow_nan=False,
).encode("utf-8")
except (TypeError, ValueError, RecursionError) as error:
raise ErpBridgeError("invalid_input", "ERP 桥请求不是有效 JSON。") from error
connect_deadline = min(
call_deadline, time.monotonic() + self.connect_timeout_ms / 1000
)
stream: BinaryIO | None = None
pipe_path = rf"\\.\pipe\{discovery.pipe_name}"
while stream is None:
try:
stream = open(pipe_path, "r+b", buffering=0)
except OSError as error:
if time.monotonic() >= connect_deadline:
raise ErpBridgeError("bridge_timeout", "连接 ERP 命令桥超时。") from error
time.sleep(0.05)
with stream:
_validate_connected_server(
discovery,
self._named_pipe_server_process_id(stream),
self._process_started_at_utc(discovery.process_id),
)
write_frame(stream, encoded)
try:
response_text = read_frame_until(stream, call_deadline).decode("utf-8")
except UnicodeDecodeError as error:
raise ErpBridgeError("bridge_protocol_error", "ERP 桥返回了无效 JSON。") from error
return _project_response_data(
method,
parse_response(response_text, request_id, correlation_id),
correlation_id,
)
def _bind_bridge_instance(
self,
client_session_id: str,
discovery: ErpBridgeDiscovery,
) -> None:
binding = (
discovery.process_id,
discovery.started_at_utc,
discovery.bridge_instance_id,
)
with self._bridge_instance_lock:
previous = self._bound_bridge_instances.get(client_session_id)
if previous is not None and previous != binding:
raise ErpBridgeError(
"erp_bridge_instance_changed",
"ERP 已重新登录或命令桥已经重建,请重新启动桌宠以建立新会话。",
)
self._bound_bridge_instances[client_session_id] = binding
self._bound_bridge_instances.move_to_end(client_session_id)
while len(self._bound_bridge_instances) > MAX_BOUND_BRIDGE_INSTANCES:
self._bound_bridge_instances.popitem(last=False)
def _find_pipe(
self,
target_process_id: int | None = None,
target_started_at_unix_seconds: int | None = None,
) -> str:
return self._find_discovery(
target_process_id, target_started_at_unix_seconds
).pipe_name
def _find_discovery(
self,
target_process_id: int | None = None,
target_started_at_unix_seconds: int | None = None,
) -> ErpBridgeDiscovery:
directory = self._discovery_path()
if not directory.is_dir():
raise ErpBridgeError("erp_bridge_not_running", "未发现已登录 ERP 的命令桥。")
candidates: list[tuple[float, Path]] = []
for path in directory.glob("agentbridge-*.json"):
try:
if path.is_symlink():
continue
stat = path.stat()
if stat.st_size <= 0 or stat.st_size > DISCOVERY_MAX_BYTES:
continue
candidates.append((stat.st_mtime, path))
except OSError:
continue
candidates.sort(key=lambda item: item[0], reverse=True)
live: list[ErpBridgeDiscovery] = []
for _modified, path in candidates:
try:
if path.is_symlink():
continue
value = parse_discovery(path.read_text(encoding="utf-8"))
if path.name.lower() != f"agentbridge-{value.process_id}.json":
continue
actual_start = self._process_started_at_utc(value.process_id)
if actual_start is None:
continue
if _process_start_matches(value, actual_start):
live.append(value)
except (OSError, UnicodeDecodeError, ErpBridgeError):
continue
if target_process_id is not None:
for value in live:
if value.process_id == target_process_id and (
target_started_at_unix_seconds is None
or int(value.started_at_utc.timestamp())
== target_started_at_unix_seconds
):
return value
raise ErpBridgeError(
"erp_bridge_target_not_running",
"指定的 ERP 进程未运行命令桥,请重新从目标 ERP 启动桌宠。",
)
if not live:
raise ErpBridgeError("erp_bridge_not_running", "ERP 命令桥发现文件均已失效。")
if len(live) > 1:
raise ErpBridgeError(
"erp_bridge_ambiguous",
"检测到多个已登录 ERP,必须从目标 ERP 启动桌宠或显式指定进程。",
)
return live[0]
def _discovery_path(self) -> Path:
if self.discovery_directory:
return Path(self.discovery_directory).expanduser().resolve()
local_app_data = os.environ.get("LOCALAPPDATA", "")
if not local_app_data:
raise ErpBridgeError("local_app_data_missing", "Windows LOCALAPPDATA 未配置。")
return Path(local_app_data) / "Langsu" / "Lserp" / "AgentBridge"
@staticmethod
def _process_started_at_utc(process_id: int) -> datetime | None:
try:
class FileTime(ctypes.Structure):
_fields_ = [
("low", ctypes.c_uint32),
("high", ctypes.c_uint32),
]
process_query_limited_information = 0x1000
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
open_process = kernel32.OpenProcess
open_process.argtypes = [ctypes.c_uint32, ctypes.c_int, ctypes.c_uint32]
open_process.restype = ctypes.c_void_p
get_process_times = kernel32.GetProcessTimes
get_process_times.argtypes = [
ctypes.c_void_p,
ctypes.POINTER(FileTime),
ctypes.POINTER(FileTime),
ctypes.POINTER(FileTime),
ctypes.POINTER(FileTime),
]
get_process_times.restype = ctypes.c_int
close_handle = kernel32.CloseHandle
close_handle.argtypes = [ctypes.c_void_p]
close_handle.restype = ctypes.c_int
handle = open_process(
process_query_limited_information, False, process_id
)
if not handle:
return None
creation = FileTime()
exit_time = FileTime()
kernel_time = FileTime()
user_time = FileTime()
try:
if not get_process_times(
handle,
ctypes.byref(creation),
ctypes.byref(exit_time),
ctypes.byref(kernel_time),
ctypes.byref(user_time),
):
return None
creation_filetime = (creation.high << 32) | creation.low
unix_seconds = (
creation_filetime - _WINDOWS_EPOCH_FILETIME
) / 10_000_000
return datetime.fromtimestamp(unix_seconds, timezone.utc)
finally:
close_handle(handle)
except (AttributeError, OSError, OverflowError, ValueError):
return None
@staticmethod
def _named_pipe_server_process_id(stream: BinaryIO) -> int | None:
try:
import msvcrt
handle = msvcrt.get_osfhandle(stream.fileno())
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
get_server_process_id = kernel32.GetNamedPipeServerProcessId
get_server_process_id.argtypes = [
ctypes.c_void_p,
ctypes.POINTER(ctypes.c_uint32),
]
get_server_process_id.restype = ctypes.c_int
server_process_id = ctypes.c_uint32(0)
if not get_server_process_id(
ctypes.c_void_p(handle), ctypes.byref(server_process_id)
):
return None
return int(server_process_id.value)
except (AttributeError, OSError, OverflowError, ValueError):
return None