Files
workphone-sdk/sdk/app/main.py
2026-08-10 22:18:38 +08:00

1445 lines
60 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.

"""
工作手机SDK v3.0 - 主入口
存客宝的AI手机控制引擎
"""
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.openapi.utils import get_openapi
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
import hmac
import base64
import hashlib
import time
from contextlib import asynccontextmanager
import logging
import os
import inspect
import json
import subprocess
import asyncio
import sys
from pathlib import Path
import markdown
from config import settings
from routers import devices, unified, agent, ai_tasks, adb, experience, projects, qrcode, voice, capture, hook_modules, connection, gateway, registry_cluster, discovery, cunke_bao, frida_wireless, wechat_full, connection_provider, integration, process, fleet, device_groups, integrations_admin, releases, workbench, kb, security_modules
from services.ws_hub import ws_hub
from services.device_manager import device_manager
from services.security_modules import security_module_registry
from services.write_gate import apply_global_openapi_contract, install_global_write_gate
from services.device_read_evidence import build_device_read_receipt, adapt_hook_probe_payload
from services.evidence_receipt import normalize_endpoint_evidence
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
_APP_DIR = os.path.dirname(__file__)
_STATIC_DIR = os.path.join(_APP_DIR, "static")
_CONSOLE_UI_DIR = os.path.join(_STATIC_DIR, "console")
_CONSOLE_UI_INDEX = os.path.join(_CONSOLE_UI_DIR, "index.html")
_DOCS_ROOT = os.path.normpath(os.path.join(_APP_DIR, "..", "..", "开发文档"))
_DOC_PAGE_MAP = {
"api-architecture": os.path.join(_DOCS_ROOT, "5、接口", "05-交互图", "API架构与交互流程图.html"),
"anti-ban-architecture": os.path.join(_DOCS_ROOT, "2、架构", "04-交互图", "防封模块架构图.html"),
"wechat-server-flow": os.path.join(_DOCS_ROOT, "2、架构", "04-交互图", "微信控制-设备服务器交互图.html"),
"dual-channel-device-map": os.path.join(_DOCS_ROOT, "2、架构", "04-交互图", "四端双通道绑定与device-map.html"),
}
_AGENT_ROOT_CANDIDATES = [
os.path.join(_APP_DIR, "agent"),
os.path.normpath(os.path.join(_APP_DIR, "..", "agent")),
]
_AGENT_ROOT = next((p for p in _AGENT_ROOT_CANDIDATES if os.path.exists(p)), _AGENT_ROOT_CANDIDATES[0])
_AGENT_DOCS = [
{
"id": "agent-readme",
"title": "Agent 架构与部署",
"summary": "设备端 Agent 总览WebSocket 连接、技能引擎、目录结构、Termux 一键安装。",
"category": "agent",
"section": "Agent 集群",
"folder": "agent",
"file": "README.md",
"keywords": ["agent", "termux", "websocket", "部署"],
},
{
"id": "agent-skills",
"title": "技能引擎 Skills",
"summary": "6 大 Skill微信/抖音/小红书/闲鱼/语音/搜索)· SkillExecutor 调度 · 复合命令。",
"category": "agent",
"section": "Agent 集群",
"folder": "agent/skills",
"file": "README.md",
"keywords": ["skill", "微信", "抖音", "技能", "executor"],
},
{
"id": "agent-hawk",
"title": "Hawk 网络恢复模块",
"summary": "Agent 与 Hawk 边界协同断网自动恢复、WiFi 开关、合法操作边界。",
"category": "agent",
"section": "Agent 集群",
"folder": "agent/docs",
"file": "AGENT_HAWK.md",
"keywords": ["hawk", "网络", "wifi", "恢复", "断网"],
},
{
"id": "agent-hook",
"title": "Hook 脚本系统",
"summary": "微信 Hook 初版脚本FridaRPC 骨架、消息监听、脚本部署流程。",
"category": "agent",
"section": "Agent 集群",
"folder": "agent/hook",
"file": "README.md",
"keywords": ["hook", "frida", "rpc", "消息监听"],
},
]
_WORKBENCH_DOCS = [
{
"id": "sdk-manual",
"title": "SDK操作手册",
"summary": "工作手机 SDK 的整体使用说明、核心能力和调用入口。",
"category": "manual",
"section": "说明文档",
"folder": "9、手册/02-操作指南",
"file": "SDK操作手册.md",
"keywords": ["SDK", "手册", "调用", "接入"],
},
{
"id": "usage-solution",
"title": "使用与落地方案",
"summary": "从部署到业务落地的整体使用路径与建议方案。",
"category": "manual",
"section": "说明文档",
"folder": "9、手册/02-操作指南",
"file": "使用与落地方案.md",
"keywords": ["方案", "落地", "部署", "使用"],
},
{
"id": "wechat-e2e",
"title": "微信消息E2E验证指南",
"summary": "微信消息链路的端到端验证步骤和排查指引。",
"category": "manual",
"section": "说明文档",
"folder": "9、手册/03-验证",
"file": "微信消息E2E验证指南.md",
"keywords": ["微信", "E2E", "验证", "排查"],
},
{
"id": "agent-install",
"title": "设备端Agent安装与公司设备说明",
"summary": "设备端 Agent 的安装方式、公司设备规范和运行说明。",
"category": "manual",
"section": "说明文档",
"folder": "9、手册/04-专项",
"file": "设备端Agent安装与公司设备说明.md",
"keywords": ["Agent", "安装", "设备", "说明"],
},
{
"id": "install-checklist",
"title": "安装前配置检查规范",
"summary": "安装前需要确认的配置、环境与风险检查项。",
"category": "manual",
"section": "说明文档",
"folder": "9、手册/04-专项",
"file": "安装前配置检查规范.md",
"keywords": ["安装", "检查", "规范", "配置"],
},
{
"id": "system-architecture",
"title": "系统架构",
"summary": "工作手机 SDK 的整体架构、模块分层与链路设计。",
"category": "architecture",
"section": "架构与经验",
"folder": "2、架构",
"file": "01-总览/系统架构.md",
"keywords": ["架构", "模块", "分层", "链路"],
},
{
"id": "hook-architecture",
"title": "Hook通道与多设备多服务器架构",
"summary": "Hook 通道、Frida 集成、多设备与多服务器的设计说明。",
"category": "architecture",
"section": "架构与经验",
"folder": "2、架构",
"file": "03-通道与设备/Hook通道与多设备多服务器架构.md",
"keywords": ["Hook", "Frida", "多设备", "多服务器"],
},
{
"id": "api-spec",
"title": "接口规范",
"summary": "统一接口的字段约定、协议与调用方式。",
"category": "api",
"section": "接口与协议",
"folder": "5、接口",
"file": "01-规范与统一层/接口规范.md",
"keywords": ["接口", "协议", "字段", "规范"],
},
{
"id": "cunkebao-spec",
"title": "存客宝对接规范",
"summary": "存客宝与工作手机 SDK 的对接方式和聚合规则。",
"category": "api",
"section": "接口与协议",
"folder": "5、接口",
"file": "02-业务对接/存客宝对接规范.md",
"keywords": ["存客宝", "对接", "SDK", "聚合"],
},
{
"id": "progress-overview",
"title": "开发进度总表",
"summary": "当前阶段、完成率、里程碑与下一步开发重点。",
"category": "project",
"section": "项目管理",
"folder": "10、项目管理",
"file": "开发进度总表.md",
"keywords": ["进度", "里程碑", "阶段", "总表"],
},
]
def _scan_arch_assets():
import urllib.parse
arch_dir = Path(_DOCS_ROOT) / "2、架构"
known = [
("arch-project-tree", "项目目录总览", "仓库顶层 sdk / 开发文档 / 机擎 结构一览。"),
("arch-sdk", "工作手机SDK架构图", "系统全景四层架构,适合做整体汇报入口。"),
("arch-flow", "设备与服务器交互流程(微信控制)", "设备、服务器与微信控制的交互过程。"),
("arch-device", "工作手机设备端运转逻辑", "设备端从启动到心跳到 Skill 执行的完整运转。"),
("arch-agent-total", "Agent与服务器工作流总图", "业务入口、服务器 Agent、设备 Agent 和消息闭环。"),
("arch-hook", "Hook与服务器交互流程图", "Hook 通道的完整交互链路与功能细节。"),
("arch-ban", "防封模块架构图", "防封中心、设备侧、行为层与服务端策略。"),
("arch-ban-detail", "防封服务端核心能力拆细图", "防封服务端 7 大模块逐一拆细。"),
("mode-hook", "本地控制模式一Hook/Frida", "Frida 深度控制微信的模式示意。"),
("mode-adb", "本地控制模式二ADB静默控制", "无需 Root 的 ADB 静默控制模式。"),
("mode-agent", "本地控制模式三Agent WebSocket远控", "远程统一控制多设备。"),
("mode-ai", "本地控制模式四AI Agent自然语言", "自然语言编排复杂任务。"),
("mode-scrcpy", "本地控制模式五scrcpy可视化控制", "投屏与人工接管。"),
]
fname_map = {
"arch-project-tree": "images/01-整体总览/工作手机项目目录总览.png",
"arch-sdk": "images/01-整体总览/工作手机SDK架构图.png",
"arch-flow": "images/03-微信与Hook/设备与服务器交互流程_微信控制.png",
"arch-device": "images/01-整体总览/工作手机设备端运转逻辑.png",
"arch-agent-total": "images/01-整体总览/卡若AI手机_Agent与服务器工作流总图.png",
"arch-hook": "images/03-微信与Hook/Hook与服务器交互流程图.png",
"arch-ban": "images/04-防封/防封模块架构图.png",
"arch-ban-detail": "images/04-防封/防封服务端核心能力拆细图.png",
"mode-hook": "images/02-控制通道/本地控制模式_1_Hook_Frida模式.png",
"mode-adb": "images/02-控制通道/本地控制模式_2_ADB静默控制模式.png",
"mode-agent": "images/02-控制通道/本地控制模式_3_Agent_WebSocket远控模式.png",
"mode-ai": "images/02-控制通道/本地控制模式_4_AI_Agent自然语言模式.png",
"mode-scrcpy": "images/02-控制通道/本地控制模式_5_scrcpy可视化控制模式.png",
}
result = []
for aid, title, summary in known:
fname = fname_map.get(aid, "")
fpath = arch_dir / fname
if fpath.exists():
encoded = urllib.parse.quote(f"2、架构/{fname}")
result.append({
"id": aid,
"title": title,
"summary": summary,
"category": "architecture",
"url": f"/workbench-docs/{encoded}",
})
return result
_WORKBENCH_ARCH_ASSETS = _scan_arch_assets()
def _doc_path(meta: dict) -> Path:
if meta.get("category") == "agent":
return Path(_AGENT_ROOT) / meta["file"] if meta["folder"] == "agent" else Path(_AGENT_ROOT) / meta["folder"].replace("agent/", "", 1) / meta["file"]
return Path(_DOCS_ROOT) / meta["folder"] / meta["file"]
def _doc_meta_with_state(meta: dict) -> dict:
path = _doc_path(meta)
exists = path.exists()
return {
**meta,
"exists": exists,
"path": str(path),
"updated_at": path.stat().st_mtime if exists else None,
"raw_url": f"/workbench-docs/{meta['folder']}/{meta['file']}" if exists else None,
}
def _render_markdown_html(content: str) -> str:
return markdown.markdown(
content,
extensions=["extra", "tables", "fenced_code", "toc", "sane_lists"],
output_format="html5",
)
import time as _time
_overview_cache = {"data": None, "ts": 0}
async def _build_workbench_overview() -> dict:
now = _time.time()
if _overview_cache["data"] and (now - _overview_cache["ts"]) < 10:
return _overview_cache["data"]
from services.adb_device import adb_manager
from services.ai_command import ai_engine
from services.connection_priority import connection_priority
# ADB 扫描加超时,避免 adb devices 卡住导致 overview 整体超时
try:
adb_authorized = await asyncio.wait_for(adb_manager.async_scan_devices(), timeout=5.0)
adb_all = await asyncio.wait_for(adb_manager.async_scan_all_devices(), timeout=5.0)
except asyncio.TimeoutError:
adb_authorized, adb_all = [], []
db_devices = []
try:
db_devices = await asyncio.wait_for(device_manager.get_all_devices(limit=200), timeout=3.0)
except (asyncio.TimeoutError, Exception):
db_devices = []
online_devices = {d["device_id"]: d for d in ws_hub.get_online_devices()}
# device_id → serial 映射,用于去重
id_serial_map = dict(adb_manager._deviceid_to_serial)
merged = {}
# ADB 设备优先(用 serial 做 key
for serial in adb_authorized:
adb_dev = adb_manager.get_device(serial)
info = adb_dev.get_info() if adb_dev else {}
merged[serial] = {
"device_id": serial,
"serial": serial,
"model": info.get("model", "Unknown"),
"brand": info.get("brand", "Unknown"),
"android_version": info.get("android_version", "Unknown"),
"status": "adb",
"connection_type": "adb",
"controllable": True,
"display": info.get("display", {}),
}
# DB 设备(跳过已通过 serial 匹配到的)
existing_device_ids = {m.get("device_id", "") for m in merged.values()}
for device in db_devices:
device_id = device["device_id"]
serial = (
id_serial_map.get(device_id)
or device.get("serial")
or device.get("adb_serial")
or device.get("wifi_serial")
or ""
)
if serial and serial in merged:
merged[serial].update({k: v for k, v in device.items() if v and k not in ("status", "device_id")})
continue
if device_id in existing_device_ids:
continue
merged[device_id] = {**device, "serial": serial}
# WebSocket 在线设备
for device_id, info in online_devices.items():
serial = id_serial_map.get(device_id, "")
if serial and serial in merged:
merged[serial].update({**info, "status": "online"})
elif device_id in merged:
merged[device_id].update({**info, "status": "online"})
else:
merged[device_id] = {**info, "status": "online", "serial": serial}
# adb_all 中未授权/离线设备
known_serials = {m.get("serial", "") for m in merged.values()} | set(merged.keys())
for adb_entry in adb_all:
serial = adb_entry["serial"]
if serial in known_serials:
continue
if adb_entry["adb_status"] != "device":
merged[serial] = {
"device_id": serial,
"serial": serial,
"model": adb_entry.get("model") or "未知设备",
"status": adb_entry["adb_status"],
"connection_type": "usb",
"controllable": False,
"adb_status": adb_entry["adb_status"],
"usb": adb_entry.get("usb", ""),
"hint": "请在手机上点击「允许 USB 调试」" if adb_entry["adb_status"] == "unauthorized" else "",
}
# 最终去重:同一物理设备可能有 serial key 和 device_id key
seen_serials = set()
deduped = {}
for key, dev in merged.items():
serial = dev.get("serial", "")
if serial and serial in seen_serials:
for k2, d2 in deduped.items():
if d2.get("serial") == serial:
# 同一物理设备优先保留 WS 在线态,其次保留 controllable=True。
if dev.get("status") == "online" and d2.get("status") != "online":
d2.update(dev)
elif dev.get("controllable") and not d2.get("controllable"):
d2.update(dev)
else:
for fk, fv in dev.items():
if fv and not d2.get(fk):
d2[fk] = fv
break
continue
if serial:
seen_serials.add(serial)
deduped[key] = dev
from services.device_id_util import enrich_device_id_fields
from services.wechat_device_map import normalize_wechat_device_fields
physical_serial_models = {
device.get("model")
for device in deduped.values()
if device.get("serial") and ":" not in str(device.get("serial")) and device.get("model")
}
devices = []
for device in deduped.values():
device_id = str(device.get("device_id") or "")
serial = str(device.get("serial") or "")
model = device.get("model")
is_hex_alias = len(device_id) == 32 and all(char in "0123456789abcdef" for char in device_id.lower())
if model in physical_serial_models and ((is_hex_alias and not serial) or (serial and ":" in serial)):
continue
if serial and len(device_id) == 32 and all(char in "0123456789abcdef" for char in device_id.lower()):
device["device_id"] = serial
devices.append(normalize_wechat_device_fields(enrich_device_id_fields(device)))
online_count = len([d for d in devices if d.get("status") in ("online", "adb")])
adb_count = len([d for d in devices if d.get("status") == "adb"])
unauthorized_count = len([d for d in devices if d.get("status") == "unauthorized"])
offline_count = len([d for d in devices if d.get("status") == "offline"])
try:
ai_status = await asyncio.wait_for(ai_engine.get_status(), timeout=3.0)
except (asyncio.TimeoutError, Exception):
ai_status = {"available": False, "backend": "", "model": ""}
docs = [_doc_meta_with_state(meta) for meta in _WORKBENCH_DOCS + _AGENT_DOCS]
# 为每台设备附加连接模式信息(并行+单设备超时,避免 overview 整体超时)
_MODE_EVAL_TIMEOUT = 5.0 # 单设备评估超时秒数
async def _eval_modes(did: str):
try:
return await asyncio.wait_for(connection_priority.async_evaluate(did), timeout=_MODE_EVAL_TIMEOUT)
except (asyncio.TimeoutError, Exception):
return []
device_modes = {}
tasks = [_eval_modes(did) for dev in devices if (did := dev.get("device_id", ""))]
results = await asyncio.gather(*tasks, return_exceptions=True)
idx = 0
for dev in devices:
did = dev.get("device_id", "")
if not did:
continue
modes = results[idx] if idx < len(results) and not isinstance(results[idx], Exception) else []
idx += 1
try:
best = next((m for m in modes if m.available), None)
dev["connection_modes"] = [m.to_dict() for m in modes]
dev["best_mode"] = best.to_dict() if best else None
device_modes[did] = dev
except Exception:
pass
result = {
"sdk_online": True,
"sdk_version": "3.0.0",
"ws_online": len(ws_hub.connections),
"adb_count": len(adb_authorized),
"adb_all": len(adb_all),
"device_total": len(devices),
"device_online": online_count,
"device_offline": offline_count + adb_count,
"unauthorized_count": unauthorized_count,
"devices": devices,
"summary": {
"sdk_online": True,
"sdk_version": "3.0.0",
"ws_online": len(ws_hub.connections),
"adb_count": len(adb_authorized),
"adb_all": len(adb_all),
"unauthorized_count": unauthorized_count,
"device_total": len(devices),
"device_online": online_count,
"device_offline": offline_count,
"manual_docs": len([d for d in docs if d["category"] == "manual" and d["exists"]]),
"architecture_assets": len(_WORKBENCH_ARCH_ASSETS),
},
"agent": ai_status,
"docs": docs,
"architecture_assets": _WORKBENCH_ARCH_ASSETS,
"sections": [
{"id": "overview", "title": "首页总览"},
{"id": "docs", "title": "说明文档"},
{"id": "devices", "title": "设备控制"},
{"id": "hook", "title": "Hook 控制"},
{"id": "agent", "title": "AI Agent"},
{"id": "connection", "title": "连接与协议"},
{"id": "architecture", "title": "架构与经验"},
{"id": "project", "title": "项目管理"},
],
"control_modes": [
{"id": "hook", "name": "Hook / Frida", "desc": "读写内部能力强,适合微信深度控制", "priority": 1},
{"id": "agent", "name": "Agent WebSocket", "desc": "实时双向设备端App常驻", "priority": 2},
{"id": "adb", "name": "ADB 静默控制", "desc": "无需 Root部署简单适合稳定兜底", "priority": 3},
{"id": "ai", "name": "AI Agent", "desc": "自然语言编排复杂任务", "priority": 4},
{"id": "scrcpy", "name": "scrcpy 可视化", "desc": "投屏与人工接管", "priority": 5},
],
}
_overview_cache["data"] = result
_overview_cache["ts"] = _time.time()
return result
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
from services.ai_heartbeat import ai_heartbeat
from services.discovery_service import discovery_service
_bind_shared_runtime_ws_hub(app)
logger.info("🚀 工作手机SDK v3.0 启动中...")
from services.process_state import mark_process_started
mark_process_started()
await device_manager.init()
if device_manager.db is not None:
logger.info("✅ MongoDB 连接成功")
else:
logger.warning("⚠️ MongoDB 不可用SDK 以无 DB 降级模式运行")
await ai_heartbeat.start()
logger.info("✅ AI 心跳监控已启动")
await discovery_service.start_beacon(port=8899)
logger.info("✅ 设备发现 beacon 已启动")
heartbeat_task = asyncio.create_task(_heartbeat_sweeper())
ai_hb_task = asyncio.create_task(_ai_heartbeat_loop())
yield
logger.info("🛑 工作手机SDK 关闭中...")
heartbeat_task.cancel()
ai_hb_task.cancel()
for t in (heartbeat_task, ai_hb_task):
try:
await t
except asyncio.CancelledError:
pass
await discovery_service.stop_beacon()
await ai_heartbeat.stop()
await device_manager.close()
async def _heartbeat_sweeper():
"""后台心跳巡检:清理长时间未上报心跳的设备"""
while True:
await asyncio.sleep(max(5, settings.WS_HEARTBEAT_INTERVAL))
await ws_hub.sweep_stale_devices(timeout_seconds=max(settings.WS_TIMEOUT, settings.WS_HEARTBEAT_INTERVAL * 3))
async def _ai_heartbeat_loop():
"""AI 心跳监控循环:健康评分 + 异常检测 + 任务下发"""
from services.ai_heartbeat import ai_heartbeat
while True:
try:
await ai_heartbeat.tick()
except Exception as e:
logger.error(f"AI 心跳监控异常: {e}")
await asyncio.sleep(ai_heartbeat.HEALTH_INTERVAL_SEC)
TAGS_METADATA = [
{"name": "消息管理", "description": "发送/获取/同步/批量/评论/转发/撤回/名片/语音 — 9个端点"},
{"name": "好友管理", "description": "添加/通过/备注/删除/搜索/批量 — 8个端点"},
{"name": "群聊管理", "description": "创建/邀请/移除/公告/群名/群消息/欢迎语/退群 — 10个端点"},
{"name": "标签管理", "description": "创建/删除/打标/取标/查人 — 6个端点"},
{"name": "朋友圈管理", "description": "发布/点赞/评论/删除/封面/可见天数/分享链接 — 8个端点"},
{"name": "个人设置", "description": "获取资料/昵称/签名/头像/性别/地区 — 6个端点"},
{"name": "账号安全", "description": "状态/解封/安全中心/改密码/申诉/限制检查 — 9个端点"},
{"name": "收藏管理", "description": "收藏/收藏列表 — 2个端点"},
{"name": "支付", "description": "红包/转账/付款码/收款/钱包/账单/领红包 — 7个端点"},
{"name": "聊天设置", "description": "置顶/免打扰/清空记录 — 3个端点"},
{"name": "小程序", "description": "打开小程序 — 1个端点"},
{"name": "公众号", "description": "关注公众号 — 1个端点"},
{"name": "视频号", "description": "列表/点赞/评论/关注/分享 — 5个端点"},
{"name": "扫一扫", "description": "扫码/加好友/我的二维码/图片识别 — 4个端点"},
{"name": "通话", "description": "语音通话/视频通话 — 2个端点"},
{"name": "群发助手", "description": "群发消息 — 1个端点"},
{"name": "搜一搜", "description": "微信搜一搜 — 1个端点"},
{"name": "看一看", "description": "微信看一看 — 1个端点"},
{"name": "微信运动", "description": "步数/点赞 — 2个端点"},
{"name": "位置", "description": "发送位置/共享实时位置 — 2个端点"},
{"name": "表情", "description": "发送表情/表情包列表 — 2个端点"},
{"name": "文件管理", "description": "发送文件/下载文件 — 2个端点"},
{"name": "设置", "description": "勿扰/清缓存/检查更新/退出/切换账号 — 5个端点"},
{"name": "设备管理", "description": "设备列表/状态/截图/ADB控制"},
{"name": "AI Agent", "description": "自然语言控制手机"},
{"name": "防封监控", "description": "操作频率/内容/账号生命周期 — 2个端点"},
]
app = FastAPI(
title="机擎 v3.0 · 工作手机SDK API",
description="""
## 工作手机SDK — 微信全功能控制接口
通过 ADB UI 自动化后端静默控制 Android 手机上的微信,**无需 Root**。
### 核心能力
- **98 个 API 端点**,覆盖微信全部功能
- **96 个引擎方法**WeChatADBEngine
- **23 个功能模块**(消息/好友/群聊/支付/解封/视频号/扫一扫...
- **导航缓存系统**:首次录制坐标,后续免 UI dump
- **操作日志**:每次操作自动记录步骤/耗时/结果
- **容错机制**:自动重试 3 次 + 状态恢复
### 支持设备
- Redmi Note 13 5G (Android 13, 1080×2400)
- 微信 8.0.56
### 调用方式
```
POST http://{服务器IP}:8899/api/v3/message/send
Content-Type: application/json
{
"device_id": "xgfe65eimrrofyws",
"platform": "wechat",
"to_id": "好友昵称",
"content": "你好"
}
```
### 公共参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| device_id | string | ✅ | ADB 设备序列号 |
| platform | string | ✅ | 固定 `wechat` |
""",
version="3.0.0",
lifespan=lifespan,
openapi_tags=TAGS_METADATA,
docs_url=None,
redoc_url="/redoc",
)
# R6所有路由必须共用当前进程的同一个 WebSocketHub。
# 某些启动方式会同时留下 services.* 与 app.services.* 两套模块名;
# 这里不新建连接,只从已经存在的对象里选当前仍有实时连接的那个,并统一绑定。
_WS_HUB_MODULES = (
"services.ws_hub",
"app.services.ws_hub",
)
_WS_HUB_ROUTE_MODULES = (
"routers.unified",
"app.routers.unified",
"routers.integration",
"app.routers.integration",
"routers.gateway",
"app.routers.gateway",
"routers.workbench",
"app.routers.workbench",
"routers.fleet",
"app.routers.fleet",
"services.workbench",
"app.services.workbench",
"services.device_fleet",
"app.services.device_fleet",
"services.device_transport",
"app.services.device_transport",
)
def _bind_shared_runtime_ws_hub(target_app: FastAPI | None = None):
"""把已加载的聚合路由绑定到同一个运行时 ws_hub不创建新连接。"""
global ws_hub
candidates = [ws_hub]
for module_name in _WS_HUB_MODULES:
module = sys.modules.get(module_name)
candidate = getattr(module, "ws_hub", None) if module else None
if candidate is not None and not any(candidate is item for item in candidates):
candidates.append(candidate)
# 优先使用已有实时连接最多的实例;数量相同则保留主入口导入的实例。
canonical = max(
candidates,
key=lambda item: len(getattr(item, "connections", {}) or {}),
)
ws_hub = canonical
for module_name in _WS_HUB_MODULES:
module = sys.modules.get(module_name)
if module is not None:
module.ws_hub = canonical
for module_name in _WS_HUB_ROUTE_MODULES:
module = sys.modules.get(module_name)
if module is not None and hasattr(module, "ws_hub"):
module.ws_hub = canonical
if target_app is not None:
target_app.state.ws_hub = canonical
return canonical
# CORS配置
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
_PUBLIC_HTTP_PATHS = {
"/", "/health", "/ready", "/openapi.json", "/docs", "/redoc",
"/docs/oauth2-redirect", "/llms.txt", "/favicon.ico", "/api/v3/console/login",
}
_CONSOLE_COOKIE = "workphone_console_session"
_CONSOLE_LOGIN_FAILURES = {}
@app.get("/docs", include_in_schema=False)
async def swagger_docs():
"""带接口实现状态标识的 Swagger 文档。"""
response = get_swagger_ui_html(
openapi_url=app.openapi_url,
title=f"{app.title} - 接口文档",
swagger_ui_parameters={"displayOperationId": False, "filter": True},
)
html = response.body.decode("utf-8").replace(
"</head>",
'<link rel="stylesheet" href="/static/swagger-status.css?v=20260724"></head>',
).replace(
"</body>",
'<script src="/static/swagger-status.js?v=20260724"></script></body>',
)
return Response(content=html, media_type="text/html; charset=utf-8")
def _create_console_session(username: str) -> str:
expires = int(time.time()) + max(300, settings.CONSOLE_SESSION_TTL)
payload = f"{username}:{expires}"
signature = hmac.new(settings.API_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()
return base64.urlsafe_b64encode(f"{payload}:{signature}".encode()).decode()
def _has_valid_console_session(request: Request) -> bool:
token = request.cookies.get(_CONSOLE_COOKIE, "")
try:
decoded = base64.urlsafe_b64decode(token.encode()).decode()
username, expires, signature = decoded.rsplit(":", 2)
payload = f"{username}:{expires}"
expected = hmac.new(settings.API_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()
return (
username == settings.CONSOLE_USERNAME
and int(expires) >= int(time.time())
and hmac.compare_digest(signature, expected)
)
except (ValueError, TypeError):
return False
def _console_cookie_secure(request: Request) -> bool:
"""根据浏览器访问协议设置 Secure避免 HTTP 页面丢弃登录 Cookie。"""
forwarded = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip().lower()
return settings.CONSOLE_COOKIE_SECURE or forwarded == "https" or request.url.scheme == "https"
@app.post("/api/v3/console/login")
async def console_login(request: Request):
data = await request.json()
client_ip = request.client.host if request.client else "unknown"
now = int(time.time())
failures = [ts for ts in _CONSOLE_LOGIN_FAILURES.get(client_ip, []) if now - ts < 300]
if len(failures) >= 5:
return JSONResponse(status_code=429, content={"code": 429, "message": "登录尝试过多请5分钟后再试"})
username = str(data.get("username", "")).strip()
password = str(data.get("password", ""))
configured = bool(settings.CONSOLE_PASSWORD)
valid = configured and hmac.compare_digest(username, settings.CONSOLE_USERNAME)
valid = valid and hmac.compare_digest(password, settings.CONSOLE_PASSWORD)
if not valid:
failures.append(now)
_CONSOLE_LOGIN_FAILURES[client_ip] = failures
return JSONResponse(status_code=401, content={"code": 401, "message": "账号或密码错误"})
_CONSOLE_LOGIN_FAILURES.pop(client_ip, None)
response = JSONResponse(content={"code": 200, "message": "登录成功", "username": username})
response.set_cookie(
_CONSOLE_COOKIE,
_create_console_session(username),
max_age=settings.CONSOLE_SESSION_TTL,
httponly=True,
secure=_console_cookie_secure(request),
samesite="strict",
path="/",
)
return response
@app.post("/api/v3/console/logout")
async def console_logout():
response = JSONResponse(content={"code": 200, "message": "已退出"})
response.delete_cookie(_CONSOLE_COOKIE, path="/")
return response
@app.get("/api/v3/console/session")
async def console_session(request: Request):
return {"code": 200, "authenticated": _has_valid_console_session(request)}
@app.middleware("http")
async def require_production_api_key(request: Request, call_next):
"""生产环境保护控制接口;健康探针和文档保持可读。"""
path = request.url.path.rstrip("/") or "/"
# 浏览器跨域调用会先发送不携带业务密钥的 OPTIONS 预检;应交给
# CORSMiddleware 返回允许头,真实 GET/POST 请求仍必须通过 API Key。
public = request.method == "OPTIONS" or path in _PUBLIC_HTTP_PATHS or path.startswith("/static/")
# 设备首装只允许携带既有配对 token 的 Bootstrap/安装包请求;绝不开放匿名下载。
device_bootstrap_paths = {
"/install.sh",
"/api/v3/agent/download",
}
is_device_bootstrap = path in device_bootstrap_paths or path.startswith("/api/v3/connection/bootstrap/")
supplied_device_token = request.headers.get("X-Device-Token", "").strip()
expected_device_token = settings.DEVICE_PAIRING_TOKEN.strip()
device_authorized = bool(
is_device_bootstrap
and expected_device_token
and hmac.compare_digest(supplied_device_token, expected_device_token)
)
security_control_path = path.startswith("/api/v3/security/modules")
api_auth_enabled = security_module_registry.is_enabled("api_auth")
if (security_control_path or api_auth_enabled) and not public and not device_authorized:
supplied = request.headers.get("X-API-Key", "").strip()
auth = request.headers.get("Authorization", "").strip()
if not supplied and auth.lower().startswith("bearer "):
supplied = auth[7:].strip()
expected = settings.API_KEY.strip()
if (not expected or not hmac.compare_digest(supplied, expected)) and not _has_valid_console_session(request):
return JSONResponse(status_code=401, content={"code": 401, "message": "API Key 无效"})
return await call_next(request)
# 注册路由
app.include_router(devices.router, prefix="/api/v3", tags=["设备管理"])
app.include_router(device_groups.router, prefix="/api/v3", tags=["设备分组"])
app.include_router(integrations_admin.router, prefix="/api/v3", tags=["第三方接入管理"])
app.include_router(releases.router, prefix="/api/v3", tags=["APK发布"])
app.include_router(workbench.router, prefix="/api/v3", tags=["总控平台总览"])
app.include_router(security_modules.router, prefix="/api/v3", tags=["安全模块"])
app.include_router(unified.router, prefix="/api/v3", tags=["统一接口"])
app.include_router(agent.router, prefix="/api/v3", tags=["AI Agent"])
app.include_router(ai_tasks.router, prefix="/api/v3", tags=["AI任务"])
app.include_router(adb.router, tags=["ADB设备控制"])
app.include_router(experience.router, tags=["经验库"])
app.include_router(projects.router, prefix="/api/v3", tags=["项目管理"])
app.include_router(qrcode.router, prefix="/api/v3", tags=["二维码"])
app.include_router(voice.router, prefix="/api/v3", tags=["语音控制"])
app.include_router(capture.router, tags=["抓包"])
app.include_router(hook_modules.router, prefix="/api/v3", tags=["Hook模块管理"])
app.include_router(connection.router, prefix="/api/v3", tags=["连接协议"])
app.include_router(connection_provider.router, prefix="/api/v3", tags=["连接方案可切换驱动"])
app.include_router(gateway.router, prefix="/api/v3", tags=["外部对接网关"])
app.include_router(registry_cluster.router, tags=["多服务器注册中心"])
app.include_router(discovery.router, tags=["设备发现"])
app.include_router(cunke_bao.router, prefix="/api/v3", tags=["存客宝对接"])
app.include_router(frida_wireless.router, prefix="/api/v3", tags=["Frida无线管理"])
app.include_router(wechat_full.router, prefix="/api/v3", tags=["微信全量操作"])
app.include_router(integration.router, prefix="/api/v3", tags=["对外接口统一清单"])
app.include_router(process.router, prefix="/api/v3", tags=["进程状态"])
app.include_router(fleet.router, prefix="/api/v3", tags=["多设备管理"])
app.include_router(kb.router, tags=["内部知识库"])
# 所有业务写接口在进入原业务路由前,先经过统一的干跑、确认和幂等门禁。
# 这只加公共外壳,不改各业务路由的具体实现。
_GLOBAL_WRITE_MANIFEST = install_global_write_gate(app)
@app.middleware("http")
async def hook_probe_evidence_envelope(request: Request, call_next):
"""只给旧Hook探针加证据外壳不改统一路由和Hook脚本。"""
response = await call_next(request)
path = request.url.path
if request.method != "GET" or not path.startswith("/api/v3/hook/probe/"):
return response
try:
body = b"".join([chunk async for chunk in response.body_iterator])
payload = json.loads(body.decode("utf-8"))
if not isinstance(payload, dict):
return response
device_id = path.rsplit("/", 1)[-1]
adapted = adapt_hook_probe_payload(payload, device_id)
headers = {k: v for k, v in response.headers.items() if k.lower() not in {"content-length", "content-encoding"}}
return JSONResponse(status_code=response.status_code, content=adapted, headers=headers)
except Exception:
# 原始响应无法解析时原样返回,不伪造成功。
return response
_PLATFORM_EVIDENCE_PATHS = {
"/api/v3/wechat/account/profile", # 模块6微信账号资料
"/api/v3/tag/list", # 模块11标签列表与映射
"/api/v3/customer/profile-bundle", # 模块46客户画像聚合
"/api/v3/message/list", # 模块47客户会话聚合
"/api/v3/stability/watch", # 模块54限流重试超时观察
}
async def _refresh_stability_hook_probe(payload: dict, device_id: str) -> dict:
"""稳定观察需要时复用正式 Hook 探针,并保留真实失败。"""
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
samples = data.get("samples") if isinstance(data.get("samples"), list) else []
summary = data.get("summary") if isinstance(data.get("summary"), dict) else {}
if not samples or not bool(summary.get("hook_required")):
return payload
# 原稳定采样已经拿到明确成功时不重复探针;只有 hook_ok=false 时才补用
# 正式 /hook/probe 的同一条函数链避免把“在线但Hook失败”改成成功。
if all(item.get("hook_ok") is True for item in samples if isinstance(item, dict)):
return payload
try:
unified_module = (
sys.modules.get("routers.unified")
or sys.modules.get("app.routers.unified")
)
if unified_module is None:
from routers import unified as unified_module # type: ignore
probe_payload = await unified_module.hook_probe(device_id)
probe_receipt = adapt_hook_probe_payload(probe_payload, device_id)
except Exception as exc: # 探针失败必须保留为失败,不伪造成功
probe_receipt = {
"code": 503,
"success": False,
"error_code": "hook_probe_exception",
"error_message": str(exc)[:200],
"readback": {"verified": False, "write_performed": False},
}
probe_readback = probe_receipt.get("readback") if isinstance(probe_receipt.get("readback"), dict) else {}
probe_verified = probe_readback.get("verified") is True
for item in samples:
if not isinstance(item, dict):
continue
item["hook_ok"] = probe_verified
item["hook_probe"] = probe_receipt
if not probe_verified:
item["error"] = item.get("error") or probe_receipt.get("error_code") or "hook_probe_failed"
total = len(samples)
ok_count = sum(
1
for item in samples
if isinstance(item, dict)
and item.get("ws_online") is True
and item.get("heartbeat_stale") is False
and (not summary.get("hook_required") or item.get("hook_ok") is True)
)
summary.update({
"total": total,
"ok": ok_count,
"success_rate": round(ok_count / total, 4) if total else 0,
})
data["samples"] = samples
data["summary"] = summary
data["hook_probe"] = probe_receipt
payload["data"] = data
payload["raw_rpc_receipt"] = {
"kind": "stability_observation_with_hook_probe",
"source": "ws_hub+hook_probe",
"device_id": device_id,
"samples": samples,
"hook_probe": probe_receipt.get("raw_rpc_receipt") or probe_receipt,
}
payload["readback"] = {
"verified": bool(total and ok_count == total),
"write_performed": False,
"source": "stability_watch+hook_probe",
"sample_count": total,
"ok_count": ok_count,
"hook_probe_verified": probe_verified,
}
payload["success"] = payload["readback"]["verified"] is True
if not payload["success"]:
payload["error_code"] = "hook_probe_failed"
payload["error_message"] = "设备在线但现有Hook探针没有通过"
else:
payload.pop("error_code", None)
payload.pop("error_message", None)
return payload
@app.middleware("http")
async def platform_evidence_envelope(request: Request, call_next):
"""把旧平台接口的组件回执提升到最外层,保留真实失败状态。"""
_bind_shared_runtime_ws_hub(app)
response = await call_next(request)
if request.url.path not in _PLATFORM_EVIDENCE_PATHS:
return response
try:
body = b"".join([chunk async for chunk in response.body_iterator])
payload = json.loads(body.decode("utf-8"))
if not isinstance(payload, dict):
return response
if request.url.path == "/api/v3/stability/watch":
device_id = request.query_params.get("device_id", "")
if device_id and request.query_params.get("include_hook", "").lower() == "true":
payload = await _refresh_stability_hook_probe(payload, device_id)
operation = (
"stability_watch"
if request.url.path == "/api/v3/stability/watch"
else request.url.path.rsplit("/", 1)[-1].replace("-", "_")
)
adapted = normalize_endpoint_evidence(
payload,
operation=operation,
trace_id=request.headers.get("X-FULL55-Trace") or request.query_params.get("trace_id"),
fallback_channel=(
"websocket/frida_rpc"
if (
"message" in request.url.path
or "tag" in request.url.path
or "profile" in request.url.path
or request.query_params.get("include_hook", "").lower() == "true"
)
else "websocket/agent"
),
)
# 外层统一口径:底层没有提供任何业务读回时,用统一的
# readback_unverified已有具体业务失败原因则原样保留。
readback = adapted.get("readback") if isinstance(adapted.get("readback"), dict) else {}
if (
adapted.get("success") is False
and adapted.get("error_code") == "readback_failed"
and readback.get("verified") is False
and request.url.path != "/api/v3/stability/watch"
):
adapted["error_code"] = "readback_unverified"
headers = {
key: value
for key, value in response.headers.items()
if key.lower() not in {"content-length", "content-encoding"}
}
return JSONResponse(status_code=response.status_code, content=adapted, headers=headers)
except Exception:
# 解析失败时保留原响应,不用包装层掩盖真实错误。
return response
# ========== 健康检查 ==========
@app.get("/health")
async def health_check():
"""健康检查"""
from services.adb_device import adb_manager
adb_devices = []
if bool(getattr(settings, "WORKPHONE_HOST_ADB_PROBE", False)):
adb_devices = await adb_manager.async_scan_devices()
data = {
"status": "healthy",
"version": "3.0.0",
"devices_online": len(ws_hub.connections),
"device_ids": list(ws_hub.connections.keys()),
"adb_devices": len(adb_devices),
"adb_serials": adb_devices,
"evidence_source": "sdk_process+ws_hub",
}
verified = bool(ws_hub.connections)
result = build_device_read_receipt(
data=data,
action="health",
channel="websocket/agent" if verified else "sdk_local/offline",
raw_rpc_receipt={"source": "sdk_health", "devices_online": len(ws_hub.connections), "adb_probe_used": bool(getattr(settings, "WORKPHONE_HOST_ADB_PROBE", False))},
readback={"verified": verified, "source": "ws_hub_live_connections", "online_ws_count": len(ws_hub.connections)},
trace_id=f"health-{int(time.time() * 1000)}",
error_code="no_live_device" if not verified else None,
error_message="没有真实在线设备,健康检查不报成功" if not verified else None,
success=verified,
)
# 保留旧健康检查调用方直接读取的字段。
result.update({"status": data["status"], "version": data["version"]})
return result
@app.get("/ready")
async def ready():
"""就绪探针(部署/负载均衡用):进程已启动且可接收流量"""
return {"ready": True, "version": "3.0.0"}
@app.get("/llms.txt")
async def llms_txt():
"""AI/LLM 可读入口声明接口网站、OpenAPI、MCP 与对接手册。"""
static_path = os.path.join(_STATIC_DIR, "llms.txt")
if os.path.exists(static_path):
return FileResponse(static_path, media_type="text/plain; charset=utf-8")
raise HTTPException(status_code=404, detail="llms.txt 不存在")
@app.get("/")
async def root():
"""根路由 → 聚合总控台"""
static_path = _CONSOLE_UI_INDEX
if os.path.exists(static_path):
return FileResponse(static_path)
return {
"name": "工作手机SDK v3.0",
"description": "统一聚合总控台",
"docs": "/docs",
"health": "/health",
"ready": "/ready",
"voice_control": "/voice",
"control_center": "/control",
}
@app.get("/hub")
async def hub_page():
"""统一聚合总控台"""
static_path = _CONSOLE_UI_INDEX
if os.path.exists(static_path):
return FileResponse(static_path)
raise HTTPException(status_code=404, detail="总控平台前端产物不存在,请先执行 sdk/android-ui 的 build:runtime")
@app.get("/control")
async def control_page():
"""兼容旧入口,永久归一到 Docker Hub。"""
return RedirectResponse(url="/?tab=devices", status_code=308)
@app.get("/voice")
async def voice_control_page():
"""旧语音页已融合进工作台命令区。"""
return RedirectResponse(url="/?tab=overview", status_code=308)
@app.get("/wechat")
async def wechat_control_page():
"""旧微信页已融合进设备管理与 Hook 面板。"""
return RedirectResponse(url="/?tab=devices", status_code=308)
@app.get("/static/index.html", include_in_schema=False)
async def legacy_static_console():
"""旧静态控制台只保留兼容跳转,不再维护第二套页面。"""
return RedirectResponse(url="/?tab=devices", status_code=308)
@app.get("/static/voice_control.html", include_in_schema=False)
async def legacy_static_voice_console():
"""旧语音控制页兼容跳转。"""
return RedirectResponse(url="/?tab=overview", status_code=308)
@app.get("/static/wechat_control.html", include_in_schema=False)
async def legacy_static_wechat_console():
"""旧微信控制页兼容跳转。"""
return RedirectResponse(url="/?tab=devices", status_code=308)
@app.get("/pages/{page_key}")
async def docs_page(page_key: str):
"""聚合页内嵌的项目文档页面"""
doc_path = _DOC_PAGE_MAP.get(page_key)
if not doc_path or not os.path.exists(doc_path):
raise HTTPException(status_code=404, detail="页面不存在")
return FileResponse(doc_path)
_WORKBENCH_OVERVIEW_TIMEOUT = 15.0 # 整体超时秒数,避免 ADB/设备端操作阻塞
def _overview_fallback() -> dict:
"""overview 超时或异常时的降级响应"""
return {
"sdk_online": True,
"sdk_version": "3.0.0",
"ws_online": len(ws_hub.connections),
"adb_count": 0,
"adb_all": 0,
"device_total": 0,
"device_online": 0,
"device_offline": 0,
"unauthorized_count": 0,
"devices": [],
"summary": {"sdk_online": True, "sdk_version": "3.0.0", "manual_docs": 0, "architecture_assets": 0},
"agent": {"available": False, "backend": "", "model": ""},
"docs": [],
"architecture_assets": [],
"sections": [],
"control_modes": [],
"timeout_fallback": True,
}
@app.get("/api/v3/workbench/docs", tags=["工作台概览"])
async def workbench_docs():
"""工作台文档清单。"""
return {
"code": 200,
"data": {
"docs": [_doc_meta_with_state(meta) for meta in _WORKBENCH_DOCS + _AGENT_DOCS],
"architecture_assets": _WORKBENCH_ARCH_ASSETS,
},
}
@app.get("/api/v3/workbench/docs/{doc_id}", tags=["工作台概览"])
async def workbench_doc_detail(doc_id: str):
"""读取工作台文档正文并返回渲染后的 HTML。"""
meta = next((item for item in _WORKBENCH_DOCS + _AGENT_DOCS if item["id"] == doc_id), None)
if not meta:
raise HTTPException(status_code=404, detail="文档不存在")
path = _doc_path(meta)
if not path.exists():
raise HTTPException(status_code=404, detail="文档文件不存在")
raw = path.read_text(encoding="utf-8")
return {
"code": 200,
"data": {
**_doc_meta_with_state(meta),
"content_markdown": raw,
"content_html": _render_markdown_html(raw),
},
}
# 挂载静态文件目录
if os.path.exists(_STATIC_DIR):
app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
if os.path.exists(_DOCS_ROOT):
app.mount("/workbench-docs", StaticFiles(directory=_DOCS_ROOT), name="workbench-docs")
# ========== Agent 分发接口 ==========
# Agent 代码根目录
_AGENT_DIR = os.path.normpath(os.path.join(_APP_DIR, "..", "agent"))
_AGENT_DIST = os.path.join(_AGENT_DIR, "dist", "agent.tar.gz")
@app.get("/api/v3/agent/download", tags=["设备管理"])
async def download_agent():
"""
下载设备端 Agent 打包文件agent.tar.gz
设备端 install.sh 会调用此接口自动下载代码。
如果 dist/agent.tar.gz 不存在,自动运行 package.sh 打包。
"""
# 自动打包(如果 dist 不存在或过期)
if not os.path.exists(_AGENT_DIST):
package_sh = os.path.join(_AGENT_DIR, "package.sh")
if os.path.exists(package_sh):
try:
subprocess.run(["bash", package_sh], cwd=_AGENT_DIR, check=True, timeout=30)
logger.info("Agent 代码已自动打包")
except Exception as e:
logger.error(f"自动打包失败: {e}")
return {"error": "Agent 打包失败,请在服务器手动执行 agent/package.sh"}
if os.path.exists(_AGENT_DIST):
return FileResponse(
_AGENT_DIST,
media_type="application/gzip",
filename="agent.tar.gz",
)
return {"error": "agent.tar.gz 不存在"}
@app.get("/install.sh")
async def serve_install_script():
"""
提供 Termux 一键安装脚本
用法: curl -sL http://服务器IP:8899/install.sh | bash -s -- --server ws://服务器IP:8899/ws/device
"""
install_sh = os.path.join(_AGENT_DIR, "install.sh")
if os.path.exists(install_sh):
return FileResponse(install_sh, media_type="text/plain", filename="install.sh")
return Response(content="echo '❌ install.sh 不存在'\n", media_type="text/plain")
# ========== WebSocket 设备连接 ==========
@app.websocket("/ws/device/{device_id}")
async def device_websocket(websocket: WebSocket, device_id: str):
"""设备WebSocket连接入口"""
expected_token = settings.DEVICE_PAIRING_TOKEN.strip()
supplied_token = websocket.query_params.get("token", "").strip()
if expected_token and not hmac.compare_digest(supplied_token, expected_token):
await websocket.close(code=4401, reason="invalid pairing token")
return
connected = await ws_hub.connect(websocket, device_id)
if not connected:
return
try:
while True:
data = await websocket.receive_json()
await ws_hub.handle_message(device_id, data)
except WebSocketDisconnect:
await ws_hub.disconnect(device_id, websocket=websocket)
except Exception as e:
logger.error(f"WebSocket错误 [{device_id}]: {e}")
await ws_hub.disconnect(device_id, websocket=websocket)
_OPENAPI_SUMMARY_ZH = {
"console_login": "控制台登录",
"console_logout": "控制台退出",
"console_session": "查询控制台会话",
"api_start_capture": "开始抓包",
"api_stop_capture": "停止抓包",
"api_capture_status": "查询抓包状态",
"api_capture_data": "获取抓包数据",
"list_modules": "查询模块列表",
"create_module": "创建模块",
"get_module": "获取模块详情",
"update_module": "更新模块",
"delete_module": "删除模块",
"set_module_scope": "设置模块作用范围",
"enable_module": "启用模块",
"disable_module": "停用模块",
"get_device_modules": "查询设备模块",
"reload_device_modules": "重新加载设备模块",
"get_device_module_logs": "查询设备模块日志",
"list_scripts": "查询脚本列表",
"upload_script": "上传脚本",
"download_script": "下载脚本",
"deploy_script": "部署脚本",
"list_hook_events": "查询 Hook 事件",
"ingest_hook_event": "接收 Hook 事件",
"register_node": "注册服务节点",
"node_heartbeat": "上报节点心跳",
"list_nodes": "查询服务节点",
"remove_node": "移除服务节点",
"registry_info": "查询注册中心信息",
"ping": "检测微信连接",
"get_safety_center": "获取微信安全中心",
"check_restrictions": "检查微信账号限制",
"get_top_stories": "获取微信看一看",
"get_wechat_steps": "获取微信运动步数",
"get_sticker_list": "获取微信表情列表",
"show_payment_code": "显示付款码",
"send_red_packet": "发送红包",
"send_transfer": "发起转账",
"confirm_prepared_transfer": "确认已准备转账",
"inspect_payment_classes": "支付类探针",
"inspect_transfer_readback": "转账回读探针",
"receive_transfer_payment": "确认收取转账",
"reject_transfer_payment": "退还转账",
"receive_payment": "确认收款",
"view_wallet": "查看钱包",
"view_transactions": "查看账单",
"receive_red_packet": "领取红包",
"receive_incoming_payment_batch": "批量领取待收款项",
"like_wechat_steps": "点赞微信运动",
"clear_cache": "清理微信缓存",
"check_for_update": "检查微信更新",
"voice_call": "发起微信语音通话",
"video_call": "发起微信视频通话",
"share_real_time_location": "共享实时位置",
"download_file": "下载微信文件",
"mass_send": "群发微信消息",
"batch_send": "批量发送微信消息",
"reply_comment": "回复朋友圈评论",
}
def _custom_openapi():
"""生成中文接口摘要,避免 FastAPI 根据函数名自动生成英文说明。"""
if app.openapi_schema:
return app.openapi_schema
schema = get_openapi(
title=app.title,
version=app.version,
description=app.description,
routes=app.routes,
tags=app.openapi_tags,
)
routes = {
(route.path, method.lower()): route
for route in app.routes
for method in (getattr(route, "methods", None) or [])
}
verification_status = _load_openapi_verification_status()
for path, path_item in schema.get("paths", {}).items():
for method, operation in path_item.items():
route = routes.get((path, method.lower()))
if route is None:
continue
doc = (getattr(route.endpoint, "__doc__", "") or "").strip()
first_line = doc.splitlines()[0].strip() if doc else ""
if route.name in _OPENAPI_SUMMARY_ZH:
operation["summary"] = _OPENAPI_SUMMARY_ZH[route.name]
elif any("\u4e00" <= char <= "\u9fff" for char in first_line):
operation["summary"] = first_line.rstrip("。;")
else:
operation["summary"] = _OPENAPI_SUMMARY_ZH.get(route.name, "接口说明")
key = f"{method.upper()} {path}"
source_status = _route_implementation_status(route)
implementation_status = verification_status.get(key, {}).get("status")
if not implementation_status:
implementation_status = "placeholder" if source_status == "placeholder" else "verification_pending"
operation["x-implementation-status"] = implementation_status
operation["x-implementation-label"] = {
"implemented": "已实现",
"verification_pending": "待真机验证",
"placeholder": "未完成",
}.get(implementation_status, "未完成")
apply_global_openapi_contract(schema, _GLOBAL_WRITE_MANIFEST)
app.openapi_schema = schema
return schema
def _route_implementation_status(route) -> str:
"""按接口源码自动识别实现状态;显式标记优先于静态判断。"""
explicit = getattr(route.endpoint, "__implementation_status__", "")
if explicit in {"implemented", "placeholder"}:
return explicit
try:
source = inspect.getsource(route.endpoint)
except (OSError, TypeError):
return "implemented"
compact = " ".join(source.lower().split())
doc = (getattr(route.endpoint, "__doc__", "") or "").lower()
body = compact.split("):", 1)[-1]
if body.strip() in {"pass", "..."}:
return "placeholder"
if ("接口占位" in doc or "空占位" in doc) and "禁止空占位" not in doc:
return "placeholder"
fixed_unavailable = (
"capability_unavailable" in source
and ('"code": 503' in source or "'code': 503" in source)
and "await " not in source
)
return "placeholder" if fixed_unavailable else "implemented"
def _load_openapi_verification_status() -> dict:
"""读取真机验收状态;接口只有取得真实设备回读后才允许亮显。"""
path = os.path.join(_APP_DIR, "data", "openapi_verification_status.json")
try:
with open(path, encoding="utf-8") as file:
data = json.load(file)
return data.get("operations", {})
except (OSError, ValueError, TypeError):
return {}
app.openapi = _custom_openapi
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8899)