""" 设备连接方案 · 可切换驱动层(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()