Files
xieyi-weixin-/wecom_sdk_client.py
2026-09-21 10:56:48 +08:00

223 lines
9.5 KiB
Python

"""Client for the NDJSON RPC contract shipped in WeComAgent's Python SDK.
Only accepts runtime credentials explicitly inherited from the SDK host.
It does not extract credentials, launch the agent, change client switches, or retry calls.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import socket
import threading
from dataclasses import dataclass, field
from typing import Mapping
MAX_FRAME_BYTES = 4 * 1024 * 1024
class SdkRpcError(RuntimeError):
def __init__(self, code, message, *, may_have_executed=False):
super().__init__(message)
self.code = code
self.may_have_executed = may_have_executed
@dataclass(frozen=True)
class RuntimeEndpoint:
host: str
port: int
token: str = field(repr=False)
def __post_init__(self):
if self.host not in ("127.0.0.1", "::1", "localhost"):
raise SdkRpcError("invalid_endpoint", "SDK 连接地址必须是本机回环地址")
if isinstance(self.port, bool) or not isinstance(self.port, int) or not 1 <= self.port <= 65535:
raise SdkRpcError("invalid_endpoint", "SDK 端口无效")
if not isinstance(self.token, str) or not self.token or len(self.token) > 65536:
raise SdkRpcError("invalid_endpoint", "SDK 授权参数无效")
@classmethod
def from_environment(cls, environ: Mapping[str, str] | None = None):
environ = os.environ if environ is None else environ
host = environ.get("WECOMAGENT_RPC_HOST", "")
port = environ.get("WECOMAGENT_RPC_PORT", "")
token = environ.get("WECOMAGENT_RPC_TOKEN", "")
if not all((host, port, token)):
raise SdkRpcError(
"sdk_runtime_unavailable",
"尚未获得企业微信 SDK 运行时连接参数;需要由客户端正常启动代理并在其 Python 工具环境中执行。",
)
try:
port = int(port)
except (TypeError, ValueError):
raise SdkRpcError("invalid_endpoint", "SDK 端口无效") from None
return cls(host, port, token)
class WeComSdkClient:
"""Serial request/response RPC. An uncertain send must be reconciled, never replayed."""
def __init__(self, endpoint: RuntimeEndpoint, *, timeout=8.0, max_frame_bytes=MAX_FRAME_BYTES):
if not math.isfinite(float(timeout)) or not 0 < float(timeout) <= 120:
raise ValueError("timeout must be within (0, 120]")
if not 256 <= max_frame_bytes <= MAX_FRAME_BYTES:
raise ValueError("invalid frame limit")
self.endpoint = endpoint
self.timeout = float(timeout)
self.max_frame_bytes = max_frame_bytes
self._socket = None
self._reader = None
self._lock = threading.Lock()
self._next_id = 1
self.server_version = ""
def _close(self):
reader, sock = self._reader, self._socket
self._reader = self._socket = None
if reader is not None:
try:
reader.close()
except OSError:
pass
if sock is not None:
try:
sock.close()
except OSError:
pass
def close(self):
with self._lock:
self._close()
def __enter__(self):
return self
def __exit__(self, *_):
self.close()
def _encode(self, value):
try:
frame = (json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":")) + "\n").encode("utf-8")
except (ValueError, TypeError, UnicodeError):
raise SdkRpcError("invalid_request", "SDK 请求不能序列化为 JSON") from None
if len(frame) > self.max_frame_bytes:
raise SdkRpcError("request_too_large", "SDK 请求超过大小限制")
return frame
def _read(self):
raw = self._reader.readline(self.max_frame_bytes + 1)
if not raw:
raise SdkRpcError("connection_closed", "SDK 连接已关闭")
if len(raw) > self.max_frame_bytes or not raw.endswith(b"\n"):
raise SdkRpcError("invalid_frame", "SDK 响应帧不完整或超过大小限制")
try:
result = json.loads(raw.decode("utf-8"))
except (ValueError, UnicodeError):
raise SdkRpcError("invalid_frame", "SDK 返回了无效的 JSON 响应") from None
if not isinstance(result, dict):
raise SdkRpcError("invalid_frame", "SDK 响应必须是 JSON 对象")
return result
def _connect(self):
try:
host = "127.0.0.1" if self.endpoint.host == "localhost" else self.endpoint.host
self._socket = socket.create_connection((host, self.endpoint.port), self.timeout)
self._socket.settimeout(self.timeout)
self._reader = self._socket.makefile("rb")
self._socket.sendall(self._encode({"type": "hello", "token": self.endpoint.token}))
welcome = self._read()
if welcome.get("type") != "welcome" or not isinstance(welcome.get("server_version"), str):
raise SdkRpcError("authorization_failed", "SDK 未接受运行时授权握手")
self.server_version = welcome["server_version"]
except SdkRpcError:
self._close()
raise
except (OSError, TimeoutError):
self._close()
raise SdkRpcError("connection_failed", "无法连接 SDK 运行时或握手超时") from None
def call_cli(self, argv: list[str]):
if not isinstance(argv, list) or not argv or not all(isinstance(value, str) and "\x00" not in value for value in argv):
raise SdkRpcError("invalid_request", "CLI 参数必须是非空字符串数组")
if not self._lock.acquire(timeout=self.timeout):
raise SdkRpcError("client_busy", "SDK 正在处理另一条请求")
attempted = False
try:
request_id = self._next_id
frame = self._encode({"id": request_id, "method": "wecom.exec", "params": {"argv": argv}})
if self._socket is None:
self._connect()
self._next_id += 1
# sendall may fail after partially writing. Mark uncertain before sending.
attempted = True
self._socket.sendall(frame)
response = self._read()
if type(response.get("id")) is not int or response["id"] != request_id:
raise SdkRpcError("response_mismatch", "SDK 响应 ID 与请求不一致")
if response.get("error") is not None:
# Do not include arbitrary server text: it may echo credentials/content.
# A generic remote error does not prove a business action was not executed.
raise SdkRpcError("remote_error", "SDK 返回调用错误,需要核对实际结果")
if "result" not in response:
raise SdkRpcError("invalid_frame", "SDK 响应缺少调用结果")
return response["result"]
except SdkRpcError as exc:
if attempted:
self._close()
raise SdkRpcError(exc.code, str(exc), may_have_executed=True) from None
raise
except (OSError, TimeoutError):
self._close()
raise SdkRpcError("rpc_interrupted", "SDK 请求中断或超时,需要核对实际结果",
may_have_executed=attempted) from None
finally:
self._lock.release()
def probe_sdk_runtime(*, environ=None, timeout=3.0):
"""Read-only handshake + CLI help. Never calls message send or business mutations."""
report = {
"available": False, "rpcVerified": False, "cliHelpVerified": False,
"sendVerified": False, "receiptVerified": False, "loginVerified": False,
}
try:
endpoint = RuntimeEndpoint.from_environment(environ)
report["available"] = True
with WeComSdkClient(endpoint, timeout=timeout) as client:
result = client.call_cli(["--help"])
report.update(rpcVerified=True, serverVersion=client.server_version)
# Preserve the response type only in ordinary diagnostics.
report["responseType"] = type(result).__name__
text = result if isinstance(result, str) else json.dumps(result, ensure_ascii=False)
error = isinstance(result, dict) and (
result.get("error") is not None or result.get("errcode", 0) != 0
or result.get("exit_code", 0) != 0)
report["cliHelpVerified"] = not error and ("Usage:" in text or "用法" in text)
report.update(status="sdk_connected", message="SDK RPC 已连通;仍需核对消息接口、在线身份与回执。")
if not report["cliHelpVerified"]:
report.update(status="sdk_contract_unverified", message="SDK RPC 已返回,CLI 帮助格式或业务结果仍待核对。")
except SdkRpcError as exc:
report.update(status=exc.code, message=str(exc))
return report
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", help="Save read-only runtime inspection JSON")
args = parser.parse_args()
report = probe_sdk_runtime()
if args.output:
from wxwork_protocol_probe import export_probe_report
export_probe_report(report, args.output)
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0 if report["rpcVerified"] else 2
if __name__ == "__main__":
import sys
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
raise SystemExit(main())