499 lines
22 KiB
Python
499 lines
22 KiB
Python
"""
|
||
设备连接方案 · 可切换驱动层(Connection Provider Manager)
|
||
|
||
产品真源(卡若 2026-05-29):
|
||
存客宝四端(存客宝/触客宝/AI数智员工/SuperAdmin)的「所有设备连接」需要一个
|
||
**可切换的多方案开关**——同一套业务接口(/api/v3/* · BFF /v1/workphone/*)背后,
|
||
可在以下「设备连接解决方案」之间一键切换,且都能直接使用:
|
||
|
||
1. jiqing 机擎工作手机(本 SDK 原生:WebSocket Agent + 设备本机 Frida,device_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 API(Hook 注入方案)",
|
||
"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()
|