Files
workphone-sdk/sdk/app/services/connection_provider.py

499 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
设备连接方案 · 可切换驱动层Connection Provider Manager
产品真源(卡若 2026-05-29
存客宝四端(存客宝/触客宝/AI数智员工/SuperAdmin的「所有设备连接」需要一个
**可切换的多方案开关**——同一套业务接口(/api/v3/* · BFF /v1/workphone/*)背后,
可在以下「设备连接解决方案」之间一键切换,且都能直接使用:
1. jiqing 机擎工作手机(本 SDK 原生WebSocket Agent + 设备本机 Fridadevice_transport—— 默认
2. aochuang 奥创工作手机007 私域管理 HTTP API · 007.siyuguanli.com
3. legacy 现有连接形式S2 / 旧接口 / 自建工作手机,配置式 HTTP 驱动)
4. custom_* 预留接口(配置式 HTTP 驱动模板,新增方案无需改代码即可接入)
设计要点:
- 统一 Provider 抽象 `BaseConnectionProvider`list_devices / send_message / get_contacts / execute / health。
- `ConnectionProviderManager`:注册表 + 三级开关(全局 / 按项目 / 按设备)+ 持久化。
- 切换不影响既有 339 路由jiqing 直接复用 device_transport不绕过无线主控铁律
- http 驱动用「端点映射 + 字段映射」描述任意第三方工作手机 API确保「预留其他方案」可落地。
真源文档:开发文档/1、需求/修改/工作手机_设备Agent与基础设施_20260529.md §3.6 连接方案可切换驱动层
"""
from __future__ import annotations
import json
import logging
import os
import threading
import time
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# 持久化配置路径(与 hook/device_modules.json 同级 data 目录)
_DATA_DIR = Path(__file__).resolve().parents[1] / "data"
_CONFIG_PATH = _DATA_DIR / "connection_providers.json"
# 切换作用域
SCOPE_GLOBAL = "global"
SCOPE_PROJECT = "project"
SCOPE_DEVICE = "device"
# 规范化动作canonical action——所有 Provider 须能映射这些动作
CANONICAL_ACTIONS = (
"list_devices",
"send_message",
"batch_send_message",
"get_contacts",
"get_messages",
"add_friend",
"post_moments",
"execute", # 通用 {script, action, params}
)
def _env(name: str, default: str = "") -> str:
return os.environ.get(name, default) or default
# =============================================================================
# Provider 抽象
# =============================================================================
class BaseConnectionProvider(ABC):
"""单个「设备连接方案/解决方案」的统一抽象。"""
kind: str = "base"
def __init__(self, config: Dict[str, Any]):
self.id: str = config.get("id", "")
self.name: str = config.get("name", self.id)
self.enabled: bool = bool(config.get("enabled", True))
self.builtin: bool = bool(config.get("builtin", False))
self.config: Dict[str, Any] = config
# --- 元信息 ---
def meta(self) -> Dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"kind": self.kind,
"enabled": self.enabled,
"builtin": self.builtin,
"capabilities": self.capabilities(),
"desc": self.config.get("desc", ""),
}
def capabilities(self) -> List[str]:
return list(CANONICAL_ACTIONS)
# --- 健康检查 ---
@abstractmethod
async def health(self) -> Dict[str, Any]:
...
# --- 业务执行(统一入口)---
@abstractmethod
async def execute(self, device_id: str, script: str, action: str, params: Dict[str, Any],
*, timeout: int = 120) -> Dict[str, Any]:
...
async def list_devices(self) -> Dict[str, Any]:
return await self.execute("", "system", "list_devices", {})
async def send_message(self, device_id: str, platform: str, to_id: str, content: str,
*, msg_type: str = "text", **kw) -> Dict[str, Any]:
params = {"to_id": to_id, "content": content, "msg_type": msg_type, **kw}
return await self.execute(device_id, platform, "send_message", params)
# =============================================================================
# 1. 机擎原生 Provider默认 — 复用 device_transport无线主控铁律
# =============================================================================
class JiqingNativeProvider(BaseConnectionProvider):
"""机擎工作手机:本 SDK 原生通道WebSocket Agent + 设备本机 Frida"""
kind = "native"
async def health(self) -> Dict[str, Any]:
try:
from services.ws_hub import ws_hub
return {
"provider": self.id,
"ok": True,
"ws_online": len(ws_hub.connections),
"online_device_ids": list(ws_hub.connections.keys()),
}
except Exception as e: # pragma: no cover
return {"provider": self.id, "ok": False, "error": str(e)}
async def list_devices(self) -> Dict[str, Any]:
from services.ws_hub import ws_hub
devices = ws_hub.get_online_devices()
return {"code": 200, "success": True, "provider": self.id,
"data": devices, "count": len(devices)}
async def execute(self, device_id: str, script: str, action: str, params: Dict[str, Any],
*, timeout: int = 120) -> Dict[str, Any]:
if action == "list_devices":
return await self.list_devices()
from services.device_transport import device_transport
hook_only = device_transport.should_force_hook_only(script, action, bool(params.get("hook_only")))
result = await device_transport.execute_via_ws(
device_id, script, action, params, timeout=timeout, hook_only=hook_only,
)
result.setdefault("provider", self.id)
return result
# =============================================================================
# 2/3/4. 配置式 HTTP Provider奥创 / 现有连接 / 自定义预留)
# =============================================================================
class HttpConnectionProvider(BaseConnectionProvider):
"""
配置式 HTTP 工作手机驱动——通过「端点映射 + 字段映射」对接任意第三方工作手机 API。
config 示例(奥创 007 私域):
{
"id": "aochuang", "name": "奥创工作手机(007私域)", "kind": "http", "enabled": false,
"base_url": "https://007.siyuguanli.com/api",
"auth": {"type": "bearer", "token": "", "token_env": "AOCHUANG_TOKEN", "header": "Authorization"},
"endpoints": {
"list_devices": {"method": "GET", "path": "/devices", "data_path": "data"},
"send_message": {"method": "POST", "path": "/message/send",
"body_map": {"device_id": "deviceId", "to_id": "wxid", "content": "content"}},
"get_contacts": {"method": "GET", "path": "/wechat/contacts", "query_map": {"device_id": "deviceId"}}
}
}
"""
kind = "http"
def base_url(self) -> str:
return (self.config.get("base_url") or _env(self.config.get("base_url_env", ""))).rstrip("/")
def _auth_headers(self) -> Dict[str, str]:
auth = self.config.get("auth") or {}
token = auth.get("token") or _env(auth.get("token_env", ""))
if not token:
return {}
header = auth.get("header", "Authorization")
atype = (auth.get("type") or "bearer").lower()
if atype == "bearer":
return {header: f"Bearer {token}"}
if atype == "apikey":
return {header: token}
return {header: token}
def capabilities(self) -> List[str]:
eps = self.config.get("endpoints") or {}
caps = [a for a in CANONICAL_ACTIONS if a in eps]
return caps or ["execute"]
def _endpoint(self, action: str) -> Optional[Dict[str, Any]]:
return (self.config.get("endpoints") or {}).get(action)
@staticmethod
def _apply_map(src: Dict[str, Any], mapping: Optional[Dict[str, str]]) -> Dict[str, Any]:
if not mapping:
return dict(src)
out: Dict[str, Any] = {}
for canon, target in mapping.items():
if canon in src and src[canon] is not None:
out[target] = src[canon]
# 透传未在映射表中的字段(保守:保留原键)
for k, v in src.items():
if k not in mapping and k not in out and v is not None:
out[k] = v
return out
async def _request(self, method: str, path: str, *, query: Optional[Dict] = None,
body: Optional[Dict] = None, timeout: int = 60) -> Dict[str, Any]:
base = self.base_url()
if not base:
return {"code": 503, "success": False, "provider": self.id,
"message": f"连接方案 {self.id} 未配置 base_url请先配置/启用)"}
url = base + path
headers = {"Content-Type": "application/json", **self._auth_headers()}
try:
import httpx # type: ignore
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.request(method.upper(), url, params=query, json=body, headers=headers)
try:
data = resp.json()
except Exception:
data = {"raw": resp.text}
ok = 200 <= resp.status_code < 300
return {"code": resp.status_code, "success": ok, "provider": self.id, "data": data,
"_channel_used": f"http/{self.id}"}
except ImportError:
# 无 httpx 时退回标准库(同步,放线程池)
import asyncio
return await asyncio.get_running_loop().run_in_executor(
None, lambda: self._request_urllib(method, url, headers, query, body, timeout))
except Exception as e:
return {"code": 502, "success": False, "provider": self.id,
"message": f"连接方案 {self.id} 请求失败: {e}", "_channel_used": f"http/{self.id}"}
def _request_urllib(self, method: str, url: str, headers: Dict[str, str],
query: Optional[Dict], body: Optional[Dict], timeout: int) -> Dict[str, Any]:
import urllib.parse
import urllib.request
try:
if query:
url = f"{url}?{urllib.parse.urlencode(query)}"
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method.upper())
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8")
try:
parsed = json.loads(raw)
except Exception:
parsed = {"raw": raw}
return {"code": resp.status, "success": 200 <= resp.status < 300,
"provider": self.id, "data": parsed, "_channel_used": f"http/{self.id}"}
except Exception as e:
return {"code": 502, "success": False, "provider": self.id,
"message": f"连接方案 {self.id} 请求失败: {e}", "_channel_used": f"http/{self.id}"}
async def health(self) -> Dict[str, Any]:
base = self.base_url()
if not base:
return {"provider": self.id, "ok": False, "error": "未配置 base_url"}
hc = self.config.get("health") or {}
method = hc.get("method", "GET")
path = hc.get("path", "/health")
res = await self._request(method, path, timeout=10)
return {"provider": self.id, "ok": bool(res.get("success")), "detail": res}
async def execute(self, device_id: str, script: str, action: str, params: Dict[str, Any],
*, timeout: int = 120) -> Dict[str, Any]:
ep = self._endpoint(action)
if not ep:
return {"code": 501, "success": False, "provider": self.id,
"message": f"连接方案 {self.id} 未定义动作映射: {action}(可在 provider.endpoints 中配置)"}
method = ep.get("method", "POST")
path = ep.get("path", "/")
src = {"device_id": device_id, "platform": script, **(params or {})}
query = None
body = None
if method.upper() == "GET":
query = self._apply_map(src, ep.get("query_map"))
else:
body = self._apply_map(src, ep.get("body_map"))
res = await self._request(method, path, query=query, body=body, timeout=timeout)
# 可选 data_path 提取
data_path = ep.get("data_path")
if data_path and isinstance(res.get("data"), dict):
res["data"] = res["data"].get(data_path, res["data"])
return res
# =============================================================================
# 管理器
# =============================================================================
class ConnectionProviderManager:
"""连接方案注册表 + 三级开关 + 持久化。"""
def __init__(self):
self._lock = threading.RLock()
self._providers: Dict[str, BaseConnectionProvider] = {}
self._active: Dict[str, Any] = {"global": "jiqing", "projects": {}, "devices": {}}
self._loaded = False
# ---- 默认配置 ----
@staticmethod
def _default_config() -> Dict[str, Any]:
return {
"active": {"global": "jiqing", "projects": {}, "devices": {}},
"providers": {
"jiqing": {
"id": "jiqing", "name": "机擎工作手机本SDK原生·WS+Frida",
"kind": "native", "enabled": True, "builtin": True,
"desc": "默认方案WebSocket Agent + 设备本机 Frida经 device_transport 无线主控",
},
"aochuang": {
"id": "aochuang", "name": "奥创工作手机007私域",
"kind": "http", "enabled": False, "builtin": True,
"desc": "奥创云脑工作手机 007 私域管理 HTTP APIHook 注入方案)",
"base_url": "", "base_url_env": "AOCHUANG_BASE_URL",
"auth": {"type": "bearer", "token": "", "token_env": "AOCHUANG_TOKEN",
"header": "Authorization"},
"health": {"method": "GET", "path": "/devices"},
"endpoints": {
"list_devices": {"method": "GET", "path": "/devices", "data_path": "data"},
"send_message": {"method": "POST", "path": "/message/send",
"body_map": {"device_id": "deviceId", "to_id": "wxid",
"content": "content", "msg_type": "msgType"}},
"batch_send_message": {"method": "POST", "path": "/message/batch-send",
"body_map": {"device_id": "deviceId"}},
"get_contacts": {"method": "GET", "path": "/wechat/contacts",
"query_map": {"device_id": "deviceId"}, "data_path": "data"},
"add_friend": {"method": "POST", "path": "/friend/add",
"body_map": {"device_id": "deviceId", "to_id": "keyword"}},
"post_moments": {"method": "POST", "path": "/moments/post",
"body_map": {"device_id": "deviceId", "content": "content"}},
},
},
"legacy": {
"id": "legacy", "name": "现有连接形式S2/旧接口)",
"kind": "http", "enabled": False, "builtin": True,
"desc": "存客宝现有/历史设备连接(自建工作手机或 S2 旧接口),配置式 HTTP 驱动",
"base_url": "", "base_url_env": "LEGACY_PROVIDER_BASE_URL",
"auth": {"type": "apikey", "token": "", "token_env": "LEGACY_PROVIDER_TOKEN",
"header": "X-API-Key"},
"endpoints": {
"send_message": {"method": "POST", "path": "/send"},
"list_devices": {"method": "GET", "path": "/devices"},
},
},
},
}
def load(self, force: bool = False) -> None:
with self._lock:
if self._loaded and not force:
return
cfg = self._default_config()
if _CONFIG_PATH.exists():
try:
saved = json.loads(_CONFIG_PATH.read_text("utf-8"))
# 合并:保留内置 + 覆盖/追加自定义
if isinstance(saved.get("providers"), dict):
cfg["providers"].update(saved["providers"])
if isinstance(saved.get("active"), dict):
cfg["active"].update(saved["active"])
except Exception as e:
logger.warning(f"[connection_provider] 读取配置失败,用默认: {e}")
self._active = cfg["active"]
self._providers = {}
for pid, pconf in cfg["providers"].items():
pconf["id"] = pid
self._providers[pid] = self._build(pconf)
self._loaded = True
logger.info(f"[connection_provider] 已加载 {len(self._providers)} 个连接方案active.global={self._active.get('global')}")
@staticmethod
def _build(pconf: Dict[str, Any]) -> BaseConnectionProvider:
kind = (pconf.get("kind") or "http").lower()
if kind == "native":
return JiqingNativeProvider(pconf)
return HttpConnectionProvider(pconf)
def _save(self) -> None:
with self._lock:
_DATA_DIR.mkdir(parents=True, exist_ok=True)
cfg = {
"active": self._active,
"providers": {pid: p.config for pid, p in self._providers.items()},
"_updated_at": int(time.time()),
}
_CONFIG_PATH.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), "utf-8")
# ---- 查询 ----
def list_providers(self) -> List[Dict[str, Any]]:
self.load()
return [p.meta() for p in self._providers.values()]
def get(self, provider_id: str) -> Optional[BaseConnectionProvider]:
self.load()
return self._providers.get(provider_id)
def active_config(self) -> Dict[str, Any]:
self.load()
return dict(self._active)
def resolve_active_id(self, device_id: str = "", project_id: str = "") -> str:
"""优先级:设备级 > 项目级 > 全局。若解析到的方案被禁用,回退 jiqing。"""
self.load()
chosen = ""
if device_id and device_id in self._active.get("devices", {}):
chosen = self._active["devices"][device_id]
elif project_id and project_id in self._active.get("projects", {}):
chosen = self._active["projects"][project_id]
else:
chosen = self._active.get("global", "jiqing")
prov = self._providers.get(chosen)
if not prov or not prov.enabled:
if chosen != "jiqing":
logger.warning(f"[connection_provider] 方案 {chosen} 不可用/未启用,回退 jiqing")
return "jiqing"
return chosen
def resolve_active(self, device_id: str = "", project_id: str = "") -> BaseConnectionProvider:
return self._providers[self.resolve_active_id(device_id, project_id)]
# ---- 开关 ----
def switch(self, provider_id: str, scope: str = SCOPE_GLOBAL,
project_id: str = "", device_id: str = "") -> Dict[str, Any]:
self.load()
if provider_id not in self._providers:
raise ValueError(f"未知连接方案: {provider_id}")
if not self._providers[provider_id].enabled:
raise ValueError(f"连接方案 {provider_id} 未启用(请先 enable 并配置)")
with self._lock:
if scope == SCOPE_GLOBAL:
self._active["global"] = provider_id
elif scope == SCOPE_PROJECT:
if not project_id:
raise ValueError("scope=project 需 project_id")
self._active.setdefault("projects", {})[project_id] = provider_id
elif scope == SCOPE_DEVICE:
if not device_id:
raise ValueError("scope=device 需 device_id")
self._active.setdefault("devices", {})[device_id] = provider_id
else:
raise ValueError(f"未知 scope: {scope}")
self._save()
return {"active": self._active, "switched_to": provider_id, "scope": scope}
# ---- 注册/更新/删除自定义方案(预留接口)----
def register(self, pconf: Dict[str, Any]) -> Dict[str, Any]:
self.load()
pid = pconf.get("id")
if not pid:
raise ValueError("缺少 provider id")
if pid in self._providers and self._providers[pid].builtin:
# 内置方案只允许更新配置enable/base_url/endpoints不允许改 kind/builtin
# 跳过空字符串,避免清掉内置 name/desc
base = self._providers[pid].config
base.update({
k: v for k, v in pconf.items()
if k not in ("kind", "builtin", "id") and not (isinstance(v, str) and v == "")
})
pconf = base
pconf["id"] = pid
pconf.setdefault("builtin", False)
with self._lock:
self._providers[pid] = self._build(pconf)
self._save()
return self._providers[pid].meta()
def remove(self, provider_id: str) -> Dict[str, Any]:
self.load()
prov = self._providers.get(provider_id)
if not prov:
raise ValueError(f"未知连接方案: {provider_id}")
if prov.builtin:
raise ValueError("内置连接方案不可删除(可改用 enable=false 禁用)")
with self._lock:
self._providers.pop(provider_id, None)
# 清理引用
if self._active.get("global") == provider_id:
self._active["global"] = "jiqing"
for scope_key in ("projects", "devices"):
self._active[scope_key] = {
k: v for k, v in self._active.get(scope_key, {}).items() if v != provider_id
}
self._save()
return {"removed": provider_id, "active": self._active}
connection_provider_manager = ConnectionProviderManager()