feat: publish workphone public SDK deployment and API docs
55
.cursorignore
Normal file
@@ -0,0 +1,55 @@
|
||||
# 工作手机 · Cursor 忽略(防持续上传/索引)
|
||||
# 根因:反编译产物 ~45 万文件,Agent 会反复同步到云端
|
||||
|
||||
# APK 反编译与资料(最大头)
|
||||
资料/**
|
||||
**/apk_analysis/**
|
||||
|
||||
# 微信反编译 / jadx / smali 产物
|
||||
sdk/tmp/**
|
||||
sdk/scripts/gadget_work/**
|
||||
**/jadx_out/**
|
||||
**/wechat_decompiled/**
|
||||
**/smali/**
|
||||
**/smali_classes*/**
|
||||
|
||||
# 依赖与构建
|
||||
**/node_modules/**
|
||||
**/.pnpm-store/**
|
||||
**/.yarn/**
|
||||
**/.npm/**
|
||||
**/.venv/**
|
||||
**/venv/**
|
||||
**/__pycache__/**
|
||||
**/.pytest_cache/**
|
||||
**/dist/**
|
||||
**/build/**
|
||||
**/.next/**
|
||||
**/.turbo/**
|
||||
**/target/**
|
||||
**/.gradle/**
|
||||
**/.m2/**
|
||||
**/vendor/**
|
||||
**/coverage/**
|
||||
|
||||
# 缓存与索引
|
||||
**/.cache/**
|
||||
**/.codegraph/**
|
||||
**/.cursor/**
|
||||
**/.git/objects/**
|
||||
|
||||
# 大文件
|
||||
**/*.apk
|
||||
**/*.aab
|
||||
**/*.dex
|
||||
**/*.so
|
||||
**/*.jar
|
||||
**/*.zip
|
||||
**/*.7z
|
||||
**/*.dmg
|
||||
**/*.mp4
|
||||
**/*.mov
|
||||
**/*.MP4
|
||||
**/*.MOV
|
||||
**/*.log
|
||||
**/logs/**
|
||||
8
.cursorindexingignore
Normal file
@@ -0,0 +1,8 @@
|
||||
# 仅排除索引搜索,Agent 仍可 @ 读取(工作手机反编译目录)
|
||||
资料/**
|
||||
sdk/tmp/**
|
||||
sdk/scripts/gadget_work/**
|
||||
**/jadx_out/**
|
||||
**/wechat_decompiled/**
|
||||
**/smali/**
|
||||
**/smali_classes*/**
|
||||
14
.gitignore
vendored
@@ -38,6 +38,20 @@ sdk/logs/
|
||||
sdk/tmp_rom/
|
||||
sdk/.runtime/
|
||||
|
||||
# 本机/真机运行态配置与验收日志(模板文件可提交)
|
||||
sdk/config/*.env
|
||||
sdk/config/agent_config_*.json
|
||||
sdk/agent/config.nas-active.json
|
||||
sdk/data/operation_logs/
|
||||
sdk/app/data/hook/events.jsonl
|
||||
|
||||
# 本地发行包、逆向工作区与设备二进制
|
||||
sdk/releases/
|
||||
sdk/scripts/gadget_work/
|
||||
sdk/scripts/frida-server-*
|
||||
sdk/scripts/frida-gadget-*
|
||||
sdk/scripts/wechat_original.apk
|
||||
|
||||
# Obsidian 粘贴图误落 vault 根(应进 开发文档/**/images/)
|
||||
/Pasted image*.png
|
||||
/Pasted image*.jpg
|
||||
|
||||
20
sdk/.dockerignore
Normal file
@@ -0,0 +1,20 @@
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.gradle/
|
||||
node_modules/
|
||||
android-app/
|
||||
admin/
|
||||
releases/
|
||||
tests/
|
||||
data/
|
||||
app/data/cunke_bao_config.json
|
||||
app/data/hook/events.jsonl
|
||||
app/data/operation_logs/
|
||||
app/agent/config.json
|
||||
agent/config.json
|
||||
agent/config.*.json
|
||||
@@ -1,9 +1,11 @@
|
||||
# 工作手机SDK v3.1 - Dockerfile(含 AI Brain + Hook 模块管理)
|
||||
FROM python:3.11-slim
|
||||
ARG PYTHON_IMAGE=docker.1ms.run/library/python:3.11-slim
|
||||
FROM ${PYTHON_IMAGE}
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN sed -i 's#deb.debian.org#mirrors.cloud.tencent.com#g; s#security.debian.org#mirrors.cloud.tencent.com#g' /etc/apt/sources.list.d/debian.sources \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
adb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
12
sdk/agent/config.nas-primary.example.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"device_id": "xgfe65eimrrofyws",
|
||||
"server_url": "ws://192.168.110.101:8899/ws/device",
|
||||
"public_servers": [
|
||||
"ws://192.168.1.201:8899/ws/device",
|
||||
"ws://192.168.110.167:8899/ws/device",
|
||||
"ws://open.quwanzhi.com:8899/ws/device"
|
||||
],
|
||||
"heartbeat_interval": 10,
|
||||
"project_id": "cunkebao",
|
||||
"comment": "顺序固定: ①NAS局域网(server_url) ②本机Docker(局网IP,示例110.167) ③NAS外网frp。勿把frp排在Docker前。"
|
||||
}
|
||||
59
sdk/agent/sdk_discovery.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
设备端 SDK 自动发现 — 监听 UDP beacon(8898),无线主控免手填 IP。
|
||||
|
||||
与 sdk/app/services/discovery_service.py BEACON 协议对齐。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
BEACON_PORT = 8898
|
||||
BEACON_MAGIC = "WORKPHONE_SDK"
|
||||
|
||||
|
||||
def _pick_best_ip(ips: list) -> str:
|
||||
"""优先 192.168.110.x(工作机常用网段),否则取第一个私网 IP。"""
|
||||
for ip in ips:
|
||||
if ip.startswith("192.168.110."):
|
||||
return ip
|
||||
for ip in ips:
|
||||
if ip.startswith(("192.168.", "10.", "172.")):
|
||||
return ip
|
||||
return ips[0]
|
||||
|
||||
|
||||
def discover_sdk_ws_base(timeout: float = 20.0) -> Optional[str]:
|
||||
"""
|
||||
监听局域网 UDP beacon,返回 WebSocket 基础地址。
|
||||
例: ws://192.168.110.251:8899/ws/device
|
||||
"""
|
||||
deadline = time.time() + timeout
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
sock.bind(("", BEACON_PORT))
|
||||
except OSError:
|
||||
# Termux 可能无权限绑 8898,改绑任意端口并只 recv
|
||||
sock.bind(("", 0))
|
||||
sock.settimeout(1.0)
|
||||
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
data, _addr = sock.recvfrom(4096)
|
||||
payload = json.loads(data.decode("utf-8"))
|
||||
if payload.get("magic") != BEACON_MAGIC:
|
||||
continue
|
||||
port = int(payload.get("port") or 8899)
|
||||
ws_path = (payload.get("ws_path") or "/ws/device").rstrip("/")
|
||||
ips = payload.get("ips") or []
|
||||
host = _pick_best_ip(ips) if ips else "127.0.0.1"
|
||||
return f"ws://{host}:{port}{ws_path}"
|
||||
except socket.timeout:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
versionCode 6
|
||||
versionName "5.0.1"
|
||||
buildConfigField "String", "PWA_URL", "\"https://ckbapi.quwanzhi.com\""
|
||||
buildConfigField "String", "DEFAULT_WS", "\"wss://workphone.quwanzhi.com/ws/device\""
|
||||
buildConfigField "String", "DEFAULT_WS", "\"wss://wpsdk.quwanzhi.com/ws/device\""
|
||||
buildConfigField "String", "AI_API_URL", "\"https://ckbapi.quwanzhi.com\""
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<!-- 仅授权测试/企业管控设备通过 ADB grant 后生效,用于自动启用本应用无障碍。 -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_SECURE_SETTINGS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
@@ -66,7 +70,7 @@
|
||||
android:name=".service.AgentAccessibilityService"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
|
||||
android:directBootAware="true"
|
||||
android:exported="false">
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||
</intent-filter>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.system.cloudservice
|
||||
|
||||
import android.app.Application
|
||||
import com.system.cloudservice.service.AgentAccessibilityService
|
||||
import com.system.cloudservice.util.Logger
|
||||
|
||||
class App : Application() {
|
||||
@@ -13,6 +14,7 @@ class App : Application() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
Logger.init(this)
|
||||
Logger.i("AI数智员工 Application created (${BuildConfig.VERSION_NAME})")
|
||||
AgentAccessibilityService.enableIfAuthorized(this)
|
||||
Logger.i("工作手机 Application created (${BuildConfig.VERSION_NAME})")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ class AgentEngine(
|
||||
private var deviceId = ""
|
||||
private var projectId = ""
|
||||
private var serverUrl = ""
|
||||
private var pairingToken = ""
|
||||
private var heartbeatIntervalMs = 30_000L
|
||||
private var lastPongAt = 0L
|
||||
private var commandsExecuted = 0
|
||||
@@ -58,6 +59,9 @@ class AgentEngine(
|
||||
|
||||
fun isFridaReady(): Boolean = fridaBridge?.isReady == true
|
||||
|
||||
/** 微信进程真实注入状态;与 frida-server 端口就绪严格分离。 */
|
||||
fun isWechatHookAttached(): Boolean = fridaBridge?.isTargetHooked() == true
|
||||
|
||||
/** AB-02~06 runtime 信号:risk_sentinel / nurture / device_guard / sensor / touch */
|
||||
fun getAntiBanStatus(): Map<String, Any?> = antiBan.statusSummary()
|
||||
|
||||
@@ -87,6 +91,8 @@ class AgentEngine(
|
||||
) {
|
||||
this.deviceId = deviceId
|
||||
this.projectId = projectId
|
||||
this.pairingToken = context.getSharedPreferences("agent_config", Context.MODE_PRIVATE)
|
||||
.getString("pairing_token", "")?.trim().orEmpty()
|
||||
this.publicServers = publicServers.filter { it.isNotBlank() }
|
||||
this.serverUrl = buildWsUrl(serverUrl, deviceId)
|
||||
this.candidates = buildCandidates(this.serverUrl, this.publicServers, deviceId)
|
||||
@@ -114,12 +120,19 @@ class AgentEngine(
|
||||
}
|
||||
|
||||
private fun buildWsUrl(base: String, deviceId: String): String {
|
||||
val cleaned = base.trimEnd('/')
|
||||
return if (cleaned.contains("/ws/device")) {
|
||||
val parts = base.trim().split("?", limit = 2)
|
||||
val cleaned = parts[0].trimEnd('/')
|
||||
val path = if (cleaned.contains("/ws/device")) {
|
||||
if (cleaned.endsWith(deviceId)) cleaned else "$cleaned/$deviceId"
|
||||
} else {
|
||||
"$cleaned/ws/device/$deviceId"
|
||||
}
|
||||
val queryParts = mutableListOf<String>()
|
||||
if (parts.size == 2 && parts[1].isNotBlank()) queryParts.add(parts[1])
|
||||
if (pairingToken.isNotBlank() && queryParts.none { it.startsWith("token=") }) {
|
||||
queryParts.add("token=${java.net.URLEncoder.encode(pairingToken, "UTF-8")}")
|
||||
}
|
||||
return if (queryParts.isEmpty()) path else "$path?${queryParts.joinToString("&")}"
|
||||
}
|
||||
|
||||
/** BIND-03:构建有序候选 [主连接, 公网1, ...],自动补全 + 去重。 */
|
||||
@@ -230,7 +243,8 @@ class AgentEngine(
|
||||
"device_profile" to info,
|
||||
"capabilities" to buildCapabilities(),
|
||||
"connect_stage" to connectStage, // BIND-07 寻服阶段上报
|
||||
"server_url" to serverUrl,
|
||||
// 配对令牌仅用于握手,禁止上报到设备资料或管理 API。
|
||||
"server_url" to serverUrl.substringBefore('?'),
|
||||
"server_candidate_count" to candidates.size,
|
||||
)
|
||||
wsManager.send(gson.toJson(msg))
|
||||
|
||||
@@ -3,12 +3,14 @@ package com.system.cloudservice.engine
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Base64
|
||||
import com.system.cloudservice.antiban.AntiBanManager
|
||||
import com.system.cloudservice.frida.FridaBridge
|
||||
import com.system.cloudservice.service.AgentAccessibilityService
|
||||
import com.system.cloudservice.util.DeviceInfo
|
||||
import com.system.cloudservice.util.Logger
|
||||
import com.system.cloudservice.util.ShellExecutor
|
||||
import java.io.File
|
||||
|
||||
class SkillExecutor(
|
||||
private val context: Context,
|
||||
@@ -26,20 +28,25 @@ class SkillExecutor(
|
||||
return mapOf("code" to 429, "message" to "风控暂停中", "data" to status)
|
||||
}
|
||||
|
||||
// If a script is specified, route through Frida-priority chain
|
||||
if (script != null) {
|
||||
// wechat/douyin 等业务脚本走 Hook 优先链;android/system/device 是设备控制域,
|
||||
// 不能误当成 Frida 脚本,否则公网 Fleet 的 app_start/ui_tree 会落到 Shell不支持。
|
||||
val deviceControlScripts = setOf("android", "system", "device")
|
||||
if (script != null && script.lowercase() !in deviceControlScripts) {
|
||||
return executeWithFridaPriority(script, action, params)
|
||||
}
|
||||
|
||||
return try {
|
||||
when (action) {
|
||||
"open_app" -> openApp(params["package"] as? String ?: "")
|
||||
"open_app", "app_start", "start_app" -> openApp(params["package"] as? String ?: "")
|
||||
"app_stop", "stop_app" -> doShell(
|
||||
mapOf("command" to "am force-stop ${params["package"] ?: ""}")
|
||||
)
|
||||
"get_installed_apps" -> getInstalledApps()
|
||||
"get_device_info" -> mapOf("code" to 200, "data" to DeviceInfo.collectFull(context))
|
||||
"click" -> doClick(params)
|
||||
"click", "tap" -> doClick(params)
|
||||
"swipe" -> doSwipe(params)
|
||||
"input_text" -> doInputText(params)
|
||||
"key_event" -> doKeyEvent(params)
|
||||
"input_text", "input" -> doInputText(params)
|
||||
"key_event", "press_key" -> doKeyEvent(params)
|
||||
"screenshot" -> doScreenshot(params)
|
||||
"back" -> doGlobalAction("back")
|
||||
"home" -> doGlobalAction("home")
|
||||
@@ -139,6 +146,22 @@ class SkillExecutor(
|
||||
if (script != "wechat") return null
|
||||
|
||||
when (action) {
|
||||
"ping" -> {
|
||||
val ver = WechatNoRootAutomation.getWechatVersion(context)
|
||||
val quick = DeviceInfo.collectQuick(context)
|
||||
return mapOf(
|
||||
"code" to 200,
|
||||
"message" to "pong",
|
||||
"data" to mapOf(
|
||||
"success" to true,
|
||||
"installed" to (ver["success"] == true),
|
||||
"wechat_version" to ver["version"],
|
||||
"wechat_running" to (quick["wechat_running"] == true),
|
||||
"channel" to "no_root_pkg",
|
||||
),
|
||||
"channel" to "no_root_pkg",
|
||||
)
|
||||
}
|
||||
"get_wechat_version" -> {
|
||||
val data = WechatNoRootAutomation.getWechatVersion(context)
|
||||
return mapOf(
|
||||
@@ -330,9 +353,27 @@ class SkillExecutor(
|
||||
}
|
||||
|
||||
private fun doScreenshot(params: Map<String, Any?>): Map<String, Any?> {
|
||||
val path = params["path"] as? String ?: "/sdcard/screenshot.png"
|
||||
val r = ShellExecutor.execute("screencap -p $path")
|
||||
return mapOf("code" to if (r.success) 200 else 500, "message" to r.output)
|
||||
val path = params["path"] as? String
|
||||
?: File(context.cacheDir, "workphone_screenshot.png").absolutePath
|
||||
val r = ShellExecutor.execute("screencap -p '$path' && chmod 644 '$path'")
|
||||
if (!r.success) {
|
||||
return mapOf("code" to 500, "message" to r.output)
|
||||
}
|
||||
return try {
|
||||
val bytes = File(path).readBytes()
|
||||
mapOf(
|
||||
"code" to 200,
|
||||
"message" to "ok",
|
||||
"data" to mapOf(
|
||||
"image_base64" to Base64.encodeToString(bytes, Base64.NO_WRAP),
|
||||
"mime_type" to "image/png",
|
||||
"size" to bytes.size,
|
||||
"path" to path,
|
||||
),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
mapOf("code" to 500, "message" to "截图读取失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun doGlobalAction(action: String): Map<String, Any?> {
|
||||
|
||||
@@ -64,7 +64,7 @@ class WebSocketManager(
|
||||
private fun doConnect() {
|
||||
if (_state.value == ConnectionState.CONNECTING) return
|
||||
_state.value = ConnectionState.CONNECTING
|
||||
Logger.d("WS connecting: $serverUrl")
|
||||
Logger.d("WS connecting: ${serverUrl.substringBefore('?')}")
|
||||
|
||||
val request = Request.Builder().url(serverUrl).build()
|
||||
ws = client.newWebSocket(request, object : WebSocketListener() {
|
||||
|
||||
@@ -65,10 +65,15 @@ class FridaBridge(
|
||||
|
||||
fun getStatus(): Map<String, Any?> = mapOf(
|
||||
"ready" to isReady,
|
||||
// 当前 APK 只确认 frida-server 端口可达,尚未持有微信 Session/Script。
|
||||
// 必须保持 false,避免 UI 与心跳把“服务就绪”误报成“微信已注入”。
|
||||
"hooked" to false,
|
||||
"server_running" to serverManager.isRunning,
|
||||
"root_available" to serverManager.hasRoot(),
|
||||
)
|
||||
|
||||
fun isTargetHooked(): Boolean = false
|
||||
|
||||
private fun loadDefaultScripts() {
|
||||
try {
|
||||
val assets = context.assets.list("") ?: return
|
||||
|
||||
@@ -39,6 +39,40 @@ class AgentAccessibilityService : AccessibilityService() {
|
||||
return flat.split(':').mapNotNull { ComponentName.unflattenFromString(it) }
|
||||
.any { it == expected }
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业授权设备可由 ADB 一次性授予 WRITE_SECURE_SETTINGS,之后冷启动自动补齐
|
||||
* 本应用无障碍服务。保留系统中已经启用的其他服务,不覆盖用户配置。
|
||||
*/
|
||||
fun enableIfAuthorized(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M ||
|
||||
context.checkSelfPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS) !=
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) return false
|
||||
|
||||
return try {
|
||||
val resolver = context.contentResolver
|
||||
val expected = ComponentName(context, AgentAccessibilityService::class.java)
|
||||
.flattenToString()
|
||||
val current = Settings.Secure.getString(
|
||||
resolver,
|
||||
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
|
||||
).orEmpty().split(':').filter { it.isNotBlank() }.toMutableList()
|
||||
if (expected !in current) current += expected
|
||||
Settings.Secure.putString(
|
||||
resolver,
|
||||
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
|
||||
current.distinct().joinToString(":"),
|
||||
) && Settings.Secure.putInt(
|
||||
resolver,
|
||||
Settings.Secure.ACCESSIBILITY_ENABLED,
|
||||
1,
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "WRITE_SECURE_SETTINGS 未授权,保留手动授权流程", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
|
||||
@@ -46,6 +46,7 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var engineStartJob: Job? = null
|
||||
var engine: AgentEngine? = null
|
||||
private set
|
||||
|
||||
@@ -98,8 +99,15 @@ class AgentForegroundService : Service() {
|
||||
|
||||
isRunning = true
|
||||
startForeground(NOTIFICATION_ID, createNotification("正在连接..."))
|
||||
synchronized(this) {
|
||||
if (engine != null || engineStartJob?.isActive == true) {
|
||||
Logger.i("AgentEngine 已存在或正在启动,忽略重复 START,仅触发重连")
|
||||
engine?.onNetworkAvailable()
|
||||
} else {
|
||||
initAndStartEngine(serverUrl, projectId, deviceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
engine?.stop()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
@@ -110,7 +118,7 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
private fun initAndStartEngine(serverUrlIn: String, projectId: String, deviceId: String) {
|
||||
serviceScope.launch {
|
||||
engineStartJob = serviceScope.launch {
|
||||
// BIND-05:未绑定 server_url 时,先走 UDP beacon 自动发现(同网零配置)
|
||||
var serverUrl = serverUrlIn
|
||||
if (serverUrl.isBlank()) {
|
||||
@@ -215,7 +223,7 @@ class AgentForegroundService : Service() {
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("AI数智员工")
|
||||
.setContentTitle("工作手机")
|
||||
.setContentText(sanitize(status))
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentIntent(pi)
|
||||
|
||||
@@ -34,9 +34,8 @@ class DashboardFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val deviceId = DeviceInfo.getOrCreateDeviceId(requireContext())
|
||||
view.findViewById<TextView>(R.id.tvDeviceIdHeader).text = deviceId
|
||||
view.findViewById<TextView>(R.id.tvDeviceIdMd5).text = "MD5 ${DeviceInfo.deviceIdMd5(requireContext())}"
|
||||
view.findViewById<TextView>(R.id.tvDeviceIdHeader).text = "设备 ID · MD5"
|
||||
view.findViewById<TextView>(R.id.tvDeviceIdMd5).text = DeviceInfo.deviceIdMd5(requireContext())
|
||||
|
||||
view.findViewById<ImageButton>(R.id.btnScanQr).setOnClickListener { startQrScan() }
|
||||
view.findViewById<Button>(R.id.btnReconnect).setOnClickListener { reconnect() }
|
||||
@@ -128,7 +127,10 @@ class DashboardFragment : Fragment() {
|
||||
if (!isAdded) return@launch
|
||||
val v = view ?: return@launch
|
||||
refresh(v)
|
||||
if (AgentForegroundService.instance?.engine != null) return@launch
|
||||
if (AgentForegroundService.instance?.engine?.wsManager?.state?.value == ConnectionState.CONNECTED) {
|
||||
refresh(v)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,7 +205,7 @@ class DashboardFragment : Fragment() {
|
||||
val wechatRunning = info["wechat_running"] == true
|
||||
val wechatInstalled = DeviceInfo.isWeChatInstalled(ctx)
|
||||
val a11yOn = AgentAccessibilityService.isEnabled(ctx)
|
||||
val hookOk = engine?.isFridaReady() == true
|
||||
val hookOk = engine?.isWechatHookAttached() == true
|
||||
val wechatCap = wechatInstalled && (wechatRunning || isOnline)
|
||||
val aiCap = engine != null || AgentForegroundService.isRunning
|
||||
|
||||
@@ -267,6 +269,7 @@ class DashboardFragment : Fragment() {
|
||||
prefs.edit()
|
||||
.putString("server_url", serverUrl)
|
||||
.putString("project_id", projectId)
|
||||
.putString("pairing_token", config["pairing_token"] ?: "")
|
||||
.putBoolean("auto_connect", true)
|
||||
.putBoolean("bound", true)
|
||||
.apply()
|
||||
|
||||
@@ -299,7 +299,7 @@ class SetupActivity : AppCompatActivity() {
|
||||
* 示例:
|
||||
* adb shell am start -n com.system.cloudservice/.ui.SetupActivity \
|
||||
* --es server_url ws://127.0.0.1:8899 --es project_id cunkebao \
|
||||
* --es device_id xgfe65eimrrofyws --ei frida_port 15715
|
||||
* --es pairing_token '<token>' --es device_id xgfe65eimrrofyws --ei frida_port 15715
|
||||
*/
|
||||
private fun consumeIntentConfigIfPresent(): Boolean {
|
||||
val server = intent?.getStringExtra("server_url")?.trim().orEmpty()
|
||||
@@ -307,6 +307,9 @@ class SetupActivity : AppCompatActivity() {
|
||||
val project = intent?.getStringExtra("project_id")?.trim().takeUnless { it.isNullOrEmpty() } ?: "cunkebao"
|
||||
val pwa = intent?.getStringExtra("pwa_url")?.trim().takeUnless { it.isNullOrEmpty() } ?: BuildConfig.PWA_URL
|
||||
val cfg = mutableMapOf("server" to server, "project" to project, "pwa" to pwa)
|
||||
intent?.getStringExtra("pairing_token")?.trim()?.takeIf { it.isNotEmpty() }?.let { token ->
|
||||
cfg["pairing_token"] = token
|
||||
}
|
||||
intent?.getStringExtra("device_id")?.trim()?.takeIf { it.isNotEmpty() }?.let { deviceId ->
|
||||
cfg["device_id"] = deviceId
|
||||
}
|
||||
@@ -348,6 +351,9 @@ class SetupActivity : AppCompatActivity() {
|
||||
config["public_servers"]?.trim()?.takeIf { it.isNotEmpty() }?.let { ps ->
|
||||
prefs.edit().putString("public_servers", ps).apply()
|
||||
}
|
||||
config["pairing_token"]?.trim()?.takeIf { it.isNotEmpty() }?.let { token ->
|
||||
prefs.edit().putString("pairing_token", token).apply()
|
||||
}
|
||||
config["auto_discover"]?.trim()?.takeIf { it.isNotEmpty() }?.let { ad ->
|
||||
prefs.edit().putBoolean("auto_discover", ad == "1" || ad.equals("true", true)).apply()
|
||||
}
|
||||
@@ -356,7 +362,7 @@ class SetupActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
startAgentService(serverUrl, projectId, deviceId)
|
||||
tvSuccessDeviceId.text = deviceId
|
||||
tvSuccessDeviceId.text = DeviceInfo.deviceIdMd5(this@SetupActivity)
|
||||
// BIND-04 连接阶段实时映射(复用 DashboardFragment 人话映射)
|
||||
var elapsed = 0
|
||||
while (isActive && elapsed < 15_000) {
|
||||
@@ -385,8 +391,8 @@ class SetupActivity : AppCompatActivity() {
|
||||
|
||||
private fun copyDeviceId(deviceId: String) {
|
||||
val cm = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
cm.setPrimaryClip(ClipData.newPlainText("device_id", deviceId))
|
||||
Toast.makeText(this, "已复制 device_id", Toast.LENGTH_SHORT).show()
|
||||
cm.setPrimaryClip(ClipData.newPlainText("device_id_md5", deviceId))
|
||||
Toast.makeText(this, "已复制设备 MD5", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun safeStart(intent: Intent) {
|
||||
|
||||
@@ -79,9 +79,11 @@ class StatusFragment : Fragment() {
|
||||
}
|
||||
|
||||
val fridaReady = engine?.isFridaReady() == true
|
||||
val hookAttached = engine?.isWechatHookAttached() == true
|
||||
tvFrida.text = when {
|
||||
engine == null -> "⬜ Frida 未启用"
|
||||
fridaReady -> "✅ Frida 就绪"
|
||||
hookAttached -> "✅ 微信 Hook 已注入"
|
||||
fridaReady -> "🟡 Frida 服务就绪 · 微信 Hook 未注入"
|
||||
engine.hasFridaBridge() -> "🔄 Frida 启动中…"
|
||||
else -> "⬜ Frida 未启用(无 Root 或未拉起)"
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ class WechatFragment : Fragment() {
|
||||
val info = DeviceInfo.collectQuick(requireContext())
|
||||
val engine = AgentForegroundService.instance?.engine
|
||||
val isConnected = engine?.wsManager?.isConnected ?: false
|
||||
val hookReady = engine?.isFridaReady() == true
|
||||
val hookReady = engine?.isWechatHookAttached() == true
|
||||
val antiBanStatus = engine?.getAntiBanStatus()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
|
||||
|
After Width: | Height: | Size: 224 KiB |
@@ -1,14 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 极简中性图标(无「手机+蓝星」品牌图),设置/通知等仍需要合法 adaptive foreground -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#E8E8E8"
|
||||
android:pathData="M54,30c-13.2,0 -24,10.8 -24,24s10.8,24 24,24 24,-10.8 24,-24 -10.8,-24 -24,-24zM54,38c8.8,0 16,7.2 16,16s-7.2,16 -16,16 -16,-7.2 -16,-16 7.2,-16 16,-16z" />
|
||||
<path
|
||||
android:fillColor="#E8E8E8"
|
||||
android:pathData="M48,72h12v6H48z" />
|
||||
</vector>
|
||||
<!-- 工作手机:云端连接 + 设备网络 + 安全盾牌;保留 adaptive icon 安全区。 -->
|
||||
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:drawable="@drawable/workphone_launcher_art"
|
||||
android:inset="12dp" />
|
||||
|
||||
@@ -294,7 +294,7 @@
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="设备ID"
|
||||
android:text="设备 ID · MD5"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/text_tertiary"
|
||||
android:layout_marginBottom="6dp" />
|
||||
@@ -314,7 +314,7 @@
|
||||
android:id="@+id/btnCopySuccess"
|
||||
android:layout_width="260dp"
|
||||
android:layout_height="42dp"
|
||||
android:text="复制 device_id"
|
||||
android:text="复制设备 MD5"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:background="@drawable/btn_glass_secondary"
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="技术绑定已完成,接下来在存客宝完成业务入库:"
|
||||
android:text="本机由工作手机主动扫描系统二维码完成服务器与项目绑定:"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:layout_marginBottom="8dp" />
|
||||
@@ -147,7 +147,7 @@
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="1. 点击上方「复制」复制 device_id"
|
||||
android:text="1. 在系统设备管理中生成绑定二维码"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:layout_marginBottom="4dp" />
|
||||
@@ -155,7 +155,7 @@
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="2. 打开存客宝 → 设备管理 → 扫码/添加设备"
|
||||
android:text="2. 回到工作台点击右上角扫码按钮"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:layout_marginBottom="4dp" />
|
||||
@@ -163,7 +163,7 @@
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="3. 粘贴 device_id 或扫描本机二维码完成入库"
|
||||
android:text="3. 工作手机扫描二维码后自动连接并上报设备"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:layout_marginBottom="10dp" />
|
||||
@@ -172,11 +172,12 @@
|
||||
android:id="@+id/btnShowDeviceQr"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="42dp"
|
||||
android:text="展示 device_id 二维码"
|
||||
android:text="旧版设备二维码(已停用)"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/brand_primary"
|
||||
android:background="@drawable/btn_glass_secondary"
|
||||
android:textAllCaps="false" />
|
||||
android:textAllCaps="false"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- MIUI 保活 checklist · WP-AGENT-01 -->
|
||||
|
||||
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 37 KiB |
@@ -66,6 +66,6 @@
|
||||
<color name="orb_secondary">#2660A5FA</color>
|
||||
<color name="orb_accent">#1A8B5CF6</color>
|
||||
|
||||
<!-- 应用图标底色:中性灰,避免桌面/设置里显眼「蓝底工作」品牌 -->
|
||||
<color name="ic_launcher_bg">#505050</color>
|
||||
<!-- 工作手机应用图标底色:企业蓝,与云端设备管理主题一致。 -->
|
||||
<color name="ic_launcher_bg">#0876E8</color>
|
||||
</resources>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">AI数智员工</string>
|
||||
<string name="app_name">工作手机</string>
|
||||
<string name="launcher_name">工作手机</string>
|
||||
<string name="app_version_tagline">v5.0.1 · 设备同步服务</string>
|
||||
<string name="accessibility_service_description">AI数智员工服务辅助功能</string>
|
||||
<string name="accessibility_service_description">工作手机设备控制辅助功能</string>
|
||||
<string name="nav_hub">工作台</string>
|
||||
<string name="nav_pwa">业务</string>
|
||||
<string name="nav_status">状态</string>
|
||||
|
||||
1
sdk/app/agent/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# 设备端 Agent 模块包(ai_brain、skills 等)
|
||||
59
sdk/app/agent/sdk_discovery.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
设备端 SDK 自动发现 — 监听 UDP beacon(8898),无线主控免手填 IP。
|
||||
|
||||
与 sdk/app/services/discovery_service.py BEACON 协议对齐。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
BEACON_PORT = 8898
|
||||
BEACON_MAGIC = "WORKPHONE_SDK"
|
||||
|
||||
|
||||
def _pick_best_ip(ips: list) -> str:
|
||||
"""优先工作手机常用网段,其次任一私网 IP。"""
|
||||
for ip in ips:
|
||||
if str(ip).startswith("192.168.110."):
|
||||
return ip
|
||||
for ip in ips:
|
||||
if str(ip).startswith(("192.168.", "10.", "172.")):
|
||||
return ip
|
||||
return str(ips[0])
|
||||
|
||||
|
||||
def discover_sdk_ws_base(timeout: float = 20.0) -> Optional[str]:
|
||||
"""
|
||||
监听局域网 UDP beacon,返回 WebSocket 基础地址。
|
||||
例: ws://192.168.110.251:8899/ws/device
|
||||
"""
|
||||
deadline = time.time() + timeout
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
sock.bind(("", BEACON_PORT))
|
||||
except OSError:
|
||||
# Termux 可能无权限绑 8898,改绑任意端口并只 recv
|
||||
sock.bind(("", 0))
|
||||
sock.settimeout(1.0)
|
||||
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
data, _addr = sock.recvfrom(4096)
|
||||
payload = json.loads(data.decode("utf-8"))
|
||||
if payload.get("magic") != BEACON_MAGIC:
|
||||
continue
|
||||
port = int(payload.get("port") or 8899)
|
||||
ws_path = (payload.get("ws_path") or "/ws/device").rstrip("/")
|
||||
ips = payload.get("ips") or []
|
||||
host = _pick_best_ip(ips) if ips else "127.0.0.1"
|
||||
return f"ws://{host}:{port}{ws_path}"
|
||||
except socket.timeout:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
@@ -13,7 +13,7 @@ import re
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _gemini_url():
|
||||
key = os.getenv("GEMINI_API_KEY", "AIzaSyCPARryq8o6MKptLoT4STAvCsRB7uZuOK8")
|
||||
key = os.getenv("GEMINI_API_KEY", "")
|
||||
return f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={key}"
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ class Settings(BaseSettings):
|
||||
|
||||
# API密钥
|
||||
API_KEY: str = "workphone-secret-key"
|
||||
API_AUTH_ENABLED: bool = False
|
||||
DEVICE_PAIRING_TOKEN: str = ""
|
||||
|
||||
# MongoDB
|
||||
MONGO_URI: str = "mongodb://localhost:27017"
|
||||
@@ -43,6 +45,12 @@ class Settings(BaseSettings):
|
||||
WORKPHONE_WS_FIRST: bool = True # 无线主控:execute/probe 优先 WS Agent;主机 ADB 仅运维/兜底
|
||||
WORKPHONE_HOST_ADB_PROBE: bool = False # hook/probe 是否允许主机 adb+frida 探测(默认关,纯 WS)
|
||||
|
||||
# 宝塔侧 Frida 反向隧道。手机仅负责维持隧道,微信 RPC 在 SDK 容器内执行。
|
||||
SERVER_FRIDA_ENABLED: bool = False
|
||||
SERVER_FRIDA_HOST: str = "172.18.0.1"
|
||||
SERVER_FRIDA_PORT: int = 14538
|
||||
SERVER_FRIDA_DEVICE_ID: str = "" # 留空表示接受任意在线设备;生产建议填写真机 ID
|
||||
|
||||
# AI 心跳任务队列:0 表示无上限(每周期抽空队列 / 队列不截断)
|
||||
AI_HEARTBEAT_TASK_BATCH: int = 0
|
||||
AI_HEARTBEAT_QUEUE_MAXLEN: int = 0
|
||||
|
||||
114
sdk/app/data/connection_providers.json
Normal file
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"active": {
|
||||
"global": "jiqing",
|
||||
"projects": {},
|
||||
"devices": {
|
||||
"xgfe65eimrrofyws": "jiqing"
|
||||
}
|
||||
},
|
||||
"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": "",
|
||||
"base_url": "",
|
||||
"base_url_env": "",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"_updated_at": 1780046806
|
||||
}
|
||||
@@ -184,7 +184,7 @@
|
||||
"frida_version": "",
|
||||
"root_status": false,
|
||||
"hook_framework": "frida-server",
|
||||
"updated_at": "2026-07-11T16:05:52.746105+00:00",
|
||||
"updated_at": "2026-07-14T05:30:03.643828+00:00",
|
||||
"modules": []
|
||||
},
|
||||
"6c08d7b2de2fd67a5e24753a6b6920a5": {
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
存客宝的AI手机控制引擎
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
import hmac
|
||||
from contextlib import asynccontextmanager
|
||||
import logging
|
||||
import os
|
||||
@@ -619,6 +620,29 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
_PUBLIC_HTTP_PATHS = {
|
||||
"/", "/health", "/ready", "/openapi.json", "/docs", "/redoc",
|
||||
"/docs/oauth2-redirect", "/llms.txt", "/favicon.ico",
|
||||
}
|
||||
|
||||
|
||||
@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/")
|
||||
if settings.API_AUTH_ENABLED and not public:
|
||||
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):
|
||||
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(unified.router, prefix="/api/v3", tags=["统一接口"])
|
||||
@@ -869,6 +893,11 @@ async def serve_install_script():
|
||||
@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
|
||||
await ws_hub.connect(websocket, device_id)
|
||||
try:
|
||||
while True:
|
||||
|
||||
@@ -227,11 +227,14 @@ async def stop_app(serial: str, req: AppRequest):
|
||||
|
||||
|
||||
@router.get("/devices/{serial}/app/current")
|
||||
def current_app(serial: str):
|
||||
async def current_app(serial: str):
|
||||
"""获取当前APP"""
|
||||
device = adb_manager.get_device(serial)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
result = await _ws_execute(serial, "get_foreground", timeout=15)
|
||||
if result:
|
||||
return result
|
||||
_raise_not_found()
|
||||
|
||||
return device.current_app()
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from services.ws_hub import ws_hub
|
||||
from services.adb_device import adb_manager
|
||||
from services.hook_module_service import hook_module_service
|
||||
from services.connection_priority import connection_priority
|
||||
from services.device_id_util import device_id_md5, enrich_device_id_fields
|
||||
from services.device_id_util import device_id_md5, enrich_device_id_fields, sanitize_sensitive_fields
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -156,7 +156,7 @@ async def get_connection_status() -> Dict[str, Any]:
|
||||
|
||||
return {
|
||||
"code": 200,
|
||||
"data": {
|
||||
"data": sanitize_sensitive_fields({
|
||||
"server_time": _iso_now(),
|
||||
"ws_path": "/ws/device/{device_id}",
|
||||
"hook_events_stream": "/api/v3/hook/events/stream",
|
||||
@@ -170,7 +170,7 @@ async def get_connection_status() -> Dict[str, Any]:
|
||||
"adb_serials_md5": {s: device_id_md5(s) for s in adb_devices},
|
||||
"transport_policy": transport_policy,
|
||||
"devices": device_rows,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
|
||||
138
sdk/app/routers/connection_provider.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
设备连接方案 · 可切换驱动 API
|
||||
|
||||
供存客宝四端(存客宝/触客宝/AI数智员工/SuperAdmin)与超管 UI 切换「设备连接解决方案」:
|
||||
- 列出所有方案 / 当前生效方案
|
||||
- 一键切换(全局 / 按项目 / 按设备)
|
||||
- 注册/更新/删除自定义方案(预留接口,新方案无需改代码)
|
||||
- 健康检查 + 统一执行(验证切换后功能可用)
|
||||
|
||||
真源:sdk/app/services/connection_provider.py
|
||||
文档:开发文档/1、需求/修改/工作手机_设备Agent与基础设施_20260529.md §3.6
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.connection_provider import (
|
||||
connection_provider_manager as mgr,
|
||||
SCOPE_GLOBAL,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SwitchRequest(BaseModel):
|
||||
provider: str = Field(..., description="目标连接方案 id:jiqing / aochuang / legacy / custom_*")
|
||||
scope: str = Field(default=SCOPE_GLOBAL, description="作用域:global / project / device")
|
||||
project_id: str = ""
|
||||
device_id: str = ""
|
||||
|
||||
|
||||
class RegisterProviderRequest(BaseModel):
|
||||
id: str = Field(..., description="方案 id(自定义建议 custom_ 前缀)")
|
||||
name: str = ""
|
||||
kind: str = Field(default="http", description="native / http")
|
||||
enabled: bool = True
|
||||
desc: str = ""
|
||||
base_url: str = ""
|
||||
base_url_env: str = ""
|
||||
auth: Optional[Dict[str, Any]] = None
|
||||
health: Optional[Dict[str, Any]] = None
|
||||
endpoints: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class ProviderExecuteRequest(BaseModel):
|
||||
device_id: str
|
||||
script: str = Field(default="wechat", description="平台:wechat/douyin/xhs/...")
|
||||
action: str = Field(..., description="规范化动作或平台 action")
|
||||
params: Dict[str, Any] = Field(default_factory=dict)
|
||||
provider: str = Field(default="", description="强制指定方案;空则按开关解析")
|
||||
project_id: str = ""
|
||||
timeout: int = 120
|
||||
|
||||
|
||||
@router.get("/connection/providers")
|
||||
async def list_connection_providers() -> Dict[str, Any]:
|
||||
"""列出所有设备连接方案 + 当前开关。"""
|
||||
return {
|
||||
"code": 200,
|
||||
"data": {
|
||||
"providers": mgr.list_providers(),
|
||||
"active": mgr.active_config(),
|
||||
"scopes": ["global", "project", "device"],
|
||||
"doc": "开发文档/1、需求/修改/工作手机_设备Agent与基础设施_20260529.md §3.6",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/connection/provider/active")
|
||||
async def get_active_provider(device_id: str = "", project_id: str = "") -> Dict[str, Any]:
|
||||
"""解析指定作用域当前生效的连接方案(设备 > 项目 > 全局)。"""
|
||||
pid = mgr.resolve_active_id(device_id=device_id, project_id=project_id)
|
||||
prov = mgr.get(pid)
|
||||
return {"code": 200, "data": {
|
||||
"active_provider": pid,
|
||||
"device_id": device_id,
|
||||
"project_id": project_id,
|
||||
"meta": prov.meta() if prov else None,
|
||||
}}
|
||||
|
||||
|
||||
@router.post("/connection/provider/switch")
|
||||
async def switch_connection_provider(req: SwitchRequest) -> Dict[str, Any]:
|
||||
"""一键切换设备连接方案(开关)。"""
|
||||
try:
|
||||
result = mgr.switch(req.provider, scope=req.scope,
|
||||
project_id=req.project_id, device_id=req.device_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return {"code": 200, "message": f"已切换到连接方案 {req.provider}", "data": result}
|
||||
|
||||
|
||||
@router.post("/connection/provider/register")
|
||||
async def register_connection_provider(req: RegisterProviderRequest) -> Dict[str, Any]:
|
||||
"""注册/更新连接方案(预留接口:自定义工作手机方案无需改代码即可接入)。"""
|
||||
try:
|
||||
meta = mgr.register(req.model_dump(exclude_none=True))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return {"code": 200, "message": f"连接方案 {req.id} 已登记", "data": meta}
|
||||
|
||||
|
||||
@router.delete("/connection/provider/{provider_id}")
|
||||
async def delete_connection_provider(provider_id: str) -> Dict[str, Any]:
|
||||
"""删除自定义连接方案(内置方案不可删,改用禁用)。"""
|
||||
try:
|
||||
result = mgr.remove(provider_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return {"code": 200, "message": f"连接方案 {provider_id} 已删除", "data": result}
|
||||
|
||||
|
||||
@router.get("/connection/provider/{provider_id}/health")
|
||||
async def provider_health(provider_id: str) -> Dict[str, Any]:
|
||||
"""连接方案健康/连通性检查。"""
|
||||
prov = mgr.get(provider_id)
|
||||
if not prov:
|
||||
raise HTTPException(status_code=404, detail=f"未知连接方案: {provider_id}")
|
||||
return {"code": 200, "data": await prov.health()}
|
||||
|
||||
|
||||
@router.post("/connection/provider/execute")
|
||||
async def provider_execute(req: ProviderExecuteRequest) -> Dict[str, Any]:
|
||||
"""经当前(或指定)连接方案统一执行——验证切换后功能可用。"""
|
||||
if req.provider:
|
||||
prov = mgr.get(req.provider)
|
||||
if not prov:
|
||||
raise HTTPException(status_code=404, detail=f"未知连接方案: {req.provider}")
|
||||
else:
|
||||
prov = mgr.resolve_active(device_id=req.device_id, project_id=req.project_id)
|
||||
result = await prov.execute(req.device_id, req.script, req.action, req.params, timeout=req.timeout)
|
||||
if isinstance(result, dict):
|
||||
result.setdefault("provider", prov.id)
|
||||
return {"code": result.get("code", 200) if isinstance(result, dict) else 200, "data": result}
|
||||
@@ -129,7 +129,7 @@ async def get_device(device_id: str):
|
||||
else:
|
||||
device["status"] = "offline"
|
||||
device["device_id"] = device_id
|
||||
from services.device_id_util import enrich_device_id_fields
|
||||
from services.device_id_util import enrich_device_id_fields, sanitize_sensitive_fields
|
||||
enrich_device_id_fields(device)
|
||||
|
||||
# 若 model/brand/android_version 为空或 Unknown,从 ADB 补充(设备可能同时连 WS 和 USB)
|
||||
@@ -153,7 +153,7 @@ async def get_device(device_id: str):
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
pass
|
||||
|
||||
return {"code": 200, "data": device}
|
||||
return {"code": 200, "data": sanitize_sensitive_fields(device)}
|
||||
|
||||
|
||||
@router.get("/devices/{device_id}/heartbeat", response_model=dict)
|
||||
|
||||
224
sdk/app/routers/fleet.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
多手机设备管理接口。
|
||||
|
||||
面向存客宝/触客宝/超管等外部系统:把单机 SDK 能力包装成可筛选、
|
||||
可批量执行、可审计的 Fleet API。单机能力仍由 devices/unified 路由负责。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.device_fleet import list_merged_local_devices
|
||||
from services.device_id_util import device_id_md5
|
||||
from services.ws_hub import ws_hub
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
WRITE_ACTIONS = {
|
||||
"send_message",
|
||||
"batch_send",
|
||||
"mass_send",
|
||||
"add_friend",
|
||||
"batch_add_friend",
|
||||
"post_moments",
|
||||
"like_moments",
|
||||
"comment_moments",
|
||||
"create_group",
|
||||
"invite_to_group",
|
||||
"remove_from_group",
|
||||
"set_group_notice",
|
||||
"set_group_name",
|
||||
"set_group_welcome",
|
||||
"delete_tag",
|
||||
"create_tag",
|
||||
"tag_add",
|
||||
"tag_remove",
|
||||
}
|
||||
|
||||
|
||||
class FleetExecuteRequest(BaseModel):
|
||||
"""批量执行请求。"""
|
||||
|
||||
device_ids: Optional[List[str]] = None
|
||||
project_id: Optional[str] = None
|
||||
all_online: bool = False
|
||||
platform: str = "wechat"
|
||||
action: str
|
||||
params: Dict[str, Any] = Field(default_factory=dict)
|
||||
hook_only: bool = False
|
||||
timeout: int = 60
|
||||
max_concurrency: int = 3
|
||||
dry_run: bool = False
|
||||
confirm: bool = False
|
||||
|
||||
|
||||
def _normalize_status(device: dict) -> str:
|
||||
if ws_hub.is_online(device.get("device_id", "")):
|
||||
return "online"
|
||||
return str(device.get("status") or "offline")
|
||||
|
||||
|
||||
async def _select_devices(
|
||||
*,
|
||||
device_ids: Optional[List[str]] = None,
|
||||
project_id: Optional[str] = None,
|
||||
all_online: bool = False,
|
||||
status: str = "",
|
||||
capability: str = "",
|
||||
) -> List[dict]:
|
||||
devices = await list_merged_local_devices()
|
||||
selected = []
|
||||
wanted = set(device_ids or [])
|
||||
for device in devices:
|
||||
did = device.get("device_id", "")
|
||||
if wanted and did not in wanted:
|
||||
continue
|
||||
if project_id and str(device.get("project_id") or "") != str(project_id):
|
||||
continue
|
||||
if all_online and not ws_hub.is_online(did):
|
||||
continue
|
||||
if status and _normalize_status(device) != status:
|
||||
continue
|
||||
if capability and capability not in (device.get("capabilities") or []):
|
||||
continue
|
||||
normalized = dict(device)
|
||||
normalized["status"] = _normalize_status(normalized)
|
||||
normalized["online"] = ws_hub.is_online(did)
|
||||
normalized["device_id_md5"] = normalized.get("device_id_md5") or device_id_md5(did)
|
||||
selected.append(normalized)
|
||||
return selected
|
||||
|
||||
|
||||
@router.get("/fleet/summary", response_model=dict)
|
||||
async def fleet_summary(project_id: str = ""):
|
||||
"""所有手机/项目手机总览。"""
|
||||
devices = await _select_devices(project_id=project_id or None)
|
||||
online = [d for d in devices if d.get("online")]
|
||||
adb = [d for d in devices if d.get("status") == "adb"]
|
||||
offline = [d for d in devices if not d.get("online") and d.get("status") != "adb"]
|
||||
by_project: Dict[str, int] = {}
|
||||
for device in devices:
|
||||
pid = str(device.get("project_id") or "")
|
||||
by_project[pid] = by_project.get(pid, 0) + 1
|
||||
return {
|
||||
"code": 200,
|
||||
"data": {
|
||||
"total": len(devices),
|
||||
"online": len(online),
|
||||
"adb": len(adb),
|
||||
"offline": len(offline),
|
||||
"by_project": by_project,
|
||||
"device_ids": [d.get("device_id") for d in devices],
|
||||
"online_device_ids": [d.get("device_id") for d in online],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/fleet/devices", response_model=dict)
|
||||
async def fleet_devices(
|
||||
project_id: str = "",
|
||||
status: str = "",
|
||||
capability: str = "",
|
||||
online_only: bool = False,
|
||||
):
|
||||
"""按项目、状态、能力筛选设备列表。"""
|
||||
devices = await _select_devices(
|
||||
project_id=project_id or None,
|
||||
status=status,
|
||||
capability=capability,
|
||||
all_online=online_only,
|
||||
)
|
||||
return {"code": 200, "data": {"devices": devices, "count": len(devices)}}
|
||||
|
||||
|
||||
@router.post("/fleet/execute", response_model=dict)
|
||||
async def fleet_execute(req: FleetExecuteRequest):
|
||||
"""在多台在线手机上批量执行同一个设备动作。"""
|
||||
devices = await _select_devices(
|
||||
device_ids=req.device_ids,
|
||||
project_id=req.project_id,
|
||||
all_online=req.all_online or not req.device_ids,
|
||||
status="online",
|
||||
)
|
||||
if not devices:
|
||||
raise HTTPException(status_code=404, detail="没有匹配的在线设备")
|
||||
|
||||
is_write = req.action in WRITE_ACTIONS
|
||||
if is_write and len(devices) > 1 and not (req.dry_run or req.confirm):
|
||||
return {
|
||||
"code": 200,
|
||||
"data": {
|
||||
"success": False,
|
||||
"confirm_required": True,
|
||||
"reason": "批量写类动作需要 confirm=true;可先 dry_run=true 查看目标设备",
|
||||
"action": req.action,
|
||||
"target_count": len(devices),
|
||||
"targets": [d.get("device_id") for d in devices],
|
||||
},
|
||||
}
|
||||
if req.dry_run:
|
||||
return {
|
||||
"code": 200,
|
||||
"data": {
|
||||
"success": True,
|
||||
"dry_run": True,
|
||||
"action": req.action,
|
||||
"target_count": len(devices),
|
||||
"targets": [d.get("device_id") for d in devices],
|
||||
},
|
||||
}
|
||||
|
||||
sem = asyncio.Semaphore(max(1, min(int(req.max_concurrency or 1), 10)))
|
||||
timeout = max(5, min(int(req.timeout or 60), 300))
|
||||
|
||||
async def run_one(device: dict) -> dict:
|
||||
did = device.get("device_id", "")
|
||||
async with sem:
|
||||
try:
|
||||
result = await ws_hub.send_command(
|
||||
did,
|
||||
{
|
||||
"type": "execute",
|
||||
"data": {
|
||||
"script": req.platform,
|
||||
"action": req.action,
|
||||
"params": req.params or {},
|
||||
"hook_only": req.hook_only,
|
||||
},
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
return {
|
||||
"device_id": did,
|
||||
"device_id_md5": device_id_md5(did),
|
||||
"success": result.get("code") == 200,
|
||||
"result": result,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"device_id": did,
|
||||
"device_id_md5": device_id_md5(did),
|
||||
"success": False,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
results = await asyncio.gather(*(run_one(device) for device in devices))
|
||||
ok = sum(1 for item in results if item.get("success"))
|
||||
return {
|
||||
"code": 200,
|
||||
"data": {
|
||||
"success": ok == len(results),
|
||||
"action": req.action,
|
||||
"target_count": len(results),
|
||||
"success_count": ok,
|
||||
"failed_count": len(results) - ok,
|
||||
"results": results,
|
||||
},
|
||||
}
|
||||
294
sdk/app/routers/integration.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""
|
||||
对外接口统一清单 · 集成中心路由
|
||||
|
||||
路由前缀:/api/v3/integration
|
||||
|
||||
供存客宝 / 超级管理端 / AI 数字员工 直接发现并调用工作手机 SDK 的全部对外接口:
|
||||
- GET /integration/manifest 全量接口清单(按模块归类)
|
||||
- GET /integration/modules 模块目录(精简,不含端点明细)
|
||||
- GET /integration/consumers 消费方列表
|
||||
- GET /integration/consumers/{consumer} 某消费方可用的接口子集
|
||||
- GET /integration/health 关键集成点健康聚合(WS/连接方案/存客宝/网关)
|
||||
|
||||
清单从 FastAPI 路由动态生成,始终与代码同步;本路由只读、零副作用。
|
||||
严禁修改存客宝 / AI 数字员工 / 超管代码——本中心仅暴露工作手机侧出口。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from services.integration_manifest import (
|
||||
build_manifest,
|
||||
build_capability,
|
||||
filter_by_consumer,
|
||||
list_modules,
|
||||
CONSUMER_LABELS,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/integration/manifest", tags=["对外接口统一清单"])
|
||||
async def integration_manifest(request: Request) -> Dict[str, Any]:
|
||||
"""全量对外接口清单(按模块 + 消费方归类,机器可读)。"""
|
||||
manifest = build_manifest(request.app)
|
||||
return {"code": 200, "data": manifest}
|
||||
|
||||
|
||||
@router.get("/integration/modules", tags=["对外接口统一清单"])
|
||||
async def integration_modules(request: Request) -> Dict[str, Any]:
|
||||
"""模块目录(精简:模块/标签/消费方/端点数)。"""
|
||||
manifest = build_manifest(request.app)
|
||||
return {"code": 200, "data": {
|
||||
"module_count": manifest["module_count"],
|
||||
"total_endpoints": manifest["total_endpoints"],
|
||||
"modules": list_modules(manifest),
|
||||
}}
|
||||
|
||||
|
||||
@router.get("/integration/consumers", tags=["对外接口统一清单"])
|
||||
async def integration_consumers() -> Dict[str, Any]:
|
||||
"""消费方列表(存客宝/超管/AI数字员工/通用)。"""
|
||||
return {"code": 200, "data": {
|
||||
"consumers": [
|
||||
{"id": cid, "label": label} for cid, label in CONSUMER_LABELS.items()
|
||||
]
|
||||
}}
|
||||
|
||||
|
||||
@router.get("/integration/consumers/{consumer}", tags=["对外接口统一清单"])
|
||||
async def integration_consumer_view(consumer: str, request: Request) -> Dict[str, Any]:
|
||||
"""
|
||||
某消费方可直接调用的接口子集。
|
||||
|
||||
consumer ∈ {cunkebao, superadmin, ai_employee, common}
|
||||
"""
|
||||
if consumer not in CONSUMER_LABELS:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"未知消费方: {consumer},可选: {list(CONSUMER_LABELS.keys())}",
|
||||
)
|
||||
manifest = build_manifest(request.app)
|
||||
return {"code": 200, "data": filter_by_consumer(manifest, consumer)}
|
||||
|
||||
|
||||
@router.get("/integration/capability/{device_id}", tags=["对外接口统一清单"])
|
||||
async def integration_capability(
|
||||
device_id: str,
|
||||
request: Request,
|
||||
consumer: str = "",
|
||||
probe: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
某设备的实时能力矩阵:各模块此刻是否可直接调用(ready/degraded/offline)。
|
||||
|
||||
- consumer: 可选,仅看某消费方(cunkebao/superadmin/ai_employee/common)的模块
|
||||
- probe=true: 触发一次轻量 Frida 探测刷新 supports_hook(仅 ping/version/profile,
|
||||
不做全量 174 探针,避免 Frida 瞬断);默认读最近一次探测缓存。
|
||||
"""
|
||||
if consumer and consumer not in CONSUMER_LABELS:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"未知消费方: {consumer},可选: {list(CONSUMER_LABELS.keys())}",
|
||||
)
|
||||
|
||||
# 1) 设备在线
|
||||
online = False
|
||||
try:
|
||||
from services.ws_hub import ws_hub
|
||||
online = ws_hub.is_online(device_id)
|
||||
except Exception: # noqa: BLE001
|
||||
online = False
|
||||
|
||||
# 2) supports_hook:默认读最近探测缓存;probe=true 时轻量刷新
|
||||
supports_hook = False
|
||||
if online and probe:
|
||||
try:
|
||||
from routers.unified import hook_probe # 复用安全的轻量探测
|
||||
pr = await hook_probe(device_id)
|
||||
supports_hook = bool(pr.get("supports_hook"))
|
||||
except Exception: # noqa: BLE001
|
||||
supports_hook = False
|
||||
else:
|
||||
try:
|
||||
from services.hook_module_service import hook_module_service
|
||||
dev = await hook_module_service.get_device_modules(device_id)
|
||||
if isinstance(dev, dict):
|
||||
supports_hook = bool(dev.get("supports_hook"))
|
||||
except Exception: # noqa: BLE001
|
||||
supports_hook = False
|
||||
# 退化补充:用 ws_hub 上报的 frida_available
|
||||
if not supports_hook:
|
||||
try:
|
||||
from services.ws_hub import ws_hub
|
||||
info = ws_hub.get_device_info(device_id) or {}
|
||||
supports_hook = bool(info.get("frida_available"))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
manifest = build_manifest(request.app)
|
||||
cap = build_capability(
|
||||
manifest, device_id, online, supports_hook,
|
||||
consumer=consumer or None,
|
||||
)
|
||||
cap["probed"] = bool(probe and online)
|
||||
return {"code": 200, "data": cap}
|
||||
|
||||
|
||||
@router.get("/integration/health", tags=["对外接口统一清单"])
|
||||
async def integration_health(request: Request) -> Dict[str, Any]:
|
||||
"""
|
||||
关键集成点健康聚合:
|
||||
- websocket 主控(在线设备数)
|
||||
- 连接方案(当前生效 provider)
|
||||
- 存客宝对接(是否已配置启用)
|
||||
- AI 网关(可用协议)
|
||||
供超管/存客宝一眼看清「能不能直接对接」。
|
||||
"""
|
||||
health: Dict[str, Any] = {"ok": True, "checks": {}}
|
||||
|
||||
# 1) WebSocket 主控
|
||||
try:
|
||||
from services.ws_hub import ws_hub
|
||||
online = list(ws_hub.connections.keys())
|
||||
health["checks"]["websocket"] = {
|
||||
"ok": True,
|
||||
"online_devices": len(online),
|
||||
"device_ids": online,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
health["checks"]["websocket"] = {"ok": False, "error": str(e)}
|
||||
health["ok"] = False
|
||||
|
||||
# 2) 连接方案(可切换驱动)
|
||||
try:
|
||||
from services.connection_provider import connection_provider_manager as mgr
|
||||
active = mgr.active_config()
|
||||
health["checks"]["connection_provider"] = {
|
||||
"ok": True,
|
||||
"active": active,
|
||||
"providers": [p.get("id") for p in mgr.list_providers()],
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
health["checks"]["connection_provider"] = {"ok": False, "error": str(e)}
|
||||
|
||||
# 3) 存客宝对接配置
|
||||
try:
|
||||
from services.cunke_bao_service import cunke_bao_service
|
||||
cfg = cunke_bao_service.config
|
||||
health["checks"]["cunkebao"] = {
|
||||
"ok": True,
|
||||
"enabled": bool(getattr(cfg, "enabled", False)),
|
||||
"api_key_set": bool(getattr(cfg, "api_key", "")),
|
||||
"base_url": getattr(cfg, "base_url", ""),
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
health["checks"]["cunkebao"] = {"ok": False, "error": str(e)}
|
||||
|
||||
# 4) AI 网关协议
|
||||
health["checks"]["ai_gateway"] = {
|
||||
"ok": True,
|
||||
"protocols": {
|
||||
"openai_compatible": "/api/v3/gateway/v1/chat/completions",
|
||||
"mcp_tools": "/api/v3/gateway/mcp/tools",
|
||||
"mcp_call": "/api/v3/gateway/mcp/call",
|
||||
"agent_execute": "/api/v3/agent/execute",
|
||||
},
|
||||
}
|
||||
|
||||
return {"code": 200, "data": health}
|
||||
|
||||
|
||||
@router.get("/integration/realtime/status", tags=["对外接口统一清单"])
|
||||
async def integration_realtime_status(
|
||||
request: Request,
|
||||
consumer: str = "",
|
||||
event_limit: int = 20,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
第三方对接实时状态入口。
|
||||
|
||||
面向存客宝/纯客宝/超管/AI 数字员工:一次返回在线设备、最近 WS/Hook/Agent 事件、
|
||||
实时 WebSocket 订阅地址和推荐拉取接口。
|
||||
"""
|
||||
if consumer and consumer not in CONSUMER_LABELS:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"未知消费方: {consumer},可选: {list(CONSUMER_LABELS.keys())}",
|
||||
)
|
||||
|
||||
try:
|
||||
from services.device_fleet import list_merged_local_devices
|
||||
from services.ws_hub import ws_hub
|
||||
from services.hook_module_service import hook_module_service
|
||||
|
||||
devices = await list_merged_local_devices()
|
||||
normalized = []
|
||||
for item in devices:
|
||||
did = item.get("device_id", "")
|
||||
normalized.append({
|
||||
"device_id": did,
|
||||
"device_id_md5": item.get("device_id_md5"),
|
||||
"project_id": item.get("project_id"),
|
||||
"model": item.get("model"),
|
||||
"status": "online" if ws_hub.is_online(did) else item.get("status", "offline"),
|
||||
"online": ws_hub.is_online(did),
|
||||
"capabilities": item.get("capabilities") or [],
|
||||
"frida": item.get("frida") or {},
|
||||
"ai_brain": item.get("ai_brain") or {},
|
||||
"wechat_version": (item.get("device_profile") or {}).get("wechat_version"),
|
||||
"last_heartbeat": item.get("last_heartbeat"),
|
||||
})
|
||||
recent_events = await hook_module_service.list_events(
|
||||
limit=max(1, min(int(event_limit or 20), 100)),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
return {
|
||||
"code": 200,
|
||||
"data": {
|
||||
"consumer": consumer or "common",
|
||||
"online_count": sum(1 for item in normalized if item.get("online")),
|
||||
"total_count": len(normalized),
|
||||
"devices": normalized,
|
||||
"recent_events": recent_events,
|
||||
"pull_endpoints": {
|
||||
"status": "/api/v3/integration/realtime/status",
|
||||
"events": "/api/v3/integration/realtime/events",
|
||||
"fleet_devices": "/api/v3/fleet/devices",
|
||||
"fleet_execute": "/api/v3/fleet/execute",
|
||||
},
|
||||
"stream": {
|
||||
"protocol": "websocket",
|
||||
"url": f"{base_url}/api/v3/hook/events/stream",
|
||||
"event_source": "真实 Agent WS / Frida Hook / AI Agent 事件总线",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/integration/realtime/events", tags=["对外接口统一清单"])
|
||||
async def integration_realtime_events(
|
||||
event_type: str = "",
|
||||
device_id: str = "",
|
||||
platform: str = "",
|
||||
limit: int = 100,
|
||||
) -> Dict[str, Any]:
|
||||
"""第三方对接实时事件列表:读取真实 Agent/Hook 事件总线。"""
|
||||
try:
|
||||
from services.hook_module_service import hook_module_service
|
||||
|
||||
events = await hook_module_service.list_events(
|
||||
event_type=event_type or None,
|
||||
device_id=device_id or None,
|
||||
platform=platform or None,
|
||||
limit=max(1, min(int(limit or 100), 500)),
|
||||
)
|
||||
return {"code": 200, "data": {"total": len(events), "events": events}}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
24
sdk/app/routers/process.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
SDK 进程状态路由
|
||||
|
||||
路由前缀:/api/v3/process
|
||||
|
||||
供 connection_keeper、部署探针、控制台轮询「进程是否正常」。
|
||||
只读、零副作用;state=normal 即为期望的正常态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from services.process_state import get_process_status
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/process/status", tags=["进程状态"])
|
||||
async def process_status() -> Dict[str, Any]:
|
||||
"""SDK 主进程正常态快照(pid / uptime / WS 在线数 / keeper 守护进程)。"""
|
||||
return {"code": 200, "data": get_process_status()}
|
||||
@@ -8,6 +8,7 @@ NAS armv7 等环境无 Pillow 编译链时,自动降级为 qrcode + pypng(
|
||||
from fastapi import APIRouter, Response
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Type
|
||||
import os
|
||||
import json
|
||||
import base64
|
||||
import io
|
||||
@@ -49,24 +50,26 @@ class ProjectBindConfig(BaseModel):
|
||||
"""项目绑定配置"""
|
||||
project_id: str
|
||||
project_name: Optional[str] = ""
|
||||
server: Optional[str] = "ws://sdk.quwanzhi.com:8899/ws/device"
|
||||
server: Optional[str] = "wss://wpsdk.quwanzhi.com/ws/device"
|
||||
pwa: Optional[str] = ""
|
||||
ai_api_key: Optional[str] = ""
|
||||
ai_api_url: Optional[str] = "https://ckbapi.quwanzhi.com"
|
||||
# BIND-03/05:公网主服有序回退列表(逗号分隔)+ 开机自动寻服
|
||||
public_servers: Optional[str] = ""
|
||||
auto_discover: Optional[bool] = False
|
||||
pairing_token: Optional[str] = ""
|
||||
|
||||
|
||||
def _build_qr_content(
|
||||
project_id: str,
|
||||
project_name: str = "",
|
||||
server: str = "ws://sdk.quwanzhi.com:8899/ws/device",
|
||||
server: str = "wss://wpsdk.quwanzhi.com/ws/device",
|
||||
pwa: str = "",
|
||||
ai_api_key: str = "",
|
||||
ai_api_url: str = "",
|
||||
public_servers: str = "",
|
||||
auto_discover: bool = False,
|
||||
pairing_token: str = "",
|
||||
) -> str:
|
||||
qr_data = {"server": server, "project": project_id, "project_name": project_name}
|
||||
if pwa:
|
||||
@@ -79,6 +82,9 @@ def _build_qr_content(
|
||||
qr_data["public_servers"] = public_servers
|
||||
if auto_discover:
|
||||
qr_data["auto_discover"] = "1"
|
||||
token = pairing_token or os.environ.get("DEVICE_PAIRING_TOKEN", "")
|
||||
if token:
|
||||
qr_data["pairing_token"] = token
|
||||
return json.dumps(qr_data, ensure_ascii=False)
|
||||
|
||||
|
||||
@@ -128,6 +134,7 @@ async def generate_qrcode(config: ProjectBindConfig):
|
||||
config.ai_api_url or "",
|
||||
config.public_servers or "",
|
||||
bool(config.auto_discover),
|
||||
config.pairing_token or "",
|
||||
)
|
||||
png_bytes = _render_qr_png_bytes(qr_content)
|
||||
img_base64 = base64.b64encode(png_bytes).decode()
|
||||
@@ -146,7 +153,7 @@ async def generate_qrcode(config: ProjectBindConfig):
|
||||
async def get_qrcode_image(
|
||||
project_id: str,
|
||||
project_name: str = "",
|
||||
server: str = "ws://sdk.quwanzhi.com:8899/ws/device",
|
||||
server: str = "wss://wpsdk.quwanzhi.com/ws/device",
|
||||
pwa: str = "",
|
||||
):
|
||||
"""直接获取二维码 PNG 图片"""
|
||||
@@ -168,7 +175,7 @@ async def get_qrcode_image(
|
||||
async def get_qrcode_html(
|
||||
project_id: str,
|
||||
project_name: str = "",
|
||||
server: str = "ws://sdk.quwanzhi.com:8899/ws/device",
|
||||
server: str = "wss://wpsdk.quwanzhi.com/ws/device",
|
||||
pwa: str = "",
|
||||
):
|
||||
"""获取带样式的二维码 HTML 页面"""
|
||||
|
||||
@@ -38,7 +38,7 @@ class AIAgent:
|
||||
def __init__(self):
|
||||
# v0 API配置
|
||||
self.api_url = os.getenv("AI_API_URL", "https://api.v0.dev/v1")
|
||||
self.api_key = os.getenv("AI_API_KEY", "v1:C6mw1SlvXsJdlO4VFEXSQEVf:519gA0DPqIMbjvfMh7CXf4B2")
|
||||
self.api_key = os.getenv("AI_API_KEY", "")
|
||||
self.model = os.getenv("AI_MODEL", "claude-opus")
|
||||
|
||||
# 系统提示词
|
||||
|
||||
203
sdk/app/services/brain_skill_registry.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
中台 AI Brain · Skill 注册表唯一真源
|
||||
|
||||
服务端 GET /api/v3/ai/brain/skill-registry 与存客宝 BFF 均以此为准。
|
||||
设备端 Agent _execute_skill(script, action) 须能 getattr(skill, action)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
# script → {name, icon, package, modules: {模块名: [action, ...]}}
|
||||
SKILL_REGISTRY: Dict[str, Dict[str, Any]] = {
|
||||
"wechat": {
|
||||
"name": "微信",
|
||||
"icon": "💬",
|
||||
"package": "com.tencent.mm",
|
||||
"modules": {
|
||||
"消息": [
|
||||
"send_message", "get_messages", "forward_message", "recall_message",
|
||||
"send_card", "batch_send_message", "send_voice_message", "mass_send",
|
||||
],
|
||||
"好友": [
|
||||
"add_friend", "accept_friend", "set_remark", "delete_friend",
|
||||
"get_contacts", "search_contact", "get_friend_info", "batch_add_friend",
|
||||
],
|
||||
"群聊": [
|
||||
"create_group", "invite_to_group", "remove_from_group", "set_group_notice",
|
||||
"set_group_name", "send_group_message", "set_group_welcome",
|
||||
"get_groups", "get_group_members", "quit_group",
|
||||
],
|
||||
"标签": [
|
||||
"add_tag", "remove_tag", "create_tag", "delete_tag",
|
||||
"get_tags", "get_users_by_tag",
|
||||
],
|
||||
"朋友圈": [
|
||||
"post_moments", "like_moments", "comment_moments", "get_moments",
|
||||
"delete_moments", "set_moments_cover", "set_moments_privacy", "forward_moments_link",
|
||||
],
|
||||
"个人资料": [
|
||||
"get_profile", "set_nickname", "set_signature", "set_avatar",
|
||||
"set_gender", "set_region",
|
||||
],
|
||||
"账号安全": [
|
||||
"check_account_status", "unblock_account", "safety_center", "change_password",
|
||||
"unblock_self", "unblock_appeal", "check_restrictions", "appeal_restriction",
|
||||
"unblock_with_sms", "unblock_via_customer_service", "check_login_state",
|
||||
],
|
||||
"支付": [
|
||||
"send_red_packet", "transfer", "show_payment_code", "receive_payment",
|
||||
"view_wallet", "view_transactions", "receive_red_packet",
|
||||
],
|
||||
"会话管理": ["set_chat_top", "set_mute_chat", "clear_chat_history"],
|
||||
"收藏": ["add_to_favorites", "get_favorites"],
|
||||
"小程序": ["open_mini_program"],
|
||||
"公众号": ["follow_official_account"],
|
||||
"视频号": [
|
||||
"open_video_channel", "get_video_list", "like_video", "comment_video",
|
||||
"follow_video_creator", "share_video",
|
||||
],
|
||||
"扫一扫": ["scan_qr_code", "scan_add_friend", "show_my_qr", "extract_qr_from_image"],
|
||||
"通话": ["voice_call", "video_call"],
|
||||
"搜索发现": ["wechat_search", "top_stories"],
|
||||
"微信运动": ["get_steps", "like_steps"],
|
||||
"位置表情文件": [
|
||||
"send_location", "share_real_time_location", "send_emoji", "get_sticker_list",
|
||||
"send_file_from_chat", "download_file",
|
||||
],
|
||||
"设置": ["toggle_do_not_disturb", "clear_cache", "check_for_update", "logout", "switch_account"],
|
||||
},
|
||||
},
|
||||
"douyin": {
|
||||
"name": "抖音",
|
||||
"icon": "🎵",
|
||||
"package": "com.ss.android.ugc.aweme",
|
||||
"modules": {
|
||||
"消息": ["send_message", "get_messages", "batch_send_message"],
|
||||
"粉丝互动": [
|
||||
"get_fans", "follow_user", "unfollow_user", "search_user",
|
||||
"get_comments", "reply_comment", "like_video", "collect_video", "share_video",
|
||||
],
|
||||
"联系人": [
|
||||
"get_contacts", "add_friend", "accept_friend", "set_remark",
|
||||
"delete_friend", "batch_add_friend",
|
||||
],
|
||||
},
|
||||
},
|
||||
"xhs": {
|
||||
"name": "小红书",
|
||||
"icon": "📕",
|
||||
"package": "com.xingin.xhs",
|
||||
"modules": {
|
||||
"消息": ["send_message", "get_messages", "batch_send_message"],
|
||||
"笔记互动": [
|
||||
"get_fans", "follow_user", "unfollow_user", "search_user",
|
||||
"get_comments", "reply_comment", "like_note", "collect_note",
|
||||
"share_note", "search_note", "post_note",
|
||||
],
|
||||
"联系人": [
|
||||
"get_contacts", "add_friend", "accept_friend", "set_remark",
|
||||
"delete_friend", "batch_add_friend",
|
||||
],
|
||||
},
|
||||
},
|
||||
"xianyu": {
|
||||
"name": "闲鱼",
|
||||
"icon": "🐟",
|
||||
"package": "com.taobao.idlefish",
|
||||
"modules": {
|
||||
"消息": ["send_message", "get_messages", "batch_send_message"],
|
||||
"联系人": [
|
||||
"get_contacts", "add_friend", "accept_friend", "set_remark",
|
||||
"delete_friend", "batch_add_friend", "follow_user", "unfollow_user",
|
||||
],
|
||||
},
|
||||
},
|
||||
"soul": {
|
||||
"name": "Soul",
|
||||
"icon": "👻",
|
||||
"package": "cn.soulapp.android",
|
||||
"modules": {
|
||||
"消息": ["send_message", "get_messages"],
|
||||
"动态": ["post_moments"],
|
||||
},
|
||||
},
|
||||
"system": {
|
||||
"name": "系统控制",
|
||||
"icon": "⚙️",
|
||||
"package": "",
|
||||
"modules": {
|
||||
"设备操作": [
|
||||
"screenshot", "click", "click_text", "input", "swipe",
|
||||
"press_key", "ui_tree", "device_info", "status",
|
||||
],
|
||||
"应用管理": ["app_start", "app_stop", "current_app", "installed_apps"],
|
||||
"网络": ["reconnect_network"],
|
||||
"守护": ["dismiss_popups", "connection_guard"],
|
||||
},
|
||||
},
|
||||
"hook": {
|
||||
"name": "Hook 引擎",
|
||||
"icon": "🪝",
|
||||
"package": "",
|
||||
"modules": {
|
||||
"Frida 消息": ["hook_send_message", "hook_get_messages", "hook_recall_message"],
|
||||
"Frida 联系人": ["hook_get_contacts", "hook_get_contact_info", "hook_search_contact"],
|
||||
"Frida 群聊": ["hook_get_groups", "hook_get_group_members", "hook_create_group", "hook_invite_to_group"],
|
||||
"Frida 朋友圈": ["hook_get_moments", "hook_post_moment", "hook_like_moment"],
|
||||
"Frida 标签": ["hook_get_labels", "hook_add_label", "hook_remove_label"],
|
||||
"Frida 支付": ["hook_get_transfers", "hook_get_red_packets"],
|
||||
"Frida 设备": ["hook_get_device_info", "hook_get_wechat_version"],
|
||||
"Frida 数据库": ["hook_query_db", "hook_get_db_tables"],
|
||||
},
|
||||
},
|
||||
"ai_brain": {
|
||||
"name": "AI Brain",
|
||||
"icon": "🧠",
|
||||
"package": "",
|
||||
"modules": {
|
||||
"决策引擎": ["think", "heartbeat_cycle", "autonomous_loop"],
|
||||
"任务管理": ["add_task", "add_standing_order", "flush_offline_buffer"],
|
||||
"API调用": ["call_ai", "get_status"],
|
||||
},
|
||||
},
|
||||
"anti_ban": {
|
||||
"name": "防封引擎",
|
||||
"icon": "🛡️",
|
||||
"package": "",
|
||||
"modules": {
|
||||
"拟人化": ["human_delay", "human_type", "human_click", "human_swipe", "human_browse"],
|
||||
"设备守卫": ["device_guard_check", "root_hide_check", "frida_detect_check"],
|
||||
"风控哨兵": ["risk_check", "rate_limit", "content_filter"],
|
||||
"养号调度": ["nurture_plan", "nurture_execute"],
|
||||
"传感器": ["sensor_simulate", "touch_harden"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# 设备端 Agent 可执行的 script(须存在 get_skill(script))
|
||||
DEVICE_EXECUTABLE_SCRIPTS = frozenset({"wechat", "douyin", "xhs", "xianyu", "soul"})
|
||||
|
||||
|
||||
def flatten_actions(script: str) -> List[str]:
|
||||
"""返回某 script 下全部 action 列表"""
|
||||
entry = SKILL_REGISTRY.get(script, {})
|
||||
modules = entry.get("modules") or {}
|
||||
out: List[str] = []
|
||||
for actions in modules.values():
|
||||
out.extend(actions)
|
||||
return out
|
||||
|
||||
|
||||
def registry_summary() -> Dict[str, int]:
|
||||
total_skills = len(SKILL_REGISTRY)
|
||||
total_modules = sum(len(s.get("modules", {})) for s in SKILL_REGISTRY.values())
|
||||
total_actions = sum(len(a) for s in SKILL_REGISTRY.values() for a in s.get("modules", {}).values())
|
||||
platform_actions = sum(len(flatten_actions(s)) for s in DEVICE_EXECUTABLE_SCRIPTS if s in SKILL_REGISTRY)
|
||||
return {
|
||||
"total_skills": total_skills,
|
||||
"total_modules": total_modules,
|
||||
"total_actions": total_actions,
|
||||
"platform_actions": platform_actions,
|
||||
}
|
||||
498
sdk/app/services/connection_provider.py
Normal file
@@ -0,0 +1,498 @@
|
||||
"""
|
||||
设备连接方案 · 可切换驱动层(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()
|
||||
@@ -9,7 +9,7 @@ import re
|
||||
from services.ws_hub import ws_hub
|
||||
from services.device_manager import device_manager
|
||||
from services.adb_device import adb_manager
|
||||
from services.device_id_util import enrich_device_id_fields
|
||||
from services.device_id_util import enrich_device_id_fields, sanitize_sensitive_fields
|
||||
|
||||
|
||||
async def list_merged_local_devices() -> List[Dict[str, Any]]:
|
||||
@@ -109,4 +109,4 @@ async def list_merged_local_devices() -> List[Dict[str, Any]]:
|
||||
)
|
||||
)
|
||||
|
||||
return devices
|
||||
return sanitize_sensitive_fields(devices)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
|
||||
def device_id_md5(device_id: str) -> str:
|
||||
@@ -17,3 +18,20 @@ def enrich_device_id_fields(device: dict) -> dict:
|
||||
if did:
|
||||
device["device_id_md5"] = device_id_md5(str(did))
|
||||
return device
|
||||
|
||||
|
||||
def sanitize_sensitive_fields(value: Any) -> Any:
|
||||
"""递归移除设备资料中的配对密钥,并对连接 URL 去除查询参数。"""
|
||||
if isinstance(value, dict):
|
||||
result = {}
|
||||
for key, item in value.items():
|
||||
if key == "pairing_token":
|
||||
continue
|
||||
if key == "server_url" and isinstance(item, str):
|
||||
result[key] = item.split("?", 1)[0]
|
||||
else:
|
||||
result[key] = sanitize_sensitive_fields(item)
|
||||
return result
|
||||
if isinstance(value, list):
|
||||
return [sanitize_sensitive_fields(item) for item in value]
|
||||
return value
|
||||
|
||||
266
sdk/app/services/device_transport.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
设备统一传输层 — 机擎默认强制规则:WebSocket 无线主控优先。
|
||||
|
||||
所有设备控制(hook/execute、message、probe、Skill)须经本模块选路与下发。
|
||||
禁止在业务路由中绕过本层直接调用 ADB / 主机 frida-ps / Mac ADB forward Agent。
|
||||
|
||||
优先级(WORKPHONE_WS_FIRST=1,默认):
|
||||
1. WebSocket Agent + 设备本机 Frida(Hook)
|
||||
2. ADB 静默(仅运维兜底或显式 u2 动作)
|
||||
3. scrcpy / AI Agent 编排(依赖底层通道)
|
||||
|
||||
真源文档:开发文档/5、接口/02-业务对接/WebSocket无线主控口径.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TransportChannel(str, Enum):
|
||||
WEBSOCKET = "websocket"
|
||||
HOOK = "hook"
|
||||
ADB = "adb"
|
||||
OFFLINE = "offline"
|
||||
|
||||
|
||||
# 允许在无 WS 时降级 ADB 的微信动作(客服解封等,见 unified.WECHAT_U2_ONLY_ACTIONS)
|
||||
# 登录/退出/注册/切号属纯 UI 流程:Frida RPC 仅广播 intent,设备无接收端时返回
|
||||
# no_receiver_registered(真机铁律禁止假成功),必须放行 u2/ADB 真实 UI 兜底,否则功能不可用。
|
||||
WECHAT_U2_ONLY_ACTIONS = frozenset({
|
||||
"unblock_via_customer_service",
|
||||
"unblock_account",
|
||||
"unblock_self",
|
||||
"unblock_appeal",
|
||||
"unblock_with_sms",
|
||||
"set_nickname",
|
||||
"set_signature",
|
||||
"set_avatar",
|
||||
"set_sex",
|
||||
"set_region",
|
||||
"set_what_up",
|
||||
"change_password",
|
||||
"bind_phone",
|
||||
"unbind_phone",
|
||||
"get_login_devices",
|
||||
"remove_login_device",
|
||||
"enable_fingerprint",
|
||||
"set_account_protection",
|
||||
"login_by_password",
|
||||
"login_by_sms",
|
||||
"register_account",
|
||||
"auto_register",
|
||||
"logout",
|
||||
"switch_account",
|
||||
"ensure_logged_in",
|
||||
"send_red_packet",
|
||||
"receive_red_packet",
|
||||
"send_transfer",
|
||||
"receive_transfer",
|
||||
"receive_payment",
|
||||
"get_wallet_balance",
|
||||
"get_transaction_history",
|
||||
# UI/展示类素材(Frida 仅 intent 占位,须 u2 真导航+截图)
|
||||
"generate_my_qr_code",
|
||||
"show_my_qr",
|
||||
"generate_group_qr_code",
|
||||
"show_payment_code",
|
||||
"scan_qr_code",
|
||||
"add_friend_by_qr",
|
||||
"voice_call",
|
||||
"video_call",
|
||||
"share_real_time_location",
|
||||
"download_file",
|
||||
"mass_send",
|
||||
"batch_send",
|
||||
"add_favorite",
|
||||
"delete_favorite",
|
||||
"follow_official_account",
|
||||
"unfollow_official_account",
|
||||
"open_mini_program",
|
||||
"get_recent_mini_programs",
|
||||
"share_mini_program",
|
||||
"send_voice",
|
||||
"send_file",
|
||||
"send_location",
|
||||
"send_card",
|
||||
"send_emoji",
|
||||
"add_custom_emoji",
|
||||
"add_to_float",
|
||||
"remove_from_float",
|
||||
"set_privacy",
|
||||
"set_notification",
|
||||
"clear_chat_history",
|
||||
"set_chat_background",
|
||||
"set_do_not_disturb",
|
||||
"pin_chat",
|
||||
"clear_cache",
|
||||
"check_for_update",
|
||||
"delete_moments",
|
||||
"browse_channels",
|
||||
"like_channel_video",
|
||||
"comment_channel_video",
|
||||
"follow_channel",
|
||||
"unfollow_channel",
|
||||
"share_channel_video",
|
||||
# forward_message / forward_multiple / revoke_message 已实现真 Frida RPC
|
||||
# (文本转发复用 _sendMessageInternal / 撤回 DB 资格预检),走 Frida 主通道,不再强制 u2
|
||||
"get_official_account_articles",
|
||||
"reply_comment",
|
||||
})
|
||||
|
||||
|
||||
class DeviceTransport:
|
||||
"""单设备传输策略与 WS 下发封装。"""
|
||||
|
||||
@staticmethod
|
||||
def _settings():
|
||||
from config import settings
|
||||
return settings
|
||||
|
||||
def ws_first_enabled(self) -> bool:
|
||||
return bool(getattr(self._settings(), "WORKPHONE_WS_FIRST", True))
|
||||
|
||||
def ws_hook_only(self, platform: str) -> bool:
|
||||
if platform != "wechat":
|
||||
return self.ws_first_enabled()
|
||||
return bool(getattr(self._settings(), "WECHAT_WS_HOOK_ONLY", True))
|
||||
|
||||
def host_adb_probe_enabled(self) -> bool:
|
||||
return bool(getattr(self._settings(), "WORKPHONE_HOST_ADB_PROBE", False))
|
||||
|
||||
def is_ws_online(self, device_id: str) -> bool:
|
||||
from services.ws_hub import ws_hub
|
||||
return ws_hub.is_online(device_id)
|
||||
|
||||
def resolve_mode(self, device_id: str, platform: str = "wechat", action: str = "") -> str:
|
||||
"""
|
||||
解析当前设备应使用的传输模式。
|
||||
无线主控:WS 在线 → websocket;否则 offline(u2 专属动作除外)。
|
||||
"""
|
||||
if self.is_ws_online(device_id):
|
||||
return TransportChannel.WEBSOCKET.value
|
||||
|
||||
if self.ws_first_enabled():
|
||||
if platform == "wechat" and action in WECHAT_U2_ONLY_ACTIONS:
|
||||
from services.adb_device import adb_manager
|
||||
dev = adb_manager.get_device(device_id)
|
||||
if dev and dev.is_online():
|
||||
return TransportChannel.ADB.value
|
||||
return TransportChannel.OFFLINE.value
|
||||
|
||||
from services.adb_device import adb_manager
|
||||
adb_dev = adb_manager.get_device(device_id)
|
||||
if adb_dev and adb_dev.is_online():
|
||||
return TransportChannel.ADB.value
|
||||
|
||||
from services.connection_priority import connection_priority
|
||||
ch = connection_priority.choose_execution_channel(device_id)
|
||||
if ch == "agent":
|
||||
return TransportChannel.WEBSOCKET.value
|
||||
if ch and ch != "offline":
|
||||
return ch
|
||||
return TransportChannel.OFFLINE.value
|
||||
|
||||
def check_device_online(self, device_id: str, platform: str = "wechat", action: str = "") -> str:
|
||||
"""按策略检查设备是否可执行业务;不可用时抛 HTTPException。"""
|
||||
mode = self.resolve_mode(device_id, platform, action)
|
||||
if mode == TransportChannel.OFFLINE.value:
|
||||
detail = (
|
||||
f"设备 WebSocket 未连接: {device_id}。"
|
||||
"无线主控请在手机 Termux 启动 Agent 连接 ws://<sdk>/ws/device/{device_id}"
|
||||
)
|
||||
if self.ws_first_enabled():
|
||||
raise HTTPException(status_code=503, detail=detail)
|
||||
raise HTTPException(status_code=503, detail=f"设备不在线: {device_id}")
|
||||
return "agent" if mode == TransportChannel.WEBSOCKET.value else mode
|
||||
|
||||
def offline_payload(self, device_id: str, reason: str = "") -> Dict[str, Any]:
|
||||
msg = reason or (
|
||||
"WebSocket Agent 未在线,请启动设备端 Termux Agent "
|
||||
f"连接 ws://<sdk>/ws/device/{device_id}"
|
||||
)
|
||||
return {
|
||||
"code": 503,
|
||||
"success": False,
|
||||
"message": msg,
|
||||
"transport": TransportChannel.WEBSOCKET.value,
|
||||
"_channel_used": "websocket/offline",
|
||||
}
|
||||
|
||||
def policy_meta(self, device_id: str) -> Dict[str, Any]:
|
||||
from services.device_id_util import device_id_md5
|
||||
from services.ws_hub import ws_hub
|
||||
|
||||
info = ws_hub.get_device_info(device_id) or {}
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"device_id_md5": device_id_md5(device_id),
|
||||
"transport": TransportChannel.WEBSOCKET.value if self.is_ws_online(device_id) else TransportChannel.OFFLINE.value,
|
||||
"ws_first": self.ws_first_enabled(),
|
||||
"ws_online": self.is_ws_online(device_id),
|
||||
"frida_available": bool(info.get("frida_available")),
|
||||
"agent_on_device": bool(info.get("on_device") or info.get("WP_AGENT_ON_DEVICE")),
|
||||
}
|
||||
|
||||
async def execute_via_ws(
|
||||
self,
|
||||
device_id: str,
|
||||
platform: str,
|
||||
action: str,
|
||||
params: dict,
|
||||
*,
|
||||
timeout: int = 120,
|
||||
hook_only: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""经 WebSocket 统一下发 execute(唯一推荐业务入口)。"""
|
||||
from services.ws_hub import ws_hub
|
||||
|
||||
# 微信内部动作优先走宝塔侧真实 Frida Session;Android/system/device
|
||||
# 仍走手机 WebSocket,避免同一设备 ID 的两个 Agent 抢占连接。
|
||||
if platform == "wechat":
|
||||
from services.server_frida_bridge import server_frida_bridge
|
||||
if server_frida_bridge.enabled_for(device_id):
|
||||
return await server_frida_bridge.execute(device_id, action, params)
|
||||
|
||||
if not self.is_ws_online(device_id):
|
||||
return self.offline_payload(device_id)
|
||||
|
||||
result = await ws_hub.send_command(
|
||||
device_id,
|
||||
{
|
||||
"type": "execute",
|
||||
"data": {
|
||||
"script": platform,
|
||||
"action": action,
|
||||
"params": params,
|
||||
"hook_only": hook_only,
|
||||
},
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
payload = result.get("data") if isinstance(result.get("data"), dict) else {}
|
||||
agent_channel = (
|
||||
result.get("channel")
|
||||
or payload.get("channel")
|
||||
or ("hook" if hook_only and payload.get("success") else "u2")
|
||||
)
|
||||
result["_channel_used"] = f"websocket/{agent_channel}"
|
||||
result.setdefault("transport", TransportChannel.WEBSOCKET.value)
|
||||
return result
|
||||
|
||||
def should_force_hook_only(self, platform: str, action: str, hook_only: bool) -> bool:
|
||||
if hook_only:
|
||||
return True
|
||||
if platform == "wechat" and getattr(self._settings(), "WECHAT_BACKEND_ONLY", True):
|
||||
return action not in WECHAT_U2_ONLY_ACTIONS
|
||||
return False
|
||||
|
||||
|
||||
device_transport = DeviceTransport()
|
||||
417
sdk/app/services/integration_manifest.py
Normal file
@@ -0,0 +1,417 @@
|
||||
"""
|
||||
对外接口统一清单(Integration Manifest)
|
||||
|
||||
目标:把工作手机 SDK 所有 /api/v3/* 对外接口,按「模块 + 消费方」自动归类,
|
||||
生成一份机器可读、始终与代码同步的接口总清单,供:
|
||||
- 存客宝(cunkebao) 直接对接业务/线索/Hook 事件接口
|
||||
- 超级管理端(superadmin) 切换连接方案 / 配置开关 / 全局管控
|
||||
- AI 数字员工(ai_employee) 通过 OpenAI / MCP / Agent 网关直接调用控机能力
|
||||
- 通用(common) 设备、健康、发现等基础接口
|
||||
|
||||
设计原则(模块化 + 接口化 + 清晰化):
|
||||
- 清单从 FastAPI app.routes 动态扫描生成 → 不手工维护、不会与代码漂移
|
||||
- 模块归类按「路由前缀规则表 MODULE_RULES」判定
|
||||
- 消费方归类按「模块 → 消费方映射 MODULE_CONSUMERS」判定
|
||||
- 只读、零副作用;不依赖真机即可返回(设备相关字段会标注 online 与否)
|
||||
|
||||
严禁修改存客宝 / AI 数字员工 / 超级管理端代码——本模块仅暴露工作手机侧出口。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 一、消费方定义
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
CONSUMER_CUNKEBAO = "cunkebao"
|
||||
CONSUMER_SUPERADMIN = "superadmin"
|
||||
CONSUMER_AI_EMPLOYEE = "ai_employee"
|
||||
CONSUMER_COMMON = "common"
|
||||
|
||||
CONSUMER_LABELS = {
|
||||
CONSUMER_CUNKEBAO: "存客宝(直连业务/线索/Hook 事件)",
|
||||
CONSUMER_SUPERADMIN: "超级管理端(连接方案切换/配置开关/全局管控)",
|
||||
CONSUMER_AI_EMPLOYEE: "AI 数字员工(OpenAI/MCP/Agent 网关调用控机)",
|
||||
CONSUMER_COMMON: "通用(设备/健康/发现/经验等基础能力)",
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 二、模块规则表:路径前缀(去掉 /api/v3)→ 模块
|
||||
# 顺序敏感:先匹配更具体的前缀
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
# (path_prefix, module_id, module_label)
|
||||
MODULE_RULES: List[Tuple[str, str, str]] = [
|
||||
("/cunke-bao", "cunkebao_link", "存客宝对接"),
|
||||
("/customer", "cunkebao_link", "存客宝对接"),
|
||||
("/connection/provider", "connection_switch", "连接方案可切换驱动"),
|
||||
("/connection", "connection", "连接协议与诊断"),
|
||||
("/stability", "connection", "连接协议与诊断"),
|
||||
("/gateway/v1", "gateway_openai", "AI 网关 · OpenAI 兼容"),
|
||||
("/v1/chat", "gateway_openai", "AI 网关 · OpenAI 兼容"),
|
||||
("/gateway/mcp", "gateway_mcp", "AI 网关 · MCP 协议"),
|
||||
("/gateway", "gateway", "AI 网关 · REST 聚合/编队"),
|
||||
("/ai/brain", "ai_brain", "AI Brain 技能注册与调度"),
|
||||
("/ai", "ai_agent", "AI Agent 控机"),
|
||||
("/agent", "ai_agent", "AI Agent 控机"),
|
||||
("/wechat", "wechat_full", "微信全量操作"),
|
||||
("/message", "wechat_message", "微信消息"),
|
||||
("/friend", "wechat_friend", "微信好友"),
|
||||
("/contacts", "wechat_contacts", "微信通讯录"),
|
||||
("/group", "wechat_group", "微信群"),
|
||||
("/moments", "wechat_moments", "微信朋友圈"),
|
||||
("/tag", "wechat_tag", "微信标签"),
|
||||
("/account", "wechat_account", "微信账号/登录/解封"),
|
||||
("/auto-register", "wechat_account", "微信账号/登录/解封"),
|
||||
("/payment", "wechat_payment", "微信支付/转账"),
|
||||
("/mass-send", "wechat_message", "微信消息"),
|
||||
# 微信扩展操作(资料/扫码/收藏/表情/文件/位置/通话/小程序/公众号/看一看/评论)
|
||||
("/profile", "wechat_extra", "微信扩展操作"),
|
||||
("/scan", "wechat_extra", "微信扩展操作"),
|
||||
("/favorites", "wechat_extra", "微信扩展操作"),
|
||||
("/emoji", "wechat_extra", "微信扩展操作"),
|
||||
("/file", "wechat_extra", "微信扩展操作"),
|
||||
("/location", "wechat_extra", "微信扩展操作"),
|
||||
("/call", "wechat_extra", "微信扩展操作"),
|
||||
("/chat", "wechat_extra", "微信扩展操作"),
|
||||
("/miniprogram", "wechat_extra", "微信扩展操作"),
|
||||
("/official-account", "wechat_extra", "微信扩展操作"),
|
||||
("/discover", "wechat_extra", "微信扩展操作"),
|
||||
("/comment", "wechat_extra", "微信扩展操作"),
|
||||
("/search", "wechat_extra", "微信扩展操作"),
|
||||
("/settings", "wechat_extra", "微信扩展操作"),
|
||||
("/video-channel", "wechat_extra", "微信扩展操作"),
|
||||
("/wechat-sport", "wechat_extra", "微信扩展操作"),
|
||||
("/hook-modules", "hook_modules", "Hook 模块管理"),
|
||||
("/modules", "hook_modules", "Hook 模块管理"),
|
||||
("/scripts", "ai_brain", "AI Brain 技能注册与调度"),
|
||||
("/hook", "hook", "Hook 探测/执行/动作"),
|
||||
("/anti-ban", "anti_ban", "防风控"),
|
||||
("/antiban", "anti_ban", "防风控"),
|
||||
("/fleet", "fleet", "多设备 Fleet 管理"),
|
||||
("/devices", "devices", "设备管理与控制"),
|
||||
("/device", "devices", "设备管理与控制"),
|
||||
("/guard-events", "devices", "设备管理与控制"),
|
||||
("/heartbeat", "devices", "设备管理与控制"),
|
||||
("/workbench", "console", "控制台/工作台聚合"),
|
||||
("/process", "console", "控制台/工作台聚合"),
|
||||
("/frida", "frida", "Frida 无线管理"),
|
||||
("/adb", "adb", "ADB 直连(运维兜底,非主控)"),
|
||||
("/registry", "registry", "多服务器注册中心"),
|
||||
("/discovery", "discovery", "设备发现"),
|
||||
("/qrcode", "qrcode", "二维码"),
|
||||
("/voice", "voice", "语音控制"),
|
||||
("/capture", "capture", "抓包"),
|
||||
("/experience", "experience", "经验库"),
|
||||
("/projects", "projects", "项目管理"),
|
||||
("/integration", "integration", "对外接口统一清单"),
|
||||
]
|
||||
|
||||
# 模块 → 主消费方(一个模块可服务多个消费方)
|
||||
MODULE_CONSUMERS: Dict[str, List[str]] = {
|
||||
"cunkebao_link": [CONSUMER_CUNKEBAO, CONSUMER_SUPERADMIN],
|
||||
"connection_switch": [CONSUMER_SUPERADMIN, CONSUMER_CUNKEBAO],
|
||||
"connection": [CONSUMER_SUPERADMIN, CONSUMER_COMMON],
|
||||
"gateway_openai": [CONSUMER_AI_EMPLOYEE],
|
||||
"gateway_mcp": [CONSUMER_AI_EMPLOYEE],
|
||||
"gateway": [CONSUMER_AI_EMPLOYEE, CONSUMER_SUPERADMIN],
|
||||
"ai_brain": [CONSUMER_AI_EMPLOYEE, CONSUMER_SUPERADMIN],
|
||||
"ai_agent": [CONSUMER_AI_EMPLOYEE, CONSUMER_CUNKEBAO],
|
||||
"wechat_full": [CONSUMER_CUNKEBAO, CONSUMER_AI_EMPLOYEE],
|
||||
"wechat_message": [CONSUMER_CUNKEBAO, CONSUMER_AI_EMPLOYEE],
|
||||
"wechat_friend": [CONSUMER_CUNKEBAO, CONSUMER_AI_EMPLOYEE],
|
||||
"wechat_contacts": [CONSUMER_CUNKEBAO, CONSUMER_AI_EMPLOYEE],
|
||||
"wechat_group": [CONSUMER_CUNKEBAO, CONSUMER_AI_EMPLOYEE],
|
||||
"wechat_moments": [CONSUMER_CUNKEBAO, CONSUMER_AI_EMPLOYEE],
|
||||
"wechat_tag": [CONSUMER_CUNKEBAO, CONSUMER_AI_EMPLOYEE],
|
||||
"wechat_account": [CONSUMER_CUNKEBAO, CONSUMER_SUPERADMIN],
|
||||
"wechat_extra": [CONSUMER_CUNKEBAO, CONSUMER_AI_EMPLOYEE],
|
||||
"wechat_payment": [CONSUMER_CUNKEBAO, CONSUMER_AI_EMPLOYEE],
|
||||
"hook": [CONSUMER_CUNKEBAO, CONSUMER_AI_EMPLOYEE, CONSUMER_SUPERADMIN],
|
||||
"anti_ban": [CONSUMER_SUPERADMIN, CONSUMER_CUNKEBAO],
|
||||
"fleet": [CONSUMER_CUNKEBAO, CONSUMER_SUPERADMIN, CONSUMER_AI_EMPLOYEE, CONSUMER_COMMON],
|
||||
"devices": [CONSUMER_COMMON, CONSUMER_CUNKEBAO, CONSUMER_AI_EMPLOYEE],
|
||||
"frida": [CONSUMER_SUPERADMIN, CONSUMER_COMMON],
|
||||
"adb": [CONSUMER_SUPERADMIN, CONSUMER_COMMON],
|
||||
"hook_modules": [CONSUMER_SUPERADMIN],
|
||||
"registry": [CONSUMER_SUPERADMIN, CONSUMER_COMMON],
|
||||
"discovery": [CONSUMER_COMMON, CONSUMER_SUPERADMIN],
|
||||
"qrcode": [CONSUMER_COMMON],
|
||||
"voice": [CONSUMER_AI_EMPLOYEE, CONSUMER_COMMON],
|
||||
"capture": [CONSUMER_SUPERADMIN],
|
||||
"experience": [CONSUMER_COMMON],
|
||||
"projects": [CONSUMER_SUPERADMIN, CONSUMER_COMMON],
|
||||
"console": [CONSUMER_SUPERADMIN, CONSUMER_CUNKEBAO, CONSUMER_COMMON],
|
||||
"integration": [CONSUMER_SUPERADMIN, CONSUMER_CUNKEBAO, CONSUMER_AI_EMPLOYEE, CONSUMER_COMMON],
|
||||
}
|
||||
|
||||
MODULE_UNKNOWN = ("other", "其它/内部")
|
||||
|
||||
# 模块运行时依赖:
|
||||
# server → SDK 进程在即可用(不依赖具体设备)
|
||||
# device → 需目标设备 WebSocket 在线(u2/ADB 即可,微信类有 u2 兜底)
|
||||
# hook → 需设备 Frida Hook 已 attach 才能发挥完整能力(否则 u2 部分降级)
|
||||
MODULE_RUNTIME: Dict[str, str] = {
|
||||
"cunkebao_link": "server",
|
||||
"connection_switch": "device",
|
||||
"connection": "device",
|
||||
"gateway_openai": "device",
|
||||
"gateway_mcp": "device",
|
||||
"gateway": "device",
|
||||
"ai_brain": "device",
|
||||
"ai_agent": "device",
|
||||
"wechat_full": "hook",
|
||||
"wechat_message": "hook",
|
||||
"wechat_friend": "hook",
|
||||
"wechat_contacts": "hook",
|
||||
"wechat_group": "hook",
|
||||
"wechat_moments": "hook",
|
||||
"wechat_tag": "hook",
|
||||
"wechat_account": "device",
|
||||
"wechat_extra": "hook",
|
||||
"wechat_payment": "hook",
|
||||
"hook": "hook",
|
||||
"anti_ban": "server",
|
||||
"fleet": "device",
|
||||
"devices": "device",
|
||||
"frida": "device",
|
||||
"adb": "device",
|
||||
"hook_modules": "server",
|
||||
"registry": "server",
|
||||
"discovery": "server",
|
||||
"qrcode": "server",
|
||||
"voice": "device",
|
||||
"capture": "device",
|
||||
"experience": "server",
|
||||
"projects": "device",
|
||||
"console": "server",
|
||||
"integration": "server",
|
||||
"other": "server",
|
||||
}
|
||||
|
||||
# 仅纳入对外清单的方法
|
||||
_PUBLIC_METHODS = {"GET", "POST", "PUT", "DELETE", "PATCH"}
|
||||
|
||||
# 这些路径片段视为内部/非业务,不纳入对外清单
|
||||
_SKIP_PREFIXES = ("/openapi", "/docs", "/redoc", "/static")
|
||||
|
||||
|
||||
def _classify_path(path: str) -> Tuple[str, str]:
|
||||
"""返回 (module_id, module_label)。path 形如 /api/v3/cunke-bao/config。"""
|
||||
rel = path
|
||||
if rel.startswith("/api/v3"):
|
||||
rel = rel[len("/api/v3"):]
|
||||
if not rel.startswith("/"):
|
||||
rel = "/" + rel
|
||||
for prefix, mod_id, label in MODULE_RULES:
|
||||
if rel == prefix or rel.startswith(prefix + "/") or rel.startswith(prefix):
|
||||
# 更严格:前缀后须是 / 或结束,避免 /tag 误匹配 /tagx
|
||||
tail = rel[len(prefix):]
|
||||
if tail == "" or tail.startswith("/"):
|
||||
return mod_id, label
|
||||
return MODULE_UNKNOWN
|
||||
|
||||
|
||||
def _consumers_for(module_id: str) -> List[str]:
|
||||
return MODULE_CONSUMERS.get(module_id, [CONSUMER_COMMON])
|
||||
|
||||
|
||||
def build_manifest(app) -> Dict[str, Any]:
|
||||
"""
|
||||
扫描 FastAPI app 路由,生成按模块归类的对外接口清单。
|
||||
|
||||
返回:
|
||||
{
|
||||
"version": "...",
|
||||
"base_path": "/api/v3",
|
||||
"total_endpoints": N,
|
||||
"consumers": {...标签...},
|
||||
"modules": [
|
||||
{"module": "cunkebao_link", "label": "存客宝对接",
|
||||
"consumers": ["cunkebao","superadmin"],
|
||||
"endpoints": [{"method","path","summary"} ...]}
|
||||
]
|
||||
}
|
||||
"""
|
||||
modules: Dict[str, Dict[str, Any]] = {}
|
||||
total = 0
|
||||
seen: set[tuple[str, str]] = set()
|
||||
|
||||
def add_endpoint(path: str, method: str, summary: str = "") -> None:
|
||||
nonlocal total
|
||||
method = method.upper()
|
||||
if method not in _PUBLIC_METHODS:
|
||||
return
|
||||
if not path or any(path.startswith(p) for p in _SKIP_PREFIXES):
|
||||
return
|
||||
key = (path, method)
|
||||
if key in seen:
|
||||
return
|
||||
if path.startswith("/api/v3"):
|
||||
mod_id, label = _classify_path(path)
|
||||
elif path in ("/health", "/ready", "/"):
|
||||
mod_id, label = ("devices", "设备管理与控制") if path == "/health" else ("integration", "对外接口统一清单")
|
||||
else:
|
||||
mod_id, label = _classify_path(path)
|
||||
if mod_id == MODULE_UNKNOWN[0]:
|
||||
return
|
||||
bucket = modules.setdefault(mod_id, {
|
||||
"module": mod_id,
|
||||
"label": label,
|
||||
"consumers": _consumers_for(mod_id),
|
||||
"endpoints": [],
|
||||
})
|
||||
bucket["endpoints"].append({
|
||||
"method": method,
|
||||
"path": path,
|
||||
"summary": summary,
|
||||
})
|
||||
seen.add(key)
|
||||
total += 1
|
||||
|
||||
for route in getattr(app, "routes", []):
|
||||
path = getattr(route, "path", "") or ""
|
||||
methods = getattr(route, "methods", None) or set()
|
||||
if not path or any(path.startswith(p) for p in _SKIP_PREFIXES):
|
||||
continue
|
||||
# 只纳入 /api/v3/* 与少量根级业务端点(/health 等归 common)
|
||||
public_methods = [m for m in methods if m in _PUBLIC_METHODS]
|
||||
if not public_methods:
|
||||
continue
|
||||
|
||||
summary = getattr(route, "summary", "") or ""
|
||||
if not summary:
|
||||
endpoint = getattr(route, "endpoint", None)
|
||||
doc = (endpoint.__doc__ or "").strip() if endpoint else ""
|
||||
summary = doc.splitlines()[0].strip() if doc else ""
|
||||
|
||||
for m in sorted(public_methods):
|
||||
add_endpoint(path, m, summary)
|
||||
|
||||
# 某些运行态下 request.app.routes 可能只暴露局部路由,但 OpenAPI 已完整。
|
||||
# 用 OpenAPI paths 做只读补齐,保证接口开放清单与 /docs 可见能力一致。
|
||||
try:
|
||||
openapi = app.openapi() if hasattr(app, "openapi") else {}
|
||||
paths = openapi.get("paths", {}) if isinstance(openapi, dict) else {}
|
||||
for path, path_item in paths.items():
|
||||
if not isinstance(path_item, dict):
|
||||
continue
|
||||
for method, meta in path_item.items():
|
||||
if str(method).upper() not in _PUBLIC_METHODS:
|
||||
continue
|
||||
summary = ""
|
||||
if isinstance(meta, dict):
|
||||
summary = str(meta.get("summary") or meta.get("description") or "")
|
||||
add_endpoint(path, str(method), summary)
|
||||
except Exception:
|
||||
# manifest 是发现接口,不应因 OpenAPI 补齐失败影响主服务。
|
||||
pass
|
||||
|
||||
# 排序:模块按 label,端点按 path
|
||||
module_list = []
|
||||
for mod in modules.values():
|
||||
mod["endpoints"].sort(key=lambda e: (e["path"], e["method"]))
|
||||
mod["endpoint_count"] = len(mod["endpoints"])
|
||||
module_list.append(mod)
|
||||
module_list.sort(key=lambda m: m["label"])
|
||||
|
||||
return {
|
||||
"version": "1.0.0",
|
||||
"base_path": "/api/v3",
|
||||
"generated_from": "fastapi.routes (dynamic, always in-sync)",
|
||||
"total_endpoints": total,
|
||||
"module_count": len(module_list),
|
||||
"consumers": CONSUMER_LABELS,
|
||||
"modules": module_list,
|
||||
"rules": {
|
||||
"note": "工作手机侧出口;严禁改存客宝/AI数字员工/超管代码,仅按本清单对接。",
|
||||
"transport": "业务下发统一经 WebSocket 主控(WORKPHONE_WS_FIRST=1)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def filter_by_consumer(manifest: Dict[str, Any], consumer: str) -> Dict[str, Any]:
|
||||
"""从完整清单中筛出某消费方可用的模块与端点。"""
|
||||
mods = [m for m in manifest.get("modules", []) if consumer in m.get("consumers", [])]
|
||||
total = sum(m.get("endpoint_count", 0) for m in mods)
|
||||
return {
|
||||
"version": manifest.get("version"),
|
||||
"base_path": manifest.get("base_path"),
|
||||
"consumer": consumer,
|
||||
"consumer_label": CONSUMER_LABELS.get(consumer, consumer),
|
||||
"total_endpoints": total,
|
||||
"module_count": len(mods),
|
||||
"modules": mods,
|
||||
}
|
||||
|
||||
|
||||
def list_modules(manifest: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""精简模块目录(不含端点明细)。"""
|
||||
return [
|
||||
{
|
||||
"module": m["module"],
|
||||
"label": m["label"],
|
||||
"consumers": m["consumers"],
|
||||
"endpoint_count": m["endpoint_count"],
|
||||
"runtime": MODULE_RUNTIME.get(m["module"], "server"),
|
||||
}
|
||||
for m in manifest.get("modules", [])
|
||||
]
|
||||
|
||||
|
||||
def build_capability(
|
||||
manifest: Dict[str, Any],
|
||||
device_id: str,
|
||||
online: bool,
|
||||
supports_hook: bool,
|
||||
consumer: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
把「接口清单(静态)」与「设备实时状态」打通,给出某设备各模块此刻是否可直接调用。
|
||||
|
||||
status 取值:
|
||||
ready 可直接调用
|
||||
degraded 设备在线但 Frida 未 attach,微信类走 u2 部分降级
|
||||
offline 设备不在线,device/hook 类不可用
|
||||
"""
|
||||
modules = manifest.get("modules", [])
|
||||
if consumer:
|
||||
modules = [m for m in modules if consumer in m.get("consumers", [])]
|
||||
|
||||
result_modules = []
|
||||
counts = {"ready": 0, "degraded": 0, "offline": 0}
|
||||
for m in modules:
|
||||
runtime = MODULE_RUNTIME.get(m["module"], "server")
|
||||
if runtime == "server":
|
||||
status = "ready"
|
||||
elif not online:
|
||||
status = "offline"
|
||||
elif runtime == "hook":
|
||||
status = "ready" if supports_hook else "degraded"
|
||||
else: # device
|
||||
status = "ready"
|
||||
counts[status] += 1
|
||||
result_modules.append({
|
||||
"module": m["module"],
|
||||
"label": m["label"],
|
||||
"runtime": runtime,
|
||||
"status": status,
|
||||
"endpoint_count": m["endpoint_count"],
|
||||
"consumers": m["consumers"],
|
||||
})
|
||||
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"consumer": consumer or "all",
|
||||
"online": online,
|
||||
"supports_hook": supports_hook,
|
||||
"summary": counts,
|
||||
"modules": result_modules,
|
||||
"legend": {
|
||||
"ready": "可直接调用",
|
||||
"degraded": "设备在线但 Frida 未 attach,微信类走 u2 部分降级",
|
||||
"offline": "设备不在线,device/hook 类不可用",
|
||||
},
|
||||
}
|
||||
130
sdk/app/services/karuo_device_ai.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
服务端编排:卡若 AI 网关决策 → WebSocket 下发到手机 Agent 执行。
|
||||
手机无需本地 ai_api_key,统一走平常使用的 /api/gateway/chat。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
import importlib.util
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_brain: Optional[Any] = None
|
||||
|
||||
|
||||
def _load_ai_brain_class():
|
||||
path = Path(__file__).resolve().parent.parent / "agent" / "ai_brain.py"
|
||||
spec = importlib.util.spec_from_file_location("workphone_device_ai_brain", path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"无法加载 AIBrain: {path}")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod.AIBrain
|
||||
|
||||
|
||||
from services.ws_hub import ws_hub
|
||||
|
||||
_AGENT_CFG_CANDIDATES = [
|
||||
Path(__file__).resolve().parent.parent / "agent" / "config.json",
|
||||
Path(__file__).resolve().parents[2] / "agent" / "config.json",
|
||||
]
|
||||
|
||||
|
||||
def _load_ai_cfg() -> Dict[str, Any]:
|
||||
for path in _AGENT_CFG_CANDIDATES:
|
||||
try:
|
||||
if path.is_file():
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data.get("ai_brain") or {}
|
||||
except Exception as exc:
|
||||
logger.warning("读取 %s 失败: %s", path, exc)
|
||||
return {}
|
||||
|
||||
|
||||
def get_karuo_brain(force_reload: bool = False) -> Optional[Any]:
|
||||
global _brain
|
||||
if _brain is not None and not force_reload:
|
||||
return _brain
|
||||
_brain = None
|
||||
cfg = _load_ai_cfg()
|
||||
api_key = (
|
||||
os.environ.get("KARUO_API_KEY")
|
||||
or os.environ.get("WP_AI_API_KEY")
|
||||
or os.environ.get("AI_BRAIN_API_KEY")
|
||||
or cfg.get("api_key")
|
||||
or ""
|
||||
).strip()
|
||||
api_url = (
|
||||
os.environ.get("KARUO_API_URL")
|
||||
or os.environ.get("WP_AI_API_URL")
|
||||
or os.environ.get("AI_BRAIN_API_URL")
|
||||
or cfg.get("api_url")
|
||||
or "http://127.0.0.1:3102"
|
||||
).strip()
|
||||
# 本机直跑 SDK(非 Docker 容器内)时 host.docker.internal 不可达
|
||||
if "host.docker.internal" in api_url and not os.path.exists("/.dockerenv"):
|
||||
api_url = api_url.replace("host.docker.internal", "127.0.0.1")
|
||||
if not api_key:
|
||||
logger.warning("卡若 AI Brain 未配置 api_key(sdk/agent/config.json)")
|
||||
return None
|
||||
_brain = _load_ai_brain_class()(
|
||||
ai_api_url=api_url,
|
||||
ai_api_key=api_key,
|
||||
ai_model=cfg.get("model") or "auto",
|
||||
brain_interval=int(cfg.get("brain_interval") or 60),
|
||||
standing_orders=cfg.get("standing_orders") or [],
|
||||
enabled=True,
|
||||
)
|
||||
return _brain
|
||||
|
||||
|
||||
def device_status(device_id: str) -> Dict[str, Any]:
|
||||
info = ws_hub.get_device_info(device_id) or {}
|
||||
qs = info.get("quick_status") or info.get("last_status") or {}
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"online": ws_hub.is_online(device_id),
|
||||
"model": info.get("model"),
|
||||
"capabilities": info.get("capabilities") or [],
|
||||
"u2": qs.get("u2", False),
|
||||
"frida": info.get("frida_available", False),
|
||||
"agent_version": info.get("agent_version"),
|
||||
}
|
||||
|
||||
|
||||
async def chat_and_execute_on_device(
|
||||
device_id: str,
|
||||
instruction: str,
|
||||
timeout: int = 120,
|
||||
) -> Dict[str, Any]:
|
||||
brain = get_karuo_brain()
|
||||
if not brain:
|
||||
return {"code": 503, "message": "卡若 AI 未配置(agent/config.json ai_brain.api_key)"}
|
||||
if not ws_hub.is_online(device_id):
|
||||
return {"code": 503, "message": "设备不在线"}
|
||||
|
||||
async def execute_fn(script: str, action: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return await ws_hub.send_command(
|
||||
device_id,
|
||||
{
|
||||
"type": "execute",
|
||||
"data": {
|
||||
"script": script,
|
||||
"action": action,
|
||||
"params": params or {},
|
||||
"channel": "auto",
|
||||
},
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
result = await brain.chat_and_execute(instruction.strip(), device_status(device_id), execute_fn)
|
||||
result["device_id"] = device_id
|
||||
result["orchestrator"] = "server_karuo_gateway"
|
||||
return result
|
||||
77
sdk/app/services/process_state.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
SDK 进程状态 — 正常态快照(供 connection_keeper / 负载均衡 / 控制台轮询)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
_PROCESS_STARTED_AT: float = time.time()
|
||||
|
||||
|
||||
def mark_process_started() -> None:
|
||||
"""lifespan 启动时调用,重置进程计时起点。"""
|
||||
global _PROCESS_STARTED_AT
|
||||
_PROCESS_STARTED_AT = time.time()
|
||||
|
||||
|
||||
def _keeper_pid_file() -> Path:
|
||||
sdk_app_root = Path(__file__).resolve().parent.parent
|
||||
host_path = sdk_app_root.parent / "logs" / "connection_keeper.pid"
|
||||
container_path = sdk_app_root / "logs" / "connection_keeper.pid"
|
||||
for p in (host_path, container_path):
|
||||
if p.is_file():
|
||||
return p
|
||||
# 宿主机 sdk/app 结构 vs Docker /app
|
||||
if (sdk_app_root.parent / "scripts").is_dir():
|
||||
return host_path
|
||||
return container_path
|
||||
|
||||
|
||||
def _read_keeper_state() -> Dict[str, Any]:
|
||||
pid_file = _keeper_pid_file()
|
||||
if not pid_file.is_file():
|
||||
return {"running": False, "pid": None, "pid_file": str(pid_file)}
|
||||
try:
|
||||
pid = int(pid_file.read_text(encoding="utf-8").strip())
|
||||
except (ValueError, OSError):
|
||||
return {"running": False, "pid": None, "pid_file": str(pid_file)}
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return {"running": True, "pid": pid, "pid_file": str(pid_file)}
|
||||
except OSError:
|
||||
return {"running": False, "pid": pid, "pid_file": str(pid_file), "stale": True}
|
||||
|
||||
|
||||
def get_process_status() -> Dict[str, Any]:
|
||||
"""
|
||||
返回 SDK 进程「正常态」快照。
|
||||
state=normal 表示主进程存活且核心子系统可响应(与 /ready 语义对齐)。
|
||||
"""
|
||||
from services.ws_hub import ws_hub
|
||||
|
||||
uptime = max(0, int(time.time() - _PROCESS_STARTED_AT))
|
||||
online_ids = list(ws_hub.connections.keys())
|
||||
keeper = _read_keeper_state()
|
||||
|
||||
return {
|
||||
"state": "normal",
|
||||
"pid": os.getpid(),
|
||||
"version": "3.0.0",
|
||||
"started_at": datetime.fromtimestamp(_PROCESS_STARTED_AT, tz=timezone.utc).isoformat(),
|
||||
"uptime_seconds": uptime,
|
||||
"ready": True,
|
||||
"ws_online_count": len(online_ids),
|
||||
"online_device_ids": online_ids,
|
||||
"connection_keeper": keeper,
|
||||
"poll_urls": {
|
||||
"process_status": "/api/v3/process/status",
|
||||
"ready": "/ready",
|
||||
"health": "/health",
|
||||
"connection_status": "/api/v3/connection/status",
|
||||
},
|
||||
}
|
||||
96
sdk/app/services/server_frida_bridge.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""宝塔侧 Frida RPC 桥。
|
||||
|
||||
手机通过认证的反向隧道暴露 frida-server,SDK 容器在本模块中持有真正的
|
||||
Frida Session 和 JS RPC。Android 系统控制仍走手机 WebSocket,两条通道互不抢占。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any, Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ServerFridaBridge:
|
||||
def __init__(self) -> None:
|
||||
self._manager = None
|
||||
self._executor = None
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@staticmethod
|
||||
def _settings():
|
||||
from config import settings
|
||||
return settings
|
||||
|
||||
def enabled_for(self, device_id: str) -> bool:
|
||||
settings = self._settings()
|
||||
if not bool(getattr(settings, "SERVER_FRIDA_ENABLED", False)):
|
||||
return False
|
||||
expected = str(getattr(settings, "SERVER_FRIDA_DEVICE_ID", "") or "").strip()
|
||||
return not expected or expected == device_id
|
||||
|
||||
def _connect(self) -> bool:
|
||||
with self._lock:
|
||||
if self._manager and getattr(self._manager, "connected", False):
|
||||
return True
|
||||
|
||||
from hook.frida_manager import FridaManager
|
||||
from hook.hook_executor import HookExecutor
|
||||
|
||||
settings = self._settings()
|
||||
manager = FridaManager(
|
||||
mode="remote",
|
||||
gadget_host=str(getattr(settings, "SERVER_FRIDA_HOST", "172.18.0.1")),
|
||||
gadget_port=int(getattr(settings, "SERVER_FRIDA_PORT", 14538)),
|
||||
use_adb_forward=False,
|
||||
auto_reconnect=True,
|
||||
)
|
||||
if not manager.start():
|
||||
logger.warning("宝塔 Frida 桥连接失败")
|
||||
return False
|
||||
self._manager = manager
|
||||
self._executor = HookExecutor(manager)
|
||||
logger.info("宝塔 Frida 桥已附着微信并加载 RPC")
|
||||
return True
|
||||
|
||||
def execute_sync(self, action: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
try:
|
||||
if not self._connect():
|
||||
return {
|
||||
"success": False,
|
||||
"code": 503,
|
||||
"error": "宝塔 Frida 桥不可用,请检查手机反向隧道和微信进程",
|
||||
"_channel_used": "server/frida/offline",
|
||||
}
|
||||
result = self._executor.execute(action, params)
|
||||
result.setdefault("code", 200 if result.get("success") else 500)
|
||||
result["_channel_used"] = "server/frida"
|
||||
result["transport"] = "frida_reverse_tunnel"
|
||||
return result
|
||||
except Exception as exc:
|
||||
logger.exception("宝塔 Frida RPC 执行异常")
|
||||
return {
|
||||
"success": False,
|
||||
"code": 500,
|
||||
"error": str(exc),
|
||||
"_channel_used": "server/frida/error",
|
||||
}
|
||||
|
||||
async def execute(self, device_id: str, action: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not self.enabled_for(device_id):
|
||||
return {"success": False, "code": 503, "error": "宝塔 Frida 桥未启用"}
|
||||
return await asyncio.to_thread(self.execute_sync, action, params)
|
||||
|
||||
def status(self) -> Dict[str, Any]:
|
||||
connected = bool(self._manager and getattr(self._manager, "connected", False))
|
||||
return {
|
||||
"enabled": bool(getattr(self._settings(), "SERVER_FRIDA_ENABLED", False)),
|
||||
"connected": connected,
|
||||
"manager": self._manager.get_status() if connected else None,
|
||||
}
|
||||
|
||||
|
||||
server_frida_bridge = ServerFridaBridge()
|
||||
72
sdk/app/services/wechat_contact_normalizer.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
存客宝通讯录字段归一化 — 禁止空 nickname/display_name 占位
|
||||
|
||||
Hook/SQL 常返回 nickname 空、remark 有值;存客宝 MySQL diff 需要稳定展示字段。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
def normalize_wechat_contact(raw: Dict[str, Any]) -> Dict[str, Any]:
|
||||
wxid = (
|
||||
raw.get("wxid")
|
||||
or raw.get("username")
|
||||
or raw.get("user_id")
|
||||
or ""
|
||||
).strip()
|
||||
nickname = (raw.get("nickname") or "").strip()
|
||||
remark = (raw.get("remark") or raw.get("conRemark") or "").strip()
|
||||
alias = (raw.get("alias") or raw.get("wechat_id") or "").strip()
|
||||
display = nickname or remark or alias or wxid
|
||||
|
||||
tags = raw.get("tags")
|
||||
if tags is None:
|
||||
tags = []
|
||||
elif isinstance(tags, str):
|
||||
tags = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
|
||||
sex_raw = raw.get("sex")
|
||||
if sex_raw in (0, "0", "unknown", None, ""):
|
||||
sex = "unknown"
|
||||
elif sex_raw in (1, "1", "male", "男"):
|
||||
sex = "male"
|
||||
elif sex_raw in (2, "2", "female", "女"):
|
||||
sex = "female"
|
||||
else:
|
||||
sex = str(sex_raw)
|
||||
|
||||
return {
|
||||
"wxid": wxid,
|
||||
"user_id": wxid,
|
||||
"wechat_id": alias,
|
||||
"nickname": nickname,
|
||||
"display_name": display,
|
||||
"remark": remark,
|
||||
"alias": alias,
|
||||
"type": str(raw.get("type", "")),
|
||||
"sex": sex,
|
||||
"country": raw.get("country") or "",
|
||||
"province": raw.get("province") or "",
|
||||
"city": raw.get("city") or "",
|
||||
"signature": raw.get("signature") or "",
|
||||
"tags": tags,
|
||||
"has_avatar": bool(raw.get("has_avatar") or raw.get("imgFlag")),
|
||||
"phone": raw.get("phone") or raw.get("mobile") or "",
|
||||
"avatar": raw.get("avatar") or "",
|
||||
}
|
||||
|
||||
|
||||
def normalize_wechat_contacts(contacts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
seen = set()
|
||||
for item in contacts or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
norm = normalize_wechat_contact(item)
|
||||
if not norm["wxid"] or norm["wxid"] in seen:
|
||||
continue
|
||||
seen.add(norm["wxid"])
|
||||
out.append(norm)
|
||||
return out
|
||||
@@ -443,14 +443,43 @@ function log(message, type='info'){
|
||||
|
||||
/* ═══ API ═══ */
|
||||
|
||||
const CONSOLE_API_KEY_STORAGE = 'workphone_api_key';
|
||||
let consoleApiKey = sessionStorage.getItem(CONSOLE_API_KEY_STORAGE) || '';
|
||||
|
||||
function requestConsoleApiKey(force=false){
|
||||
if (consoleApiKey && !force) return true;
|
||||
const value = window.prompt(force
|
||||
? '控制台认证已失效,请重新输入 API Key:'
|
||||
: '请输入工作手机控制台 API Key(仅保存到当前浏览器会话):', '');
|
||||
if (!value || !value.trim()) return false;
|
||||
consoleApiKey = value.trim();
|
||||
sessionStorage.setItem(CONSOLE_API_KEY_STORAGE, consoleApiKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function authenticatedFetch(url, options={}, retried=false){
|
||||
if (!consoleApiKey && !requestConsoleApiKey(false)) {
|
||||
throw new Error('控制台未认证');
|
||||
}
|
||||
const headers = new Headers(options.headers || {});
|
||||
headers.set('X-API-Key', consoleApiKey);
|
||||
const res = await fetch(url, {...options, headers});
|
||||
if (res.status === 401 && !retried) {
|
||||
sessionStorage.removeItem(CONSOLE_API_KEY_STORAGE);
|
||||
consoleApiKey = '';
|
||||
if (requestConsoleApiKey(true)) return authenticatedFetch(url, options, true);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function apiGet(url){
|
||||
const res = await fetch(`${API}${url}`);
|
||||
const res = await authenticatedFetch(`${API}${url}`);
|
||||
if(!res.ok) throw new Error(`请求失败: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiPost(url, body){
|
||||
const res = await fetch(`${API}${url}`, {
|
||||
const res = await authenticatedFetch(`${API}${url}`, {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify(body || {})
|
||||
@@ -2692,7 +2721,7 @@ async function fetchHookData(){
|
||||
state.hookDataLoading = true;
|
||||
renderCurrentView();
|
||||
try {
|
||||
const r = await fetch(`/api/v3/hook/data/${did}?modules=profile,contacts,groups,labels,messages,device_info&contact_limit=200&message_limit=50&contact_offset=0&message_offset=0`);
|
||||
const r = await authenticatedFetch(`/api/v3/hook/data/${did}?modules=profile,contacts,groups,labels,messages,device_info&contact_limit=200&message_limit=50&contact_offset=0&message_offset=0`);
|
||||
const j = await r.json();
|
||||
if (j.success && j.data) {
|
||||
state.hookData = j.data;
|
||||
|
||||
@@ -295,7 +295,7 @@ body {
|
||||
|
||||
<script>
|
||||
const API = location.origin;
|
||||
const API_HEADERS = {'X-API-Key': 'workphone-secret-key'};
|
||||
const API_HEADERS = {'X-API-Key': sessionStorage.getItem('workphone_api_key') || ''};
|
||||
let currentDevice = null;
|
||||
let cmdMode = 'pattern'; // pattern | ai | ai-only
|
||||
|
||||
|
||||
51
sdk/app/static/llms.txt
Normal file
@@ -0,0 +1,51 @@
|
||||
# WorkPhone SDK API Portal
|
||||
|
||||
WorkPhone SDK is a real-device phone control engine for Cunkebao, Super Admin, AI Employee, and any external project.
|
||||
|
||||
## Human entry points
|
||||
|
||||
- API Portal: /static/hub.html
|
||||
- Swagger UI: /docs
|
||||
- ReDoc: /redoc
|
||||
- Health: /health
|
||||
|
||||
## Machine-readable API sources
|
||||
|
||||
- OpenAPI JSON: /openapi.json
|
||||
- Full integration manifest: /api/v3/integration/manifest
|
||||
- Modules: /api/v3/integration/modules
|
||||
- Consumers: /api/v3/integration/consumers
|
||||
- Cunkebao subset: /api/v3/integration/consumers/cunkebao
|
||||
- Super Admin subset: /api/v3/integration/consumers/superadmin
|
||||
- AI Employee subset: /api/v3/integration/consumers/ai_employee
|
||||
- Runtime device capability: /api/v3/integration/capability/{device_id}?consumer=cunkebao
|
||||
- Integration health: /api/v3/integration/health
|
||||
|
||||
## Recommended integration flow
|
||||
|
||||
1. Discover available APIs for your consumer role with /api/v3/integration/consumers/{consumer}.
|
||||
2. Check runtime capability for a target device with /api/v3/integration/capability/{device_id}?consumer={consumer}.
|
||||
3. Call concrete /api/v3/* APIs, such as /api/v3/message/send or /api/v3/hook/execute.
|
||||
4. For WeChat production control, prefer WebSocket Agent + Frida Hook. Do not claim success without real-device evidence.
|
||||
|
||||
## Main consumers
|
||||
|
||||
- cunkebao: business messaging, contacts, groups, tags, moments, lead reporting, hook events.
|
||||
- superadmin: connection provider switching, device management, Frida, risk control, global governance.
|
||||
- ai_employee: OpenAI-compatible gateway, MCP tools, natural-language agent execution.
|
||||
- common: health, discovery, devices, shared operational APIs.
|
||||
|
||||
## Documentation files
|
||||
|
||||
- Interface manifest guide: /workbench-docs/5%E3%80%81%E6%8E%A5%E5%8F%A3/07-%E5%AF%B9%E5%A4%96%E6%8E%A5%E5%8F%A3%E7%BB%9F%E4%B8%80%E6%B8%85%E5%8D%95/README.md
|
||||
- Any-project integration manual: /workbench-docs/9%E3%80%81%E6%89%8B%E5%86%8C/02-%E6%93%8D%E4%BD%9C%E6%8C%87%E5%8D%97/%E5%B7%A5%E4%BD%9C%E6%89%8B%E6%9C%BA%C2%B7%E6%8E%A5%E5%8F%A3%E7%BD%91%E7%AB%99%E4%B8%8E%E4%BB%BB%E6%84%8F%E9%A1%B9%E7%9B%AE%E5%AF%B9%E6%8E%A5%E6%89%8B%E5%86%8C.md
|
||||
|
||||
## Current verified scale
|
||||
|
||||
- 33 integration modules
|
||||
- 358 API endpoints in manifest
|
||||
- 253 Cunkebao-ready endpoints across 18 modules
|
||||
|
||||
## Safety
|
||||
|
||||
Real-device validation is mandatory for production claims. Offline tests and mocked responses are regression tools only.
|
||||
36
sdk/deploy/baota/wpsdk.quwanzhi.com.conf
Normal file
@@ -0,0 +1,36 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name wpsdk.quwanzhi.com;
|
||||
location /.well-known/acme-challenge/ { root /www/wwwroot/java_node_ssl; }
|
||||
location / { return 301 https://$host$request_uri; }
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name wpsdk.quwanzhi.com;
|
||||
|
||||
ssl_certificate /www/server/panel/vhost/cert/wpsdk.quwanzhi.com/fullchain.pem;
|
||||
ssl_certificate_key /www/server/panel/vhost/cert/wpsdk.quwanzhi.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
client_max_body_size 50m;
|
||||
proxy_connect_timeout 15s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 3600s;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8899;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
access_log /www/wwwlogs/wpsdk.quwanzhi.com.log;
|
||||
error_log /www/wwwlogs/wpsdk.quwanzhi.com.error.log;
|
||||
}
|
||||
85
sdk/docker-compose.baota.yml
Normal file
@@ -0,0 +1,85 @@
|
||||
services:
|
||||
mongo:
|
||||
image: docker.m.daocloud.io/library/mongo:7
|
||||
container_name: workphone-mongo-baota
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USERNAME:-workphone}
|
||||
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD is required}
|
||||
volumes:
|
||||
- workphone_mongo_data:/data/db
|
||||
networks: [workphone]
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--quiet", "mongodb://${MONGO_ROOT_USERNAME:-workphone}:${MONGO_ROOT_PASSWORD}@127.0.0.1:27017/admin", "--eval", "db.adminCommand('ping')"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 30s
|
||||
|
||||
redis:
|
||||
image: docker.1ms.run/library/redis:7-alpine
|
||||
container_name: workphone-redis-baota
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- workphone_redis_data:/data
|
||||
networks: [workphone]
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
sdk:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: workphone-sdk-baota
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8899:8899"
|
||||
environment:
|
||||
ENV: production
|
||||
DEBUG: "false"
|
||||
TZ: Asia/Shanghai
|
||||
API_AUTH_ENABLED: "true"
|
||||
API_KEY: ${API_KEY:?API_KEY is required}
|
||||
DEVICE_PAIRING_TOKEN: ${DEVICE_PAIRING_TOKEN:?DEVICE_PAIRING_TOKEN is required}
|
||||
MONGO_URI: mongodb://${MONGO_ROOT_USERNAME:-workphone}:${MONGO_ROOT_PASSWORD}@mongo:27017/?authSource=admin
|
||||
MONGO_DB: workphone_sdk
|
||||
REDIS_URL: redis://redis:6379/1
|
||||
AI_BRAIN_ENABLED: ${AI_BRAIN_ENABLED:-false}
|
||||
AI_BRAIN_API_URL: ${AI_BRAIN_API_URL:-https://kr-ai.quwanzhi.com}
|
||||
AI_BRAIN_API_KEY: ${AI_BRAIN_API_KEY:-}
|
||||
WORKPHONE_WS_FIRST: "true"
|
||||
WECHAT_WS_HOOK_ONLY: "true"
|
||||
WORKPHONE_HOST_ADB_PROBE: "false"
|
||||
SERVER_FRIDA_ENABLED: ${SERVER_FRIDA_ENABLED:-false}
|
||||
SERVER_FRIDA_HOST: ${SERVER_FRIDA_HOST:-172.18.0.1}
|
||||
SERVER_FRIDA_PORT: ${SERVER_FRIDA_PORT:-14538}
|
||||
SERVER_FRIDA_DEVICE_ID: ${SERVER_FRIDA_DEVICE_ID:-}
|
||||
volumes:
|
||||
- workphone_sdk_data:/data
|
||||
- workphone_sdk_logs:/app/logs
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks: [workphone]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:8899/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 90s
|
||||
|
||||
volumes:
|
||||
workphone_mongo_data:
|
||||
workphone_redis_data:
|
||||
workphone_sdk_data:
|
||||
workphone_sdk_logs:
|
||||
|
||||
networks:
|
||||
workphone:
|
||||
name: workphone-baota-network
|
||||
50
sdk/launchagents/com.workphone.python-agent.plist
Normal file
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.workphone.python-agent</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/Users/karuo/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3</string>
|
||||
<string>agent.py</string>
|
||||
<string>-d</string>
|
||||
<string>xgfe65eimrrofyws</string>
|
||||
<string>-s</string>
|
||||
<string>ws://127.0.0.1:8899/ws/device</string>
|
||||
<string>--heartbeat</string>
|
||||
<string>10</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/karuo/Documents/开发/2、私域银行/工作手机/sdk/agent</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||
<key>PYTHONPATH</key>
|
||||
<string>/Users/karuo/Documents/开发/2、私域银行/工作手机/sdk/.runtime/python312:/Users/karuo/Documents/开发/2、私域银行/工作手机/sdk/agent</string>
|
||||
<key>ANDROID_ADB_SERVER_PORT</key>
|
||||
<string>5038</string>
|
||||
<key>WP_AI_ENABLED</key>
|
||||
<string>0</string>
|
||||
<key>WP_FRIDA_MODE</key>
|
||||
<string>remote</string>
|
||||
<key>WP_FRIDA_PORT</key>
|
||||
<string>13507</string>
|
||||
<key>WP_DEVICE_SERIAL</key>
|
||||
<string>192.168.110.80:5555</string>
|
||||
</dict>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>5</integer>
|
||||
<key>ProcessType</key>
|
||||
<string>Background</string>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/Users/karuo/Documents/开发/2、私域银行/工作手机/sdk/logs/python-agent.out.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/Users/karuo/Documents/开发/2、私域银行/工作手机/sdk/logs/python-agent.err.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
60
sdk/scripts/adb_bootstrap_termux_agent.sh
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# 运维兜底:ADB 一旦连上红米,自动在 Termux 启动 WS Agent(一次性 bootstrap,非日常主通道)
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LAN_ENV="${SCRIPT_DIR}/../config/lan.env"
|
||||
if [[ -f "$LAN_ENV" ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
source "$LAN_ENV"
|
||||
fi
|
||||
export ANDROID_ADB_SERVER_PORT="${ANDROID_ADB_SERVER_PORT:-5038}"
|
||||
ADB=(adb -P "${ANDROID_ADB_SERVER_PORT}")
|
||||
DEVICE_IP="${DEVICE_IP:-192.168.110.80:5555}"
|
||||
DEVICE_ID="${DEVICE_ID:-xgfe65eimrrofyws}"
|
||||
SDK_BASE="${WORKPHONE_API_URL:-http://127.0.0.1:8899}"
|
||||
FRIDA_PORT="$(python3 -c "import json; print(json.load(open('${SCRIPT_DIR}/anti_detect/phantom_frida_config.json'))['listen_port'])" 2>/dev/null || echo 17263)"
|
||||
WS_URL="${WORKPHONE_WS_URL:-ws://127.0.0.1:8899/ws/device/${DEVICE_ID}}"
|
||||
LOG="/tmp/wp_adb_bootstrap_agent.log"
|
||||
MAX_WAIT="${ADB_BOOTSTRAP_WAIT:-7200}"
|
||||
|
||||
exec >>"$LOG" 2>&1
|
||||
echo "=== $(date) adb bootstrap agent wait ${DEVICE_IP} max=${MAX_WAIT}s ==="
|
||||
|
||||
deadline=$((SECONDS + MAX_WAIT))
|
||||
while (( SECONDS < deadline )); do
|
||||
# 优先 USB 序列号,其次任意非 TV 的无线 device
|
||||
serial=""
|
||||
if [[ -n "${DEVICE_SERIAL:-}" ]] && "${ADB[@]}" -s "$DEVICE_SERIAL" get-state 2>/dev/null | grep -qx device; then
|
||||
serial="$DEVICE_SERIAL"
|
||||
else
|
||||
while IFS= read -r line; do
|
||||
s="${line%%$'\t'*}"
|
||||
[[ -z "$s" ]] && continue
|
||||
brand=$("${ADB[@]}" -s "$s" shell getprop ro.product.brand 2>/dev/null | tr -d '\r')
|
||||
[[ "$brand" == "haier" ]] && continue
|
||||
serial="$s"
|
||||
break
|
||||
done < <("${ADB[@]}" devices 2>/dev/null | awk '/\tdevice$/{print $1}')
|
||||
fi
|
||||
if [[ -n "$serial" ]]; then
|
||||
DEVICE_IP="$serial"
|
||||
echo "=== $(date) ADB ready serial=${serial}, bootstrap Termux Agent ==="
|
||||
"${ADB[@]}" -s "$serial" shell "am start -n com.termux/.HomeActivity" >/dev/null 2>&1 || true
|
||||
sleep 2
|
||||
CMD="export WP_AGENT_ON_DEVICE=1 WP_FRIDA_MODE=remote WP_FRIDA_PORT=${FRIDA_PORT}; cd ~/workphone/agent && python agent.py -d ${DEVICE_ID} -s ${WS_URL} --heartbeat 10"
|
||||
"${ADB[@]}" -s "$serial" shell am startservice -n com.termux/com.termux.app.RunCommandService \
|
||||
-a com.termux.RUN_COMMAND \
|
||||
--es com.termux.RUN_COMMAND_PATH /data/data/com.termux/files/usr/bin/bash \
|
||||
--esa com.termux.RUN_COMMAND_ARGUMENTS "-lc,${CMD}" 2>/dev/null || {
|
||||
echo "RunCommand 失败,请手动在 Termux 粘贴 termux_agent_bootstrap.sh 输出"
|
||||
bash "${SCRIPT_DIR}/termux_agent_bootstrap.sh" "$DEVICE_ID"
|
||||
}
|
||||
echo "=== bootstrap 已触发,等待 WS 上线 ==="
|
||||
bash "${SCRIPT_DIR}/wait_ws_agent.sh" "$DEVICE_ID" "$SDK_BASE" 120 5 && exit 0
|
||||
fi
|
||||
"${ADB[@]}" connect "${DEVICE_IP}" >/dev/null 2>&1 || true
|
||||
echo " … $(date '+%H:%M:%S') waiting phone (skip haier TV)"
|
||||
sleep 10
|
||||
done
|
||||
echo "=== timeout ==="
|
||||
exit 1
|
||||
82
sdk/scripts/adb_connection_stabilize.sh
Executable file
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
# ADB 连接稳定化:宿主机 adb server + USB/无线扫描 + u2/ATX + Agent/WS 自检
|
||||
set -euo pipefail
|
||||
SDK_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SDK_PORT="${SDK_PORT:-8899}"
|
||||
LAN_ENV="${SDK_ROOT}/config/lan.env"
|
||||
if [[ -f "${LAN_ENV}" ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
source "${LAN_ENV}" || true
|
||||
fi
|
||||
WIRELESS_IPS="${WIRELESS_IPS:-192.168.110.80 192.168.1.12 192.168.0.12}"
|
||||
export ANDROID_ADB_SERVER_PORT="${ANDROID_ADB_SERVER_PORT:-5038}"
|
||||
LOG="${TMPDIR:-/tmp}/adb-stabilize-$(date +%Y%m%d_%H%M%S).log"
|
||||
|
||||
adb() {
|
||||
command adb -P "${ANDROID_ADB_SERVER_PORT}" "$@"
|
||||
}
|
||||
|
||||
exec > >(tee -a "$LOG") 2>&1
|
||||
echo "=== ADB 连接稳定化 $(date) ==="
|
||||
echo "日志: $LOG"
|
||||
echo "ADB port: ${ANDROID_ADB_SERVER_PORT}"
|
||||
|
||||
bash "${SDK_ROOT}/scripts/adb_host_server_listen_all.sh" --background || true
|
||||
sleep 1
|
||||
|
||||
echo "--- 清理离线无线连接 ---"
|
||||
adb disconnect >/dev/null 2>&1 || true
|
||||
adb kill-server >/dev/null 2>&1 || true
|
||||
adb start-server >/dev/null 2>&1 || true
|
||||
sleep 2
|
||||
|
||||
echo "--- USB / 已配对设备 ---"
|
||||
adb devices -l || true
|
||||
|
||||
# 尝试无线(短超时,避免 hang)
|
||||
for ip in $WIRELESS_IPS; do
|
||||
[[ "${ip}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || continue
|
||||
( adb connect "${ip}:5555" & pid=$!; sleep 3; kill $pid 2>/dev/null || true ) || true
|
||||
done
|
||||
adb devices -l || true
|
||||
|
||||
SERIAL=""
|
||||
while read -r s st _rest; do
|
||||
[[ -z "${s}" || -z "${st}" ]] && continue
|
||||
if [[ "$st" == "device" ]]; then
|
||||
SERIAL="$s"
|
||||
break
|
||||
fi
|
||||
if [[ "$st" == "unauthorized" ]]; then
|
||||
echo "⚠️ 设备 ${s} 未授权:请在手机上点「允许 USB 调试」并选「文件传输/MTP」"
|
||||
fi
|
||||
done < <(adb devices | awk 'NR>1 && NF>=2 {print $1, $2}')
|
||||
|
||||
if [[ -z "$SERIAL" ]]; then
|
||||
echo "❌ 当前无已授权 ADB 设备"
|
||||
echo " 1) 开发者选项 → USB 调试 开启"
|
||||
echo " 2) USB 模式 → 文件传输"
|
||||
echo " 3) 重新插拔数据线,看手机弹窗点「允许」"
|
||||
echo " 4) 再运行: bash sdk/scripts/adb_connection_stabilize.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ 已授权设备: $SERIAL"
|
||||
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
echo "--- uiautomator2 init (ATX 9008) ---"
|
||||
python3 -m uiautomator2 init --serial "$SERIAL" 2>/dev/null || \
|
||||
python3 -m uiautomator2 init -s "$SERIAL" 2>/dev/null || \
|
||||
echo "⚠️ u2 init 跳过(可手动: python3 -m uiautomator2 init --serial $SERIAL)"
|
||||
fi
|
||||
|
||||
echo "--- 启动 Frida 主控 + Agent ---"
|
||||
bash "${SDK_ROOT}/scripts/frida_workphone_oneclick.sh" -d "$SERIAL" || true
|
||||
|
||||
echo "--- 连接状态 ---"
|
||||
curl -s "http://127.0.0.1:${SDK_PORT}/api/v3/connection/status" | python3 -m json.tool 2>/dev/null || true
|
||||
curl -s "http://127.0.0.1:${SDK_PORT}/api/v3/hook/probe/${SERIAL}" | python3 -m json.tool 2>/dev/null | head -20 || true
|
||||
|
||||
echo ""
|
||||
echo "管理端: http://127.0.0.1:${SDK_PORT}/hub"
|
||||
echo "设备控制台: http://127.0.0.1:${SDK_PORT}/static/index.html"
|
||||
151
sdk/scripts/aochuang_protocol_coverage.py
Normal file
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
奥创 SDK 通信协议(客服通信接口总目录 · EnumMsgType 指令类 Task)↔ 工作手机 SDK action 覆盖矩阵。
|
||||
|
||||
目的:对照奥创 proto 的服务端→设备「指令类任务」(1070-1270),核对本 SDK 是否已具备
|
||||
可下发的等价 action(ACTION_TO_RPC + ACTION_ALIASES catalog)。锁定真实缺口,禁止漏项。
|
||||
|
||||
用法: python3 sdk/scripts/aochuang_protocol_coverage.py [--md 输出.md]
|
||||
真机铁律:本脚本仅做「能力覆盖」静态核对,不代表真机已验收。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HE = ROOT / "sdk/agent/hook/hook_executor.py"
|
||||
|
||||
# 奥创协议「服务端/PC → 设备」指令类任务枚举 → 本 SDK 期望 action(None = 缺口)
|
||||
# 依据 资料/工作手机客服通信接口总目录(带通信协议)SDK.doc EnumMsgType
|
||||
PROTO_TASKS: dict[str, tuple[str, str | None]] = {
|
||||
"TalkToFriendTask(1070)": ("给好友发消息", "send_message"),
|
||||
"PostSNSNewsTask(1071)": ("发朋友圈", "post_moments"),
|
||||
"AddFriendsTask(1072)": ("主动添加好友", "add_friend"),
|
||||
"DeleteSNSNewsTask(1074)": ("删除朋友圈", "delete_moments"),
|
||||
"AcceptFriendAddRequestTask(1075)": ("接受好友请求", "accept_friend"),
|
||||
"WeChatGroupSendTask(1076)": ("群发消息", "mass_send"),
|
||||
"WeChatMaintenanceTask(1077)": ("养号任务", "start_nurture"),
|
||||
"RequestTalkDetailTask(1078)": ("请求图片/视频详情", "get_message_raw_xml"),
|
||||
"PullWeChatQrCodeTask(1079)": ("上传当前微信二维码", "generate_my_qr_code"),
|
||||
"TriggerFriendPushTask(1080)": ("推送好友列表", "get_contacts"),
|
||||
"TriggerCirclePushTask(1081)": ("推送朋友圈列表", "get_moments"),
|
||||
"CircleCommentDeleteTask(1082)": ("朋友圈评论删除", "delete_moment_comment"),
|
||||
"CircleCommentReplyTask(1084)": ("朋友圈评论回复", "reply_moment_comment"),
|
||||
"TriggerMessageReadTask(1086)": ("聊天窗口置已读", "mark_conversation_read"),
|
||||
"RevokeMessageTask(1087)": ("消息撤回", "revoke_message"),
|
||||
"ForwardMessageTask(1088)": ("转发消息", "forward_message"),
|
||||
"TriggerHistoryMsgPushTask(1089)": ("推送历史聊天记录", "get_recent_messages"),
|
||||
"PullChatRoomQrCodeTask(1090)": ("获取群二维码", "generate_group_qr_code"),
|
||||
"SendMultiPictureTask(1091)": ("发送多张图片", "send_multi_image"),
|
||||
"ForwardMultiMessageTask(1092)": ("转发多条消息", "forward_multiple"),
|
||||
"PostFriendDetectTask(1095)": ("清粉任务", "detect_zombie_fans"),
|
||||
"PostStopFriendDetectTask(1096)": ("终止清粉", "stop_detect_zombie_fans"),
|
||||
"PostMomentsPraiseTask(1098)": ("朋友圈点赞", "like_moments"),
|
||||
"PostStopMomentsPraiseTask(1099)": ("停止朋友圈点赞", "stop_moments_praise"),
|
||||
"PostStopWeChatMaintenanceTask(1100)": ("养号停止", "stop_nurture"),
|
||||
"ModifyFriendMemoTask(1101)": ("修改备注", "set_friend_remark"),
|
||||
"AddFriendWithSceneTask(1102)": ("场景加好友", "add_friend_with_scene"),
|
||||
"TakeLuckyMoneyTask(1200)": ("领取红包/转账", "receive_red_packet"),
|
||||
"PullFriendCircleTask(1201)": ("获取指定好友朋友圈", "get_friend_moments"),
|
||||
"PullCircleDetailTask(1202)": ("获取朋友圈图片", "get_moment_detail"),
|
||||
"CircleLikeTask(1203)": ("单条朋友圈点赞", "like_moments"),
|
||||
"TriggerChatroomPushTask(1210)": ("推送群聊列表", "get_groups"),
|
||||
"RequestChatRoomInfoTask(1211)": ("群聊详情", "get_group_info"),
|
||||
"RequestContactsInfoTask(1212)": ("联系人详情", "get_contact_info"),
|
||||
"ChatRoomActionTask(1213)": ("群聊管理", "set_group_name"),
|
||||
"AddFriendInChatRoomTask(1214)": ("群内加好友", "add_friend_in_room"),
|
||||
"AddFriendFromPhonebookTask(1215)": ("通讯录加好友", "add_friend_from_phonebook"),
|
||||
"DeleteFriendTask(1216)": ("删除好友", "delete_friend"),
|
||||
"SendLuckyMoneyTask(1217)": ("发红包", "send_red_packet"),
|
||||
"RequestTalkContentTask(1218)": ("获取消息原始xml", "get_message_raw_xml"),
|
||||
"ForwardMessageByContentTask(1220)": ("转发消息内容", "forward_message"),
|
||||
"ChatRoomInviteApproveTask(1221)": ("群主确认入群申请", "approve_group_invite"),
|
||||
"WechatLogoutTask(1222)": ("微信登出", "logout"),
|
||||
"PhoneActionTask(1223)": ("手机操作(重启等)", "phone_action"),
|
||||
"ContactLabelTask(1224)": ("设置联系人标签", "set_contact_label"),
|
||||
"ContactLabelDeleteTask(1225)": ("删除联系人标签", "delete_label"),
|
||||
"VoiceTransTextTask(1226)": ("语音转文字", "voice_to_text"),
|
||||
"FindContactTask(1227)": ("查找微信联系人", "search_contacts"),
|
||||
"AgreeJoinChatRoomTask(1229)": ("同意加入群聊", "agree_join_group"),
|
||||
"ClearAllChatMsgTask(1230)": ("清空聊天记录", "clear_chat_history"),
|
||||
"SendFriendVerifyTask(1231)": ("聊天界面发送朋友验证", "send_friend_verify"),
|
||||
"TriggerConversationPushTask(1232)": ("会话列表推送", "get_conversations"),
|
||||
"WechatSettingTask(1233)": ("微信设置(昵称/头像)", "set_nickname"),
|
||||
"PullFriendAddReqListTask(1234)": ("加好友请求列表", "get_friend_requests"),
|
||||
"TriggerBizContactPushTask(1235)": ("公众号列表", "get_official_accounts"),
|
||||
"AddFriendNameCardTask(1236)": ("名片加好友", "add_friend_by_card"),
|
||||
"TriggerChatMsgIdsPushTask(1251)": ("时间段内 msgSvrId", "get_msg_ids_by_time"),
|
||||
"RequestTalkMsgTask(1252)": ("按 msgSvrId 取消息", "get_message_by_id"),
|
||||
"SearchBizContactTask(1254)": ("搜索公众号/小程序", "search_official_account"),
|
||||
"PhoneStateTask(1256)": ("手机状态(电量/存储)", "get_device_info"),
|
||||
"WeChatLocationTask(1258)": ("微信查手机位置", "get_wechat_location"),
|
||||
"RemittanceTask(1260)": ("转账", "send_transfer"),
|
||||
"WalletBalanceTask(1262)": ("钱包余额", "get_wallet_balance"),
|
||||
"QueryHbDetailTask(1265)": ("查询红包详情", "query_red_packet"),
|
||||
"JoinGroupByQrTask(1267)": ("扫二维码进群", "join_group_by_qr"),
|
||||
"SendJielongTask(1268)": ("发接龙消息", "send_jielong"),
|
||||
"ContactSetLabelTask(1270)": ("设置用户标签", "set_contact_label"),
|
||||
"WechatLogoutTask(cmd)": ("账号登出命令", "logout"),
|
||||
"PhoneActionTask(cmd-reboot)": ("重启手机等命令", "phone_action"),
|
||||
"UpgradeDeviceAppNotice(cmd)": ("软件升级通知", "check_for_update"),
|
||||
}
|
||||
|
||||
|
||||
def load_sdk_catalog() -> set[str]:
|
||||
text = HE.read_text(encoding="utf-8", errors="ignore")
|
||||
a = text.split("ACTION_TO_RPC:", 1)[1].split("ACTION_ALIASES:", 1)[0]
|
||||
b = text.split("ACTION_ALIASES:", 1)[1].split("\n\n", 1)[0]
|
||||
rpc = set(re.findall(r'"([a-z_]+)"\s*:', a))
|
||||
alias = set(re.findall(r'"([a-z_]+)"\s*:', b))
|
||||
return rpc | alias
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--md", default="")
|
||||
args = ap.parse_args()
|
||||
|
||||
catalog = load_sdk_catalog()
|
||||
covered, gaps = [], []
|
||||
for task, (cn, action) in PROTO_TASKS.items():
|
||||
if action and action in catalog:
|
||||
covered.append((task, cn, action))
|
||||
elif action and action not in catalog:
|
||||
gaps.append((task, cn, f"{action}(映射缺失)"))
|
||||
else:
|
||||
gaps.append((task, cn, "无对应 action"))
|
||||
|
||||
total = len(PROTO_TASKS)
|
||||
lines = [
|
||||
"# 奥创协议 ↔ 工作手机 SDK action 覆盖矩阵",
|
||||
"",
|
||||
f"> 真源:`资料/工作手机客服通信接口总目录(带通信协议)SDK.doc` · 自动生成 `sdk/scripts/aochuang_protocol_coverage.py`",
|
||||
f"> 指令类任务 **{total}** 项 · 已覆盖 **{len(covered)}** · 缺口 **{len(gaps)}** · 覆盖率 **{len(covered)*100//total}%**",
|
||||
"> 真机铁律:覆盖=能力映射就绪;真机 E2E 另见验收清单,禁止无证据标功能 ✅",
|
||||
"",
|
||||
"## 一、已覆盖",
|
||||
"",
|
||||
"| 协议任务 | 中文 | SDK action |",
|
||||
"|:---|:---|:---|",
|
||||
]
|
||||
for t, cn, a in covered:
|
||||
lines.append(f"| {t} | {cn} | `{a}` |")
|
||||
lines += ["", "## 二、缺口(待补 action)", "", "| 协议任务 | 中文 | 现状 |", "|:---|:---|:---|"]
|
||||
for t, cn, a in gaps:
|
||||
lines.append(f"| {t} | {cn} | {a} |")
|
||||
|
||||
md = "\n".join(lines) + "\n"
|
||||
if args.md:
|
||||
Path(args.md).write_text(md, encoding="utf-8")
|
||||
print(f"已写入 {args.md}")
|
||||
print(f"覆盖 {len(covered)}/{total} · 缺口 {len(gaps)}")
|
||||
for t, cn, _ in gaps:
|
||||
print(f" GAP {t} {cn}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
43
sdk/scripts/auto_accept_when_ready.sh
Executable file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# auto_accept_when_ready.sh - guard: when phone frida attaches WeChat, auto run 3-item acceptance
|
||||
# Polls hook/probe; on supports_hook=true runs run_three_acceptance.sh and archives evidence.
|
||||
# Usage: bash sdk/scripts/auto_accept_when_ready.sh [device_id] [max_wait_sec]
|
||||
set -o pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
DEVICE="${1:-xgfe65eimrrofyws}"
|
||||
MAX_WAIT="${2:-7200}"
|
||||
BASE="${SDK_BASE_URL:-http://127.0.0.1:8899}"
|
||||
INTERVAL=10
|
||||
LOG="$ROOT/sdk/logs/auto_accept_${DEVICE}.log"
|
||||
mkdir -p "$(dirname "$LOG")"
|
||||
|
||||
echo "[auto-accept] start $(date) device=$DEVICE max=${MAX_WAIT}s" | tee -a "$LOG"
|
||||
deadline=$(( $(date +%s) + MAX_WAIT ))
|
||||
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
# 就绪信号:diag_wcdb RPC 可用(= 新 wechat_hook_v2.js 已重载,含 WCDB 2.x 适配)
|
||||
DIAG="$(curl -s --max-time 30 -X POST "$BASE/api/v3/hook/execute" -H 'Content-Type: application/json' \
|
||||
-d "{\"device_id\":\"$DEVICE\",\"platform\":\"wechat\",\"action\":\"diag_wcdb\",\"params\":{},\"hook_only\":true}" 2>/dev/null)"
|
||||
SUP="$(printf '%s' "$DIAG" | python3 -c 'import sys,json
|
||||
try:
|
||||
d=json.load(sys.stdin); inner=d.get("data",d)
|
||||
# 最新 JS 标志:diag_wcdb 含 main_query 实读测试字段
|
||||
print("True" if (inner.get("success") and "main_query" in inner) else "ERR")
|
||||
except Exception:
|
||||
print("ERR")' 2>/dev/null)"
|
||||
SUP="${SUP:-ERR}"
|
||||
if [ "$SUP" = "True" ]; then
|
||||
echo "[auto-accept] $(date) diag_wcdb ok (new JS loaded) -> run 3-item acceptance" | tee -a "$LOG"
|
||||
echo "[auto-accept] WCDB diag: $DIAG" >> "$LOG"
|
||||
bash "$ROOT/sdk/scripts/run_three_acceptance.sh" "$DEVICE" 2>&1 | tee -a "$LOG"
|
||||
RC="${PIPESTATUS[0]:-0}"
|
||||
echo "[auto-accept] acceptance exit=$RC $(date)" | tee -a "$LOG"
|
||||
exit "$RC"
|
||||
fi
|
||||
echo "[auto-accept] $(date '+%H:%M:%S') 新JS未就绪(diag_wcdb=$SUP),等手机重启Agent,retry ${INTERVAL}s" >> "$LOG"
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
|
||||
echo "[auto-accept] timeout, frida not ready $(date)" | tee -a "$LOG"
|
||||
exit 1
|
||||
31
sdk/scripts/auto_capture_when_frida_back.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# 守护:轮询微信 Frida 附着状态,一旦 frida_connected=true 立即跑真机验收(截图+读/发/转发+revoke)并退出。
|
||||
# 真机铁律:只在 Frida 真附着时才跑 E2E,绝不伪造。
|
||||
set -uo pipefail
|
||||
SDK="${WORKPHONE_SDK_URL:-http://127.0.0.1:8899}"
|
||||
DEVICE_ID="${DEVICE_ID:-xgfe65eimrrofyws}"
|
||||
MAX_MIN="${MAX_MIN:-120}" # 最长守护分钟
|
||||
INTERVAL="${INTERVAL:-30}"
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
LOG="$REPO_ROOT/sdk/tmp/auto_capture_$(date +%Y%m%d_%H%M%S).log"
|
||||
mkdir -p "$(dirname "$LOG")"
|
||||
deadline=$(( $(date +%s) + MAX_MIN*60 ))
|
||||
echo "[watch] start sdk=$SDK device=$DEVICE_ID interval=${INTERVAL}s max=${MAX_MIN}min" | tee -a "$LOG"
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
R="$(curl -sS -m 15 -X POST "$SDK/api/v3/wechat/execute" -H 'Content-Type: application/json' \
|
||||
-d "{\"device_id\":\"$DEVICE_ID\",\"action\":\"get_profile\",\"params\":{}}" 2>/dev/null)"
|
||||
FR="$(printf '%s' "$R" | python3 -c "import sys,json
|
||||
try:
|
||||
d=json.load(sys.stdin);print((d.get('data') or {}).get('frida_connected'))
|
||||
except: print('err')" 2>/dev/null)"
|
||||
echo "[watch] $(date +%H:%M:%S) frida_connected=$FR" | tee -a "$LOG"
|
||||
if [ "$FR" = "True" ]; then
|
||||
echo "[watch] Frida 已附着 → 跑真机验收" | tee -a "$LOG"
|
||||
WORKPHONE_SDK_URL="$SDK" DEVICE_ID="$DEVICE_ID" bash "$REPO_ROOT/sdk/scripts/run_wechat_realdevice_acceptance.sh" 2>&1 | tee -a "$LOG"
|
||||
echo "[watch] done exit=${PIPESTATUS[0]}" | tee -a "$LOG"
|
||||
exit 0
|
||||
fi
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
echo "[watch] 超时未等到 Frida 附着(${MAX_MIN}min),退出。设备侧需:微信前台+frida-server+Agent。" | tee -a "$LOG"
|
||||
exit 2
|
||||
150
sdk/scripts/build_agent_config.py
Executable file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
"""生成 Termux Agent config.json — 连接顺序 ① NAS 局域网 ② 本机 Docker ③ NAS 外网 frp。
|
||||
|
||||
与 agent.py BIND-03 / workphone.mdc §零.十 一致。
|
||||
用法:
|
||||
python3 build_agent_config.py --device-id xgfe65eimrrofyws --out /tmp/config.json
|
||||
python3 build_agent_config.py --print-primary # 只输出当前可达主服 ws 基址
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
import urllib.request
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
SDK_PORT = 8899
|
||||
NAS_LAN_HOSTS = ("192.168.110.101", "192.168.1.201")
|
||||
NAS_FRP_HOST = "open.quwanzhi.com"
|
||||
HEARTBEAT = 10
|
||||
PROJECT_ID = "cunkebao"
|
||||
|
||||
|
||||
def _tcp_ok(host: str, port: int, timeout: float = 2.5) -> bool:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _health_ok(host: str, port: int = SDK_PORT, timeout: float = 3.0) -> bool:
|
||||
try:
|
||||
url = f"http://{host}:{port}/health"
|
||||
with urllib.request.urlopen(url, timeout=timeout) as resp:
|
||||
return resp.status == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def pick_mac_lan_ip() -> str:
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
out = subprocess.check_output(["ifconfig"], text=True, stderr=subprocess.DEVNULL)
|
||||
except Exception:
|
||||
return "127.0.0.1"
|
||||
ips: List[str] = []
|
||||
for line in out.splitlines():
|
||||
parts = line.strip().split()
|
||||
if len(parts) >= 2 and parts[0] == "inet":
|
||||
ip = parts[1]
|
||||
if ip.startswith("127.") or ip.startswith("198.18."):
|
||||
continue
|
||||
ips.append(ip)
|
||||
for ip in ips:
|
||||
if ip.startswith("192.168.110."):
|
||||
return ip
|
||||
for ip in ips:
|
||||
if ip.startswith("192.168.") or ip.startswith("10."):
|
||||
return ip
|
||||
return ips[0] if ips else "127.0.0.1"
|
||||
|
||||
|
||||
def resolve_primary(for_phone: bool = False) -> Tuple[str, str, List[str]]:
|
||||
"""返回 (primary_ws_base, tier, ordered_ws_bases)。
|
||||
|
||||
手机/本机统一顺序(BIND-03 · workphone.mdc §零.十):
|
||||
① NAS 局域网 → ② 本机 Mac Docker 局域网 → ③ NAS 外网 frp;
|
||||
Agent 运行时 TCP 探测,连不上自动切下一候选。
|
||||
"""
|
||||
mac_ip = pick_mac_lan_ip()
|
||||
nas_bases = [(f"ws://{h}:{SDK_PORT}/ws/device", "nas_lan") for h in NAS_LAN_HOSTS]
|
||||
frp_base = (f"ws://{NAS_FRP_HOST}:{SDK_PORT}/ws/device", "nas_frp")
|
||||
mac_base = (f"ws://{mac_ip}:{SDK_PORT}/ws/device", "mac_docker") if mac_ip != "127.0.0.1" else None
|
||||
|
||||
ordered: List[Tuple[str, str]] = list(nas_bases)
|
||||
if mac_base:
|
||||
ordered.append(mac_base)
|
||||
ordered.append(frp_base)
|
||||
|
||||
if for_phone:
|
||||
# 手机 OTA:固定 NAS 为主连接写入 config,public_servers 按序探测(局域网优先于公网)
|
||||
primary_ws, tier = nas_bases[0][0], "nas_lan"
|
||||
bases = [b for b, _ in ordered]
|
||||
seen = set()
|
||||
ordered_bases: List[str] = []
|
||||
for b in bases:
|
||||
if b not in seen:
|
||||
seen.add(b)
|
||||
ordered_bases.append(b)
|
||||
return primary_ws, tier, ordered_bases
|
||||
|
||||
primary_ws, tier = ordered[0][0], ordered[0][1]
|
||||
for ws_base, t in ordered:
|
||||
host = ws_base.split("//")[1].split(":")[0]
|
||||
if _health_ok(host, SDK_PORT):
|
||||
primary_ws, tier = ws_base, t
|
||||
break
|
||||
|
||||
bases: List[str] = []
|
||||
seen = set()
|
||||
for ws_base, _ in ordered:
|
||||
if ws_base not in seen:
|
||||
seen.add(ws_base)
|
||||
bases.append(ws_base)
|
||||
bases = [primary_ws] + [b for b in bases if b != primary_ws]
|
||||
return primary_ws, tier, bases
|
||||
|
||||
|
||||
def build_config(device_id: str, for_phone: bool = False) -> dict:
|
||||
primary_ws, tier, bases = resolve_primary(for_phone=for_phone)
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"server_url": primary_ws,
|
||||
"public_servers": [b for b in bases if b != primary_ws],
|
||||
"heartbeat_interval": HEARTBEAT,
|
||||
"project_id": PROJECT_ID,
|
||||
"connection_priority": ["nas_lan", "mac_docker", "nas_frp"],
|
||||
"_meta": {"primary_tier": tier, "generator": "build_agent_config.py"},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--device-id", default="xgfe65eimrrofyws")
|
||||
ap.add_argument("--out", default="")
|
||||
ap.add_argument("--print-primary", action="store_true")
|
||||
ap.add_argument("--for-phone", action="store_true", help="手机 OTA:NAS→frp→Mac")
|
||||
args = ap.parse_args()
|
||||
|
||||
primary_ws, tier, _ = resolve_primary(for_phone=args.for_phone)
|
||||
if args.print_primary:
|
||||
print(primary_ws)
|
||||
print(tier, file=sys.stderr)
|
||||
return 0
|
||||
|
||||
cfg = build_config(args.device_id, for_phone=args.for_phone)
|
||||
text = json.dumps(cfg, ensure_ascii=False, indent=2)
|
||||
if args.out:
|
||||
with open(args.out, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
else:
|
||||
print(text)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
252
sdk/scripts/clean_aochuang_device.sh
Executable file
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env bash
|
||||
# 奥创工作手机 · 真机清机(保留机擎 com.system.cloudservice + Magisk/Frida 基建)
|
||||
# 真源 Skill:机擎/阿服/奥创真机清机/SKILL.md
|
||||
# 用法:
|
||||
# bash sdk/scripts/clean_aochuang_device.sh [--dry-run|--apply] [-s SERIAL] [--deep]
|
||||
# 默认 --dry-run;真清须显式 --apply
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SERIAL=""
|
||||
MODE="dry-run"
|
||||
DEEP=0
|
||||
EVIDENCE_DIR=""
|
||||
DEFAULT_SERIAL="xgfe65eimrrofyws"
|
||||
|
||||
# 奥创/S2 手机端包(资料真源:奥创微信控制接口与插件提取复用指南.md)
|
||||
AOCHUANG_PACKAGES=(
|
||||
"org.xeslciw.manager" # XESlciw Manager v1.8.4
|
||||
"top.zzz.vivwxjz" # VivWxjz 微信 Hook
|
||||
"uni.UNI9421F6C" # AI数智员工 / 007 业务入口
|
||||
)
|
||||
|
||||
# 可能关联(--deep 才卸)
|
||||
AOCHUANG_OPTIONAL_PACKAGES=(
|
||||
"io.github.vvb2060.mahoshojo" # 魔法少女(资料提及同链路工具)
|
||||
)
|
||||
|
||||
KEEP_PACKAGES=(
|
||||
"com.system.cloudservice" # 机擎 Agent(禁止卸)
|
||||
"com.tencent.mm" # 微信(默认保留数据)
|
||||
)
|
||||
|
||||
# 框架/寄生目录(须 root)
|
||||
AOCHUANG_ROOT_PATHS=(
|
||||
"/data/adb/xesd"
|
||||
"/data/adb/modules/xeslciw"
|
||||
"/data/adb/modules/XESlciw"
|
||||
"/data/adb/modules/grvjfe"
|
||||
"/data/adb/modules/vivwxjz"
|
||||
)
|
||||
|
||||
# 常见残留 app 分区路径(资料:奥创工作手机-复刻开发详解.md §APK路径)
|
||||
AOCHUANG_APP_DIRS=(
|
||||
"/data/app/manager_sign"
|
||||
"/data/app/VivWxjz"
|
||||
"/data/app/~~*org.xeslciw.manager*"
|
||||
"/data/app/~~*top.zzz.vivwxjz*"
|
||||
"/data/app/~~*uni.UNI9421F6C*"
|
||||
)
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
奥创真机清机 — clean_aochuang_device.sh
|
||||
|
||||
bash sdk/scripts/clean_aochuang_device.sh [选项]
|
||||
|
||||
选项:
|
||||
--dry-run 仅扫描+打印将执行的操作(默认)
|
||||
--apply 实际清理(须 ADB device + 建议 Root)
|
||||
-s SERIAL 设备序列号(默认唯一在线设备或 xgfe65eimrrofyws)
|
||||
--deep 额外卸 mahoshojo + 清 007 业务 app 数据目录
|
||||
--evidence DIR 留痕目录(默认 开发文档/8、部署/05-测试验收/YYYYMMDD_奥创清机_<serial>/)
|
||||
|
||||
保留(永不卸):com.system.cloudservice、Magisk、机擎 Frida 基建
|
||||
EOF
|
||||
}
|
||||
|
||||
log() { printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*"; }
|
||||
run_adb() { adb -s "$SERIAL" "$@"; }
|
||||
run_sh() { run_adb shell "$@"; }
|
||||
|
||||
pick_su() {
|
||||
run_sh 'for p in /sbin/su /system/bin/su /system/xbin/su su; do
|
||||
if [ -x "$p" ] 2>/dev/null || command -v "$p" >/dev/null 2>&1; then echo "$p"; exit 0; fi
|
||||
done; exit 1' 2>/dev/null || true
|
||||
}
|
||||
|
||||
run_root() {
|
||||
local cmd="$1"
|
||||
local su
|
||||
su="$(pick_su)"
|
||||
if [[ -z "$su" ]]; then
|
||||
log "WARN: 无 su,跳过 root 操作: $cmd"
|
||||
return 1
|
||||
fi
|
||||
if [[ "$MODE" == "dry-run" ]]; then
|
||||
log "DRY su -c $(printf '%q' "$cmd")"
|
||||
return 0
|
||||
fi
|
||||
run_sh "$su -c $(printf '%q' "$cmd")" || return 1
|
||||
}
|
||||
|
||||
pkg_installed() {
|
||||
local pkg="$1"
|
||||
run_sh pm path "$pkg" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
do_uninstall_pkg() {
|
||||
local pkg="$1"
|
||||
if ! pkg_installed "$pkg"; then
|
||||
log "SKIP 未安装: $pkg"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$MODE" == "dry-run" ]]; then
|
||||
log "DRY pm uninstall --user 0 $pkg"
|
||||
log "DRY pm uninstall $pkg"
|
||||
return 0
|
||||
fi
|
||||
log "卸载: $pkg"
|
||||
run_sh pm disable-user --user 0 "$pkg" 2>/dev/null || true
|
||||
run_sh am force-stop "$pkg" 2>/dev/null || true
|
||||
run_sh pm uninstall --user 0 "$pkg" 2>/dev/null || run_sh pm uninstall "$pkg" 2>/dev/null || log "WARN 卸载失败: $pkg"
|
||||
}
|
||||
|
||||
clear_pkg_data() {
|
||||
local pkg="$1"
|
||||
if [[ "$MODE" == "dry-run" ]]; then
|
||||
log "DRY pm clear $pkg"
|
||||
return 0
|
||||
fi
|
||||
run_sh pm clear "$pkg" 2>/dev/null || true
|
||||
}
|
||||
|
||||
remove_root_path() {
|
||||
local p="$1"
|
||||
if [[ "$MODE" == "dry-run" ]]; then
|
||||
log "DRY rm -rf $p"
|
||||
return 0
|
||||
fi
|
||||
run_root "rm -rf $(printf '%q' "$p")" || true
|
||||
}
|
||||
|
||||
scan_magisk_modules() {
|
||||
log "== Magisk 模块扫描(名称含 xes/grvjfe/vivwx/lsposed 且非机擎) =="
|
||||
run_root 'ls -1 /data/adb/modules 2>/dev/null' | while read -r m; do
|
||||
[[ -z "$m" ]] && continue
|
||||
if echo "$m" | grep -qiE 'xes|grvjfe|vivwx|lsposed|edxposed|xposed'; then
|
||||
if echo "$m" | grep -qiE 'shamiko|zygisk|rezygisk|riru'; then
|
||||
log "KEEP Magisk模块(机擎基建): $m"
|
||||
else
|
||||
if [[ "$MODE" == "dry-run" ]]; then
|
||||
log "DRY 将禁用并移除模块: $m"
|
||||
else
|
||||
log "移除 Magisk 模块: $m"
|
||||
run_root "touch /data/adb/modules/$m/disable" || true
|
||||
run_root "rm -rf /data/adb/modules/$m" || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done || true
|
||||
}
|
||||
|
||||
write_evidence() {
|
||||
[[ -z "$EVIDENCE_DIR" ]] && return 0
|
||||
mkdir -p "$EVIDENCE_DIR"
|
||||
{
|
||||
echo "# 奥创清机留痕 $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "serial=$SERIAL mode=$MODE deep=$DEEP"
|
||||
echo ""
|
||||
echo "## pm list packages (filter)"
|
||||
run_sh pm list packages 2>/dev/null | grep -iE 'xes|zzz|vivwx|UNI9421|mahoshojo|cloudservice' || true
|
||||
echo ""
|
||||
echo "## xesd"
|
||||
run_sh "ls -la /data/adb/xesd/ 2>/dev/null || echo NO_XESD" || true
|
||||
} >"$EVIDENCE_DIR/clean_report.txt"
|
||||
log "留痕: $EVIDENCE_DIR/clean_report.txt"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--apply) MODE="apply" ;;
|
||||
--dry-run) MODE="dry-run" ;;
|
||||
-s) SERIAL="${2:-}"; shift ;;
|
||||
--deep) DEEP=1 ;;
|
||||
--evidence) EVIDENCE_DIR="${2:-}"; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "未知参数: $1"; usage; exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [[ -z "$SERIAL" ]]; then
|
||||
SERIAL="$(adb devices 2>/dev/null | awk 'NR>1 && $2=="device" {print $1; exit}')"
|
||||
[[ -z "$SERIAL" ]] && SERIAL="$DEFAULT_SERIAL"
|
||||
fi
|
||||
|
||||
if ! adb devices 2>/dev/null | awk -v s="$SERIAL" '$1==s && $2=="device" {found=1} END{exit !found}'; then
|
||||
echo "FAIL: 设备 $SERIAL 未在线(adb devices 无 device)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$EVIDENCE_DIR" ]]; then
|
||||
EVIDENCE_DIR="$(cd "$(dirname "$0")/../.." && pwd)/开发文档/8、部署/05-测试验收/$(date '+%Y%m%d')_奥创清机_${SERIAL}"
|
||||
fi
|
||||
|
||||
log "== 奥创真机清机 mode=$MODE serial=$SERIAL deep=$DEEP =="
|
||||
|
||||
for k in "${KEEP_PACKAGES[@]}"; do
|
||||
if pkg_installed "$k"; then
|
||||
log "KEEP 保留: $k"
|
||||
fi
|
||||
done
|
||||
|
||||
log "== 1/6 停止并卸载奥创 APK =="
|
||||
for pkg in "${AOCHUANG_PACKAGES[@]}"; do
|
||||
do_uninstall_pkg "$pkg"
|
||||
done
|
||||
if [[ "$DEEP" == "1" ]]; then
|
||||
for pkg in "${AOCHUANG_OPTIONAL_PACKAGES[@]}"; do
|
||||
do_uninstall_pkg "$pkg"
|
||||
done
|
||||
fi
|
||||
|
||||
log "== 2/6 清理 app 数据(已卸包) =="
|
||||
for pkg in "${AOCHUANG_PACKAGES[@]}"; do
|
||||
clear_pkg_data "$pkg"
|
||||
done
|
||||
|
||||
log "== 3/6 移除框架目录 /data/adb/xesd 等 =="
|
||||
for p in "${AOCHUANG_ROOT_PATHS[@]}"; do
|
||||
remove_root_path "$p"
|
||||
done
|
||||
|
||||
log "== 4/6 Magisk 奥创相关模块 =="
|
||||
scan_magisk_modules
|
||||
|
||||
log "== 5/6 残留分区路径 =="
|
||||
for p in "${AOCHUANG_APP_DIRS[@]}"; do
|
||||
if [[ "$p" == *'*'* ]]; then
|
||||
if [[ "$MODE" == "dry-run" ]]; then
|
||||
log "DRY rm -rf $p"
|
||||
else
|
||||
run_root "rm -rf $p" 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
remove_root_path "$p"
|
||||
fi
|
||||
done
|
||||
|
||||
log "== 6/6 重启提示 =="
|
||||
if [[ "$MODE" == "apply" ]]; then
|
||||
log "建议: adb -s $SERIAL reboot(Magisk 模块变更后)"
|
||||
fi
|
||||
|
||||
write_evidence
|
||||
|
||||
if [[ "$MODE" == "dry-run" ]]; then
|
||||
log "完成(dry-run)。真清请: bash sdk/scripts/clean_aochuang_device.sh --apply -s $SERIAL"
|
||||
log "验收: bash sdk/scripts/verify_aochuang_clean.sh -s $SERIAL"
|
||||
else
|
||||
log "完成(apply)。请跑验收脚本确认全绿。"
|
||||
fi
|
||||
210
sdk/scripts/connection_keeper_daemon.sh
Executable file
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env bash
|
||||
# 宿主机连接守护:ADB + WS Agent 常驻保活(处理长任务时也不断链)
|
||||
# 用法:
|
||||
# bash sdk/scripts/connection_keeper_daemon.sh # 前台
|
||||
# bash sdk/scripts/connection_keeper_daemon.sh --bg # 后台
|
||||
# bash sdk/scripts/connection_keeper_daemon.sh --once # 只跑一轮稳定化
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SDK_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
LAN_ENV="${SDK_ROOT}/config/lan.env"
|
||||
REQUESTED_ADB_PORT="${ANDROID_ADB_SERVER_PORT:-}"
|
||||
[[ -f "${LAN_ENV}" ]] && source "${LAN_ENV}" || true
|
||||
[[ -n "${REQUESTED_ADB_PORT}" ]] && export ANDROID_ADB_SERVER_PORT="${REQUESTED_ADB_PORT}"
|
||||
SDK_PORT="${WORKPHONE_SDK_PORT:-8899}"
|
||||
INTERVAL="${KEEPER_INTERVAL:-15}"
|
||||
FAST_INTERVAL="${KEEPER_FAST_INTERVAL:-8}"
|
||||
DEVICE_SERIAL="${DEVICE_SERIAL:-}"
|
||||
DEVICE_ID="${DEVICE_ID:-xgfe65eimrrofyws}"
|
||||
export ANDROID_ADB_SERVER_PORT="${ANDROID_ADB_SERVER_PORT:-5037}"
|
||||
ADB=(adb -P "${ANDROID_ADB_SERVER_PORT}")
|
||||
LOG="${SDK_ROOT}/logs/connection_keeper.log"
|
||||
PID_FILE="${SDK_ROOT}/logs/connection_keeper.pid"
|
||||
|
||||
mkdir -p "${SDK_ROOT}/logs"
|
||||
|
||||
log() { echo "[$(date '+%F %T')] $*" | tee -a "$LOG"; }
|
||||
|
||||
is_phone_serial() {
|
||||
local s="$1"
|
||||
local brand
|
||||
brand=$("${ADB[@]}" -s "$s" shell getprop ro.product.brand 2>/dev/null | tr -d '\r')
|
||||
[[ "$brand" != "haier" ]]
|
||||
}
|
||||
|
||||
pick_serial() {
|
||||
if [[ -n "$DEVICE_SERIAL" ]]; then
|
||||
if "${ADB[@]}" -s "$DEVICE_SERIAL" get-state 2>/dev/null | grep -qx device && is_phone_serial "$DEVICE_SERIAL"; then
|
||||
echo "$DEVICE_SERIAL"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
local s
|
||||
while IFS= read -r s; do
|
||||
[[ -z "$s" ]] && continue
|
||||
is_phone_serial "$s" || continue
|
||||
echo "$s"
|
||||
return
|
||||
done < <("${ADB[@]}" devices 2>/dev/null | awk '/\tdevice$/{print $1}')
|
||||
}
|
||||
|
||||
usb_xiaomi_present() {
|
||||
ioreg -p IOUSB -l 2>/dev/null | grep -q "Xiaomi" 2>/dev/null
|
||||
}
|
||||
|
||||
try_wireless_ports() {
|
||||
local host="${1:-192.168.1.12}"
|
||||
if usb_xiaomi_present; then
|
||||
log "USB Xiaomi detected → skip wireless port scan (优先等 MTP/授权)"
|
||||
return 1
|
||||
fi
|
||||
ping -c 1 -W 2 "$host" >/dev/null 2>&1 || return 1
|
||||
"${ADB[@]}" reconnect offline >/dev/null 2>&1 || true
|
||||
local p open_port="" hits=0
|
||||
for p in 5555 $(seq 37000 37005); do
|
||||
if nc -z -G 1 "$host" "$p" 2>/dev/null; then
|
||||
hits=$((hits + 1))
|
||||
[[ "$hits" -gt 3 ]] && break
|
||||
log "wireless port open ${host}:${p} → adb connect"
|
||||
( "${ADB[@]}" connect "${host}:${p}" & pid=$!; sleep 3; kill $pid 2>/dev/null; wait $pid 2>/dev/null ) || true
|
||||
if "${ADB[@]}" -s "${host}:${p}" get-state 2>/dev/null | grep -qx device; then
|
||||
DEVICE_SERIAL="${host}:${p}"
|
||||
echo "$DEVICE_SERIAL"
|
||||
return 0
|
||||
fi
|
||||
"${ADB[@]}" disconnect "${host}:${p}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
ws_online_count() {
|
||||
curl -s "http://127.0.0.1:${SDK_PORT}/api/v3/connection/status" 2>/dev/null | \
|
||||
python3 -c "import sys,json;d=json.load(sys.stdin).get('data',{});print(d.get('online_ws_count',0))" 2>/dev/null || echo 0
|
||||
}
|
||||
|
||||
u2_online() {
|
||||
curl -s "http://127.0.0.1:${SDK_PORT}/api/v3/devices/${DEVICE_ID}" 2>/dev/null | \
|
||||
python3 -c "import sys,json;d=json.load(sys.stdin).get('data',{});print('1' if d.get('quick_status',{}).get('u2') else '0')" 2>/dev/null || echo 0
|
||||
}
|
||||
|
||||
ensure_adb_server() {
|
||||
if nc -z 127.0.0.1 "${ANDROID_ADB_SERVER_PORT}" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
"${ADB[@]}" start-server 2>/dev/null || true
|
||||
sleep 1
|
||||
}
|
||||
|
||||
adb_devices_safe() {
|
||||
( "${ADB[@]}" devices "$@" & pid=$!; sleep 8; kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null ) 2>/dev/null || true
|
||||
}
|
||||
|
||||
keeper_once() {
|
||||
ensure_adb_server
|
||||
|
||||
ws="$(ws_online_count)"
|
||||
u2_ok="$(u2_online)"
|
||||
if [[ "$ws" -ge 1 && "$u2_ok" == "1" ]]; then
|
||||
log "ok ws_online=${ws} u2=1"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$ws" -ge 1 ]]; then
|
||||
log "ws_online=${ws} but u2=${u2_ok} → continue adb/u2 recovery"
|
||||
fi
|
||||
|
||||
serial="$(pick_serial || true)"
|
||||
if [[ -z "$serial" ]]; then
|
||||
if usb_xiaomi_present; then
|
||||
log "USB 已插入但未 adb device → 请在手机选「传输文件」并允许 USB 调试;或开启无线调试"
|
||||
# USB 未授权时仍尝试无线 ADB(与 USB 并行)
|
||||
for ip in ${WIRELESS_IPS:-192.168.110.80 192.168.3.1}; do
|
||||
if serial="$(try_wireless_ports "$ip" || true)" && [[ -n "$serial" ]]; then
|
||||
log "wireless adb ok ${serial}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
serial="$(pick_serial || true)"
|
||||
else
|
||||
for ip in ${WIRELESS_IPS:-192.168.110.80 192.168.110.60 192.168.110.197 192.168.3.1 192.168.1.12}; do
|
||||
if serial="$(try_wireless_ports "$ip" || true)" && [[ -n "$serial" ]]; then
|
||||
break
|
||||
fi
|
||||
( "${ADB[@]}" connect "${ip}:5555" & pid=$!; sleep 3; kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null ) || true
|
||||
done
|
||||
serial="$(pick_serial || true)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$serial" ]]; then
|
||||
log "no adb device — 若手机 Termux Agent 已配置 NAS/frp 寻服,可忽略;否则 USB 授权一次做 OTA"
|
||||
bash "${SCRIPT_DIR}/sdk_connection_watcher.sh" --once >>"$LOG" 2>&1 || true
|
||||
ws="$(ws_online_count)"
|
||||
[[ "$ws" -ge 1 ]] && log "sdk_watcher recovered ws=${ws}" && return 0
|
||||
return 1
|
||||
fi
|
||||
|
||||
state="$("${ADB[@]}" -s "$serial" get-state 2>/dev/null || echo offline)"
|
||||
if [[ "$state" == "unauthorized" ]]; then
|
||||
log "adb unauthorized serial=${serial} → 请在手机点「允许 USB 调试」"
|
||||
return 1
|
||||
fi
|
||||
if [[ "$state" != "device" ]]; then
|
||||
log "adb not ready serial=${serial} state=${state}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
ws="$(ws_online_count)"
|
||||
if [[ "$ws" -ge 1 ]]; then
|
||||
log "ok serial=${serial} ws_online=${ws}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "adb ok but ws=0 → termux/on-device agent serial=${serial}"
|
||||
DEVICE_SERIAL="$serial" DEVICE_ID="${DEVICE_ID:-xgfe65eimrrofyws}" \
|
||||
bash "${SCRIPT_DIR}/termux_push_and_start_agent.sh" >>"$LOG" 2>&1 || true
|
||||
ws="$(ws_online_count)"
|
||||
if [[ "$ws" -ge 1 ]]; then
|
||||
log "termux/oneclick ok ws_online=${ws}"
|
||||
return 0
|
||||
fi
|
||||
log "still ws=0 after termux_push"
|
||||
return 1
|
||||
}
|
||||
|
||||
keeper_loop() {
|
||||
log "connection_keeper start interval=${INTERVAL}s fast=${FAST_INTERVAL}s port=${SDK_PORT} lan=${WORKPHONE_LAN_IP:-auto}"
|
||||
while true; do
|
||||
if keeper_once; then
|
||||
sleep "$INTERVAL"
|
||||
else
|
||||
sleep "$FAST_INTERVAL"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
--once)
|
||||
keeper_once
|
||||
;;
|
||||
--bg)
|
||||
if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
||||
echo "已在运行 PID $(cat "$PID_FILE")"
|
||||
exit 0
|
||||
fi
|
||||
nohup bash "$0" >>"$LOG" 2>&1 &
|
||||
echo $! >"$PID_FILE"
|
||||
echo "connection_keeper 后台 PID $(cat "$PID_FILE") log=$LOG"
|
||||
;;
|
||||
--stop)
|
||||
if [[ -f "$PID_FILE" ]]; then
|
||||
kill "$(cat "$PID_FILE")" 2>/dev/null || true
|
||||
rm -f "$PID_FILE"
|
||||
echo "已停止"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
keeper_loop
|
||||
;;
|
||||
esac
|
||||
48
sdk/scripts/deploy_baota_wpsdk.sh
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
TARGET="${TARGET:-cunkebao}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-/www/wwwroot/workphone-sdk}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SDK_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
ssh "$TARGET" "mkdir -p '$REMOTE_DIR/app' '$REMOTE_DIR/agent' '$REMOTE_DIR/deploy/baota'"
|
||||
|
||||
rsync -az --delete \
|
||||
--exclude '__pycache__' --exclude '*.pyc' --exclude '*.log' \
|
||||
--exclude 'data/cunke_bao_config.json' --exclude 'data/hook/events.jsonl' \
|
||||
--exclude 'data/operation_logs' --exclude 'agent/config.json' \
|
||||
"$SDK_ROOT/app/" "$TARGET:$REMOTE_DIR/app/"
|
||||
|
||||
rsync -az --delete \
|
||||
--exclude '__pycache__' --exclude '*.pyc' --exclude '*.log' \
|
||||
--exclude 'config.json' --exclude 'config.*.json' \
|
||||
"$SDK_ROOT/agent/" "$TARGET:$REMOTE_DIR/agent/"
|
||||
|
||||
rsync -az \
|
||||
"$SDK_ROOT/Dockerfile" "$SDK_ROOT/.dockerignore" "$SDK_ROOT/requirements.txt" \
|
||||
"$SDK_ROOT/docker-compose.baota.yml" "$TARGET:$REMOTE_DIR/"
|
||||
rsync -az "$SDK_ROOT/deploy/baota/wpsdk.quwanzhi.com.conf" "$TARGET:$REMOTE_DIR/deploy/baota/"
|
||||
|
||||
ssh "$TARGET" "REMOTE_DIR='$REMOTE_DIR' bash -s" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
cd "$REMOTE_DIR"
|
||||
if [[ ! -f .env ]]; then
|
||||
umask 077
|
||||
mongo_password="$(openssl rand -hex 24)"
|
||||
api_key="$(openssl rand -hex 32)"
|
||||
pairing_token="$(openssl rand -hex 32)"
|
||||
printf 'MONGO_ROOT_USERNAME=workphone\nMONGO_ROOT_PASSWORD=%s\nAPI_KEY=%s\nDEVICE_PAIRING_TOKEN=%s\nAI_BRAIN_ENABLED=false\nAI_BRAIN_API_URL=https://kr-ai.quwanzhi.com\nAI_BRAIN_API_KEY=\n' \
|
||||
"$mongo_password" "$api_key" "$pairing_token" > .env
|
||||
fi
|
||||
chmod 600 .env
|
||||
docker compose -f docker-compose.baota.yml up -d --build
|
||||
install -d -m 755 /www/server/panel/vhost/cert/wpsdk.quwanzhi.com
|
||||
install -m 644 /root/.acme.sh/quwanzhi.com_ecc/fullchain.cer /www/server/panel/vhost/cert/wpsdk.quwanzhi.com/fullchain.pem
|
||||
install -m 600 /root/.acme.sh/quwanzhi.com_ecc/quwanzhi.com.key /www/server/panel/vhost/cert/wpsdk.quwanzhi.com/privkey.pem
|
||||
install -m 644 deploy/baota/wpsdk.quwanzhi.com.conf /www/server/panel/vhost/nginx/wpsdk.quwanzhi.com.conf
|
||||
/www/server/nginx/sbin/nginx -t
|
||||
/www/server/nginx/sbin/nginx -s reload
|
||||
REMOTE
|
||||
|
||||
echo "部署完成:https://wpsdk.quwanzhi.com/health"
|
||||
@@ -47,6 +47,10 @@ ssh "${NAS_USER}@${NAS_HOST}" "mkdir -p '${NAS_DIR}'"
|
||||
# 严格排除非镜像构建所需目录(.runtime 含 46w+ 缓存文件,android-app/typescript-sdk/php-sdk 不进镜像)
|
||||
rsync -avz \
|
||||
--exclude '.git' \
|
||||
--exclude '.env' \
|
||||
--exclude '.env.*' \
|
||||
--exclude '*secret*' \
|
||||
--exclude '*credential*' \
|
||||
--exclude '__pycache__' \
|
||||
--exclude '*.pyc' \
|
||||
--exclude 'node_modules' \
|
||||
|
||||
@@ -7,6 +7,7 @@ SDK_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
DEVICE_SERIAL="${DEVICE_SERIAL:-192.168.110.80:5555}"
|
||||
SDK_PORT="${SDK_PORT:-8899}"
|
||||
PH_CFG="${SDK_ROOT}/scripts/anti_detect/phantom_frida_config.json"
|
||||
PH_KEEPALIVE="${SDK_ROOT}/scripts/phantom_frida_keepalive.sh"
|
||||
LOG="/tmp/wp_agent_ws.log"
|
||||
|
||||
if [[ ! -f "$PH_CFG" ]]; then
|
||||
@@ -15,6 +16,11 @@ if [[ ! -f "$PH_CFG" ]]; then
|
||||
fi
|
||||
|
||||
PH_PORT=$(python3 -c "import json; print(json.load(open('${PH_CFG}'))['listen_port'])")
|
||||
# 只允许一个读同一 phantom 配置的保活器,避免 Frida 重启后回到默认/随机端口。
|
||||
if ! pgrep -f "${PH_KEEPALIVE} ${DEVICE_SERIAL}" >/dev/null 2>&1; then
|
||||
nohup bash "${PH_KEEPALIVE}" "${DEVICE_SERIAL}" \
|
||||
>> /tmp/phantom_frida_keepalive.log 2>&1 &
|
||||
fi
|
||||
adb -s "${DEVICE_SERIAL}" forward "tcp:${PH_PORT}" "tcp:${PH_PORT}" >/dev/null 2>&1 || true
|
||||
|
||||
pkill -f "agent.py -d ${DEVICE_SERIAL} -s ws://127.0.0.1:${SDK_PORT}" 2>/dev/null || true
|
||||
|
||||
37
sdk/scripts/ensure_ws_agent_wireless.sh
Executable file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# 无线主控:设备端 Termux Agent + 本机 Frida,无需 Mac ADB
|
||||
# 用法: bash ensure_ws_agent_wireless.sh [SDK_HOST:PORT] [device_id]
|
||||
set -euo pipefail
|
||||
SDK_HOST="${1:-127.0.0.1:8899}"
|
||||
DEVICE_ID="${2:-xgfe65eimrrofyws}"
|
||||
WS_URL="ws://${SDK_HOST}/ws/device/${DEVICE_ID}"
|
||||
|
||||
echo "================================================================"
|
||||
echo " 工作手机 · 无线主控(WebSocket + 设备本机 Frida)"
|
||||
echo " 不需要 Mac USB ADB;execute/probe 全走 WS"
|
||||
echo "================================================================"
|
||||
echo ""
|
||||
echo "【请在红米 Termux 执行】"
|
||||
echo ""
|
||||
FRIDA_PORT="$(python3 -c "import json; print(json.load(open('$(dirname "$0")/anti_detect/phantom_frida_config.json'))['listen_port'])" 2>/dev/null || echo 17263)"
|
||||
cat <<EOF
|
||||
# 1) 确保 frida-server 在手机本机运行(Root,phantom 端口见 phantom_frida_config.json)
|
||||
# 2) 启动 Agent(连 SDK):
|
||||
export WP_AGENT_ON_DEVICE=1
|
||||
export WP_FRIDA_MODE=remote
|
||||
export WP_FRIDA_PORT=${FRIDA_PORT}
|
||||
export WP_AUTO_DISCOVER=1
|
||||
cd ~/workphone/agent # 或你同步 agent 目录的路径
|
||||
python agent.py -d ${DEVICE_ID} --heartbeat 10
|
||||
# 可选手填: -s ${WS_URL}
|
||||
EOF
|
||||
echo ""
|
||||
echo "【Mac 端仅保留 SDK】"
|
||||
echo " curl http://${SDK_HOST}/api/v3/connection/status"
|
||||
echo " curl http://${SDK_HOST}/api/v3/hook/probe/${DEVICE_ID}"
|
||||
echo ""
|
||||
echo "【勿再使用】Mac 上 ensure_ws_agent.sh(需 ADB forward,与无线口径冲突)"
|
||||
echo ""
|
||||
# 可选:停掉 Mac 侧假 Agent
|
||||
pkill -f "agent.py -d ${DEVICE_ID} -s ws://" 2>/dev/null || true
|
||||
curl -s "http://${SDK_HOST}/api/v3/connection/status" | python3 -m json.tool 2>/dev/null | head -20 || true
|
||||
152
sdk/scripts/extract_aochuang_to_ziliao.sh
Normal file
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env bash
|
||||
# 奥创工作手机 · 真机全量提取到 资料/奥创工作手机APK提取/
|
||||
# 真源:资料/奥创微信控制接口与插件提取复用指南.md · 机擎/阿服/奥创真机清机/SKILL.md
|
||||
#
|
||||
# 用法:
|
||||
# bash sdk/scripts/extract_aochuang_to_ziliao.sh [-s SERIAL] [--decompile]
|
||||
#
|
||||
# 前置:Type-C USB 调试 + adb devices 见 device(或无线 adb connect)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
OUT="${ROOT}/资料/奥创工作手机APK提取"
|
||||
EVID="${ROOT}/开发文档/8、部署/05-测试验收/$(date +%Y%m%d)_奥创资料提取_$(date +%H%M%S)"
|
||||
SERIAL=""
|
||||
DECOMPILE=0
|
||||
DEFAULT_SERIAL="xgfe65eimrrofyws"
|
||||
|
||||
AOCHUANG_PACKAGES=(
|
||||
"org.xeslciw.manager"
|
||||
"top.zzz.vivwxjz"
|
||||
"uni.UNI9421F6C"
|
||||
)
|
||||
|
||||
AOCHUANG_ROOT_PATHS=(
|
||||
"/data/adb/xesd"
|
||||
"/data/adb/modules"
|
||||
)
|
||||
|
||||
log() { printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*"; }
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
奥创真机全量提取 → 资料/奥创工作手机APK提取/
|
||||
|
||||
bash sdk/scripts/extract_aochuang_to_ziliao.sh [-s SERIAL] [--decompile]
|
||||
|
||||
--decompile 额外 apktool 反编译到 decompiled/<包名>/
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-s) SERIAL="$2"; shift 2 ;;
|
||||
--decompile) DECOMPILE=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "未知参数: $1"; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
pick_serial() {
|
||||
if [[ -n "$SERIAL" ]]; then return; fi
|
||||
local online
|
||||
online="$(adb devices | awk '/\tdevice$/{print $1; exit}')"
|
||||
if [[ -n "$online" ]]; then SERIAL="$online"; return; fi
|
||||
if adb devices | grep -q "^${DEFAULT_SERIAL}[[:space:]]"; then
|
||||
SERIAL="$DEFAULT_SERIAL"
|
||||
fi
|
||||
}
|
||||
|
||||
run_adb() { adb -s "$SERIAL" "$@"; }
|
||||
run_sh() { run_adb shell "$@"; }
|
||||
|
||||
pick_su() {
|
||||
run_sh 'for p in /sbin/su /system/bin/su /system/xbin/su su; do
|
||||
if [ -x "$p" ] 2>/dev/null || command -v "$p" >/dev/null 2>&1; then echo "$p"; exit 0; fi
|
||||
done; exit 1' 2>/dev/null || true
|
||||
}
|
||||
|
||||
run_root() {
|
||||
local cmd="$1"
|
||||
local su
|
||||
su="$(pick_su)"
|
||||
if [[ -n "$su" ]]; then
|
||||
run_sh "su -c $(printf '%q' "$cmd")" 2>/dev/null
|
||||
else
|
||||
run_sh "$cmd" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
pick_serial
|
||||
if [[ -z "$SERIAL" ]] || ! adb devices | grep -q "^${SERIAL}[[:space:]]*device"; then
|
||||
echo "FAIL: 无 ADB 在线设备。请 Type-C 连接并开启 USB 调试,或 adb connect <ip>:5555"
|
||||
adb devices -l
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$OUT" "$EVID"
|
||||
log "serial=$SERIAL out=$OUT"
|
||||
|
||||
# --- 设备画像 ---
|
||||
{
|
||||
echo "# 奥创提取 · 设备检查 $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo
|
||||
adb devices -l
|
||||
echo
|
||||
run_adb shell getprop ro.product.model
|
||||
run_adb shell getprop ro.build.version.release
|
||||
run_adb shell getprop ro.product.brand
|
||||
} > "$EVID/00_device_info.txt"
|
||||
|
||||
run_sh pm list packages > "$EVID/01_all_packages.txt" 2>/dev/null || true
|
||||
grep -E 'xes|zzz|vivwx|UNI9421|cloudservice|magisk|tencent\.mm' "$EVID/01_all_packages.txt" > "$EVID/02_aochuang_related_packages.txt" 2>/dev/null || true
|
||||
|
||||
# --- 拉 APK ---
|
||||
for pkg in "${AOCHUANG_PACKAGES[@]}"; do
|
||||
apk_path="$(run_sh pm path "$pkg" 2>/dev/null | head -1 | cut -d: -f2 | tr -d '\r' || true)"
|
||||
if [[ -z "$apk_path" ]]; then
|
||||
log "SKIP APK 未安装: $pkg"
|
||||
echo "NOT_INSTALLED $pkg" >> "$EVID/03_apk_pull.log"
|
||||
continue
|
||||
fi
|
||||
ver="$(run_sh dumpsys package "$pkg" 2>/dev/null | awk -F= '/versionName/{print $2; exit}' | tr -d '\r ')"
|
||||
safe="${pkg}_v${ver:-unknown}.apk"
|
||||
log "PULL $pkg -> $safe"
|
||||
run_adb pull "$apk_path" "$OUT/$safe"
|
||||
echo "OK $pkg $apk_path -> $OUT/$safe" >> "$EVID/03_apk_pull.log"
|
||||
done
|
||||
|
||||
# --- 框架 / Magisk 模块(需 root)---
|
||||
for p in "${AOCHUANG_ROOT_PATHS[@]}"; do
|
||||
dest="$OUT/device_root$(echo "$p" | tr '/' '_')"
|
||||
log "TAR $p (root)"
|
||||
run_root "test -e '$p' && tar -czf /sdcard/aochuang_extract_$(basename "$p").tgz '$p' 2>/dev/null" || true
|
||||
tgz="/sdcard/aochuang_extract_$(basename "$p").tgz"
|
||||
if run_sh "test -f '$tgz'" 2>/dev/null | grep -q .; then
|
||||
mkdir -p "$dest"
|
||||
run_adb pull "$tgz" "$dest/" 2>/dev/null || true
|
||||
run_root "rm -f '$tgz'" || true
|
||||
else
|
||||
echo "MISSING_OR_DENIED $p" >> "$EVID/04_root_paths.log"
|
||||
fi
|
||||
done
|
||||
|
||||
# Magisk 模块名过滤
|
||||
run_root 'ls /data/adb/modules 2>/dev/null' > "$EVID/05_magisk_modules.txt" 2>/dev/null || true
|
||||
|
||||
# --- 可选反编译 ---
|
||||
if [[ "$DECOMPILE" -eq 1 ]] && command -v apktool >/dev/null 2>&1; then
|
||||
DEC="$OUT/decompiled"
|
||||
mkdir -p "$DEC"
|
||||
for apk in "$OUT"/*.apk; do
|
||||
[[ -f "$apk" ]] || continue
|
||||
base="$(basename "$apk" .apk)"
|
||||
log "apktool d $base"
|
||||
apktool d -f -o "$DEC/$base" "$apk" >> "$EVID/06_apktool.log" 2>&1 || true
|
||||
done
|
||||
fi
|
||||
|
||||
log "DONE 证据目录: $EVID"
|
||||
log "资料目录: $OUT"
|
||||
ls -lah "$OUT"
|
||||
63
sdk/scripts/install_connection_keeper_launchagent.sh
Executable file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# 安装 Mac 常驻「连接守护」LaunchAgent(每 15s 扫描 ADB + 自动拉起 Termux Agent)
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SDK_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
PROJ_ROOT="$(cd "${SDK_ROOT}/.." && pwd)"
|
||||
LABEL="com.karuo.workphone.connection-keeper"
|
||||
PLIST="${HOME}/Library/LaunchAgents/${LABEL}.plist"
|
||||
LOG="${SDK_ROOT}/logs/connection_keeper.log"
|
||||
|
||||
mkdir -p "${SDK_ROOT}/logs" "${HOME}/Library/LaunchAgents"
|
||||
|
||||
cat > "${PLIST}" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>${LABEL}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/bash</string>
|
||||
<string>${SDK_ROOT}/scripts/connection_keeper_daemon.sh</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>${PROJ_ROOT}</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||
<key>ANDROID_ADB_SERVER_PORT</key>
|
||||
<string>5038</string>
|
||||
<key>WORKPHONE_SDK_PORT</key>
|
||||
<string>8899</string>
|
||||
<key>KEEPER_INTERVAL</key>
|
||||
<string>15</string>
|
||||
<key>DEVICE_SERIAL</key>
|
||||
<string>192.168.110.80:5555</string>
|
||||
<key>DEVICE_ID</key>
|
||||
<string>xgfe65eimrrofyws</string>
|
||||
</dict>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>${LOG}</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${LOG}</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
launchctl bootout "gui/$(id -u)/${LABEL}" 2>/dev/null || true
|
||||
launchctl bootstrap "gui/$(id -u)" "${PLIST}"
|
||||
launchctl enable "gui/$(id -u)/${LABEL}"
|
||||
launchctl kickstart -k "gui/$(id -u)/${LABEL}" 2>/dev/null || \
|
||||
bash "${SDK_ROOT}/scripts/connection_keeper_daemon.sh" --bg
|
||||
|
||||
echo "✅ 连接守护 LaunchAgent 已安装: ${LABEL}"
|
||||
echo " 日志: ${LOG}"
|
||||
echo " 停止: bash ${SDK_ROOT}/scripts/connection_keeper_daemon.sh --stop"
|
||||
57
sdk/scripts/install_sdk_watcher_launchagent.sh
Executable file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# 安装 Mac 常驻「SDK 连接监视器」(无 USB 铁律 · 只盯 NAS/frp/WS)
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SDK_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
PROJ_ROOT="$(cd "${SDK_ROOT}/.." && pwd)"
|
||||
LABEL="com.karuo.workphone.sdk-watcher"
|
||||
PLIST="${HOME}/Library/LaunchAgents/${LABEL}.plist"
|
||||
LOG="${SDK_ROOT}/logs/sdk_connection_watcher.log"
|
||||
|
||||
mkdir -p "${SDK_ROOT}/logs" "${HOME}/Library/LaunchAgents"
|
||||
|
||||
cat > "${PLIST}" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>${LABEL}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/bash</string>
|
||||
<string>${SDK_ROOT}/scripts/sdk_connection_watcher.sh</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>${PROJ_ROOT}</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>WORKPHONE_SDK_PORT</key>
|
||||
<string>8899</string>
|
||||
<key>DEVICE_ID</key>
|
||||
<string>xgfe65eimrrofyws</string>
|
||||
<key>WATCHER_INTERVAL</key>
|
||||
<string>20</string>
|
||||
</dict>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>${LOG}</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${LOG}</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
launchctl bootout "gui/$(id -u)/${LABEL}" 2>/dev/null || true
|
||||
launchctl bootstrap "gui/$(id -u)" "${PLIST}"
|
||||
launchctl enable "gui/$(id -u)/${LABEL}"
|
||||
launchctl kickstart -k "gui/$(id -u)/${LABEL}" 2>/dev/null || \
|
||||
bash "${SDK_ROOT}/scripts/sdk_connection_watcher.sh" --bg
|
||||
|
||||
echo "✅ SDK 监视器 LaunchAgent 已安装: ${LABEL}"
|
||||
echo " 日志: ${LOG}"
|
||||
echo " 当前 SDK: source ${SDK_ROOT}/config/active_sdk.env"
|
||||
165
sdk/scripts/lan_connect_setup.sh
Executable file
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env bash
|
||||
# 局域网无线主控:本机局网 IP ↔ 手机同 WiFi ↔ Termux Agent / 无线 ADB
|
||||
# 用法:
|
||||
# bash sdk/scripts/lan_connect_setup.sh
|
||||
# PHONE_IP=192.168.110.40 bash sdk/scripts/lan_connect_setup.sh
|
||||
# bash sdk/scripts/lan_connect_setup.sh --connect # 尝试 adb connect 后 oneclick
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SDK_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
SDK_PORT="${WORKPHONE_SDK_PORT:-8899}"
|
||||
DEVICE_ID="${DEVICE_ID:-xgfe65eimrrofyws}"
|
||||
PHONE_IP="${PHONE_IP:-}"
|
||||
ADB_PORT="${ANDROID_ADB_SERVER_PORT:-5038}"
|
||||
ADB=(adb -P "${ADB_PORT}")
|
||||
export ANDROID_ADB_SERVER_PORT="${ADB_PORT}"
|
||||
LAN_ENV="${SDK_ROOT}/config/lan.env"
|
||||
DO_CONNECT=0
|
||||
|
||||
[[ "${1:-}" == "--connect" ]] && DO_CONNECT=1
|
||||
|
||||
pick_lan_ip() {
|
||||
local ip
|
||||
for ip in $(ifconfig | awk '/inet /{print $2}' | grep -v '^127\.'); do
|
||||
[[ "$ip" == 198.18.* ]] && continue
|
||||
[[ "$ip" == 192.168.110.* ]] && { echo "$ip"; return; }
|
||||
done
|
||||
for ip in $(ifconfig | awk '/inet /{print $2}' | grep -v '^127\.'); do
|
||||
[[ "$ip" == 198.18.* ]] && continue
|
||||
[[ "$ip" == 192.168.* ]] && { echo "$ip"; return; }
|
||||
done
|
||||
ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "127.0.0.1"
|
||||
}
|
||||
|
||||
LAN_IP="$(pick_lan_ip)"
|
||||
WS_URL="ws://${LAN_IP}:${SDK_PORT}/ws/device/${DEVICE_ID}"
|
||||
API_URL="http://${LAN_IP}:${SDK_PORT}"
|
||||
|
||||
log() { echo "[lan] $*"; }
|
||||
|
||||
log "本机局网 IP: ${LAN_IP}"
|
||||
log "SDK API: ${API_URL}"
|
||||
log "Agent WS: ${WS_URL}"
|
||||
|
||||
mkdir -p "${SDK_ROOT}/config"
|
||||
cat >"${LAN_ENV}" <<EOF
|
||||
# 自动生成 $(date '+%F %T') — source 后用于脚本
|
||||
export WORKPHONE_LAN_IP=${LAN_IP}
|
||||
export WORKPHONE_SDK_HOST=${LAN_IP}:${SDK_PORT}
|
||||
export WORKPHONE_WS_URL=${WS_URL}
|
||||
export WORKPHONE_API_URL=${API_URL}
|
||||
export ANDROID_ADB_SERVER_PORT=${ADB_PORT}
|
||||
export WIRELESS_IPS="${PHONE_IP:-192.168.110.40 192.168.110.80 192.168.110.236}"
|
||||
EOF
|
||||
log "已写入 ${LAN_ENV}"
|
||||
|
||||
# ── 1) SDK 本机 / 局网可达性 ──
|
||||
"${ADB[@]}" start-server >/dev/null 2>&1 || true
|
||||
|
||||
SDK_VIA="local"
|
||||
if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx workphone-sdk; then
|
||||
SDK_VIA="docker"
|
||||
log "SDK 由 Docker workphone-sdk 提供 :${SDK_PORT}"
|
||||
if ! curl -sf --max-time 5 "http://127.0.0.1:${SDK_PORT}/health" >/dev/null 2>&1; then
|
||||
log "Docker SDK 未响应,尝试 adb 桥接后重启容器…"
|
||||
bash "${SCRIPT_DIR}/adb_host_server_listen_all.sh" --background || true
|
||||
(cd "${SDK_ROOT}" && ADB_SERVER_SOCKET="tcp:host.docker.internal:${ADB_PORT}" docker compose up -d workphone-sdk) || true
|
||||
sleep 4
|
||||
fi
|
||||
elif curl -sf --max-time 3 "http://127.0.0.1:${SDK_PORT}/health" >/dev/null 2>&1; then
|
||||
log "SDK localhost:${SDK_PORT} ✅ (${SDK_VIA})"
|
||||
else
|
||||
log "SDK 未运行 → 启动 uvicorn 0.0.0.0:${SDK_PORT}"
|
||||
cd "${SDK_ROOT}/app"
|
||||
nohup env ANDROID_ADB_SERVER_PORT="${ADB_PORT}" python3 -m uvicorn main:app \
|
||||
--host 0.0.0.0 --port "${SDK_PORT}" >>"${SDK_ROOT}/logs/sdk.log" 2>&1 &
|
||||
sleep 3
|
||||
fi
|
||||
|
||||
if [[ "$SDK_VIA" == "docker" ]]; then
|
||||
bash "${SCRIPT_DIR}/adb_host_server_listen_all.sh" --background || true
|
||||
fi
|
||||
|
||||
if curl -sf --max-time 5 "${API_URL}/health" >/dev/null 2>&1; then
|
||||
log "SDK 局网 ${API_URL}/health ✅"
|
||||
else
|
||||
log "⚠️ 局网 ${API_URL} 不可达 — 检查 Mac 防火墙是否放行 ${SDK_PORT},或确认手机与 Mac 同网段"
|
||||
fi
|
||||
|
||||
# ── 2) 探测手机(同网段 5555)──
|
||||
try_adb_connect() {
|
||||
local host="$1" port="${2:-5555}"
|
||||
local target="${host}:${port}"
|
||||
"${ADB[@]}" connect "${target}" >/dev/null 2>&1 || true
|
||||
sleep 2
|
||||
if "${ADB[@]}" -s "${target}" get-state 2>/dev/null | grep -qx device; then
|
||||
printf '%s' "${target}"
|
||||
return 0
|
||||
fi
|
||||
"${ADB[@]}" disconnect "${target}" >/dev/null 2>&1 || true
|
||||
return 1
|
||||
}
|
||||
|
||||
FOUND=""
|
||||
ADB_SERIAL=""
|
||||
if [[ -n "$PHONE_IP" ]]; then
|
||||
if try_adb_connect "$PHONE_IP" >/dev/null; then
|
||||
ADB_SERIAL="${PHONE_IP}:5555"
|
||||
FOUND="${ADB_SERIAL}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$FOUND" ]]; then
|
||||
for ip in 192.168.110.40 192.168.110.80 192.168.110.236 192.168.1.12; do
|
||||
ping -c 1 -W 1 "$ip" >/dev/null 2>&1 || continue
|
||||
if try_adb_connect "$ip" >/dev/null; then
|
||||
ADB_SERIAL="${ip}:5555"
|
||||
FOUND="${ADB_SERIAL}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -n "$FOUND" ]]; then
|
||||
log "无线 ADB 已连接: ${FOUND} ✅"
|
||||
else
|
||||
log "暂未发现无线 ADB(手机需:开发者选项 → 无线调试 / 或 USB 执行一次 adb tcpip 5555)"
|
||||
fi
|
||||
|
||||
# ── 3) Termux 手机端命令(手机连本机 IP)──
|
||||
FRIDA_PORT="$(python3 -c "import json; print(json.load(open('${SCRIPT_DIR}/anti_detect/phantom_frida_config.json'))['listen_port'])" 2>/dev/null || echo 17263)"
|
||||
|
||||
echo ""
|
||||
echo "================================================================"
|
||||
echo " 手机 Termux 执行(与 Mac 同一 WiFi · 连 ${LAN_IP})"
|
||||
echo "================================================================"
|
||||
cat <<EOF
|
||||
|
||||
export WP_AGENT_ON_DEVICE=1
|
||||
export WP_FRIDA_MODE=remote
|
||||
export WP_FRIDA_PORT=${FRIDA_PORT}
|
||||
export WP_SERVER_URL=${WS_URL}
|
||||
cd ~/workphone/agent || cd ~/storage/downloads/workphone/agent
|
||||
python agent.py -d ${DEVICE_ID} -s ${WS_URL} --heartbeat 10
|
||||
|
||||
# 或依赖 UDP 8898 自动发现(免手填 IP):
|
||||
# python agent.py -d ${DEVICE_ID} --heartbeat 10
|
||||
|
||||
EOF
|
||||
|
||||
echo "================================================================"
|
||||
echo " Mac 验收"
|
||||
echo "================================================================"
|
||||
echo " curl ${API_URL}/health"
|
||||
echo " curl ${API_URL}/api/v3/connection/status"
|
||||
echo " curl ${API_URL}/api/v3/hook/probe/${DEVICE_ID}"
|
||||
echo ""
|
||||
|
||||
if [[ "$DO_CONNECT" == "1" && -n "$ADB_SERIAL" ]]; then
|
||||
log "执行 oneclick device=${ADB_SERIAL}"
|
||||
DEVICE_SERIAL="$ADB_SERIAL" ANDROID_ADB_SERVER_PORT="${ADB_PORT}" \
|
||||
bash "${SCRIPT_DIR}/frida_workphone_oneclick.sh" -d "$ADB_SERIAL" || true
|
||||
fi
|
||||
|
||||
curl -s --max-time 5 "${API_URL}/api/v3/connection/status" 2>/dev/null | python3 -m json.tool 2>/dev/null | head -25 || true
|
||||
115
sdk/scripts/nas_primary_agent_setup.sh
Executable file
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env bash
|
||||
# 工作手机 Agent 连接顺序:① NAS 局域网 ② 本机 Docker ③ NAS 外网 frp
|
||||
# 用法: bash sdk/scripts/nas_primary_agent_setup.sh [device_id]
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SDK_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
DEVICE_ID="${1:-xgfe65eimrrofyws}"
|
||||
SDK_PORT="${WORKPHONE_SDK_PORT:-8899}"
|
||||
NAS_FRP_HOST="${WORKPHONE_NAS_FRP_HOST:-open.quwanzhi.com}"
|
||||
ADB_PORT="${ANDROID_ADB_SERVER_PORT:-5038}"
|
||||
ADB=(adb -P "${ADB_PORT}")
|
||||
export ANDROID_ADB_SERVER_PORT="${ADB_PORT}"
|
||||
|
||||
pick_mac_lan_ip() {
|
||||
local ip
|
||||
for ip in $(ifconfig 2>/dev/null | awk '/inet /{print $2}' | grep -v '^127\.'); do
|
||||
[[ "$ip" == 198.18.* ]] && continue
|
||||
[[ "$ip" == 192.168.110.* ]] && { echo "$ip"; return; }
|
||||
done
|
||||
for ip in $(ifconfig 2>/dev/null | awk '/inet /{print $2}' | grep -v '^127\.'); do
|
||||
[[ "$ip" == 198.18.* ]] && continue
|
||||
[[ "$ip" == 192.168.* ]] && { echo "$ip"; return; }
|
||||
done
|
||||
echo "127.0.0.1"
|
||||
}
|
||||
|
||||
health_ok() {
|
||||
curl -sf --max-time 3 "http://$1:${SDK_PORT}/health" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
MAC_IP="$(pick_mac_lan_ip)"
|
||||
|
||||
# ① NAS 局域网 → ② 本机 Docker → ③ NAS 外网
|
||||
PRIMARY_HOST=""
|
||||
PRIMARY_TIER="nas_lan"
|
||||
for h in 192.168.110.101 192.168.1.201; do
|
||||
if health_ok "$h"; then
|
||||
PRIMARY_HOST="$h"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -z "${PRIMARY_HOST}" && "${MAC_IP}" != "127.0.0.1" ]] && health_ok "${MAC_IP}"; then
|
||||
PRIMARY_HOST="${MAC_IP}"
|
||||
PRIMARY_TIER="mac_docker"
|
||||
fi
|
||||
if [[ -z "${PRIMARY_HOST}" ]] && health_ok "${NAS_FRP_HOST}"; then
|
||||
PRIMARY_HOST="${NAS_FRP_HOST}"
|
||||
PRIMARY_TIER="nas_frp"
|
||||
fi
|
||||
if [[ -z "${PRIMARY_HOST}" ]]; then
|
||||
PRIMARY_HOST="192.168.110.101"
|
||||
echo "[nas-primary] 警告: ①②③ 均不可达,server_url 仍写 NAS 局域网 ${PRIMARY_HOST}(Agent 将按序探测)"
|
||||
fi
|
||||
|
||||
PRIMARY_WS="ws://${PRIMARY_HOST}:${SDK_PORT}/ws/device"
|
||||
CONFIG_TMP="$(mktemp)"
|
||||
export DEVICE_ID PRIMARY_WS SDK_PORT MAC_IP NAS_FRP_HOST CONFIG_TMP
|
||||
python3 - <<'PY'
|
||||
import json, os
|
||||
|
||||
port = os.environ["SDK_PORT"]
|
||||
mac = os.environ["MAC_IP"]
|
||||
frp = os.environ["NAS_FRP_HOST"]
|
||||
primary = os.environ["PRIMARY_WS"]
|
||||
|
||||
# 固定回退顺序:NAS 内网 → 本机 Docker → NAS 外网
|
||||
ordered = [
|
||||
f"ws://192.168.110.101:{port}/ws/device",
|
||||
f"ws://192.168.1.201:{port}/ws/device",
|
||||
f"ws://{mac}:{port}/ws/device",
|
||||
f"ws://{frp}:{port}/ws/device",
|
||||
]
|
||||
pubs = []
|
||||
seen = {primary}
|
||||
for u in ordered:
|
||||
if u not in seen:
|
||||
seen.add(u)
|
||||
pubs.append(u)
|
||||
|
||||
cfg = {
|
||||
"device_id": os.environ["DEVICE_ID"],
|
||||
"server_url": primary,
|
||||
"public_servers": pubs,
|
||||
"heartbeat_interval": 10,
|
||||
"project_id": "cunkebao",
|
||||
"connection_priority": ["nas_lan", "mac_docker", "nas_frp"],
|
||||
}
|
||||
with open(os.environ["CONFIG_TMP"], "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
||||
PY
|
||||
|
||||
echo "[nas-primary] 连接顺序: ①NAS局域网 ②本机Docker ③NAS外网(${NAS_FRP_HOST})"
|
||||
echo "[nas-primary] 当前主服 tier=${PRIMARY_TIER} host=${PRIMARY_HOST}"
|
||||
echo "[nas-primary] server_url: ${PRIMARY_WS}/${DEVICE_ID}"
|
||||
cat "${CONFIG_TMP}"
|
||||
|
||||
REMOTE_CFG="/data/data/com.termux/files/home/workphone/agent/config.json"
|
||||
SERIAL="$("${ADB[@]}" devices 2>/dev/null | awk '/device$/{print $1; exit}')"
|
||||
PUBLIC_LIST="ws://192.168.110.101:${SDK_PORT}/ws/device,ws://192.168.1.201:${SDK_PORT}/ws/device,ws://${MAC_IP}:${SDK_PORT}/ws/device,ws://${NAS_FRP_HOST}:${SDK_PORT}/ws/device"
|
||||
|
||||
if [[ -n "${SERIAL}" ]]; then
|
||||
echo "[nas-primary] 推送 config → ${SERIAL}"
|
||||
"${ADB[@]}" -s "${SERIAL}" push "${CONFIG_TMP}" "${REMOTE_CFG}" 2>/dev/null || \
|
||||
"${ADB[@]}" -s "${SERIAL}" push "${CONFIG_TMP}" /sdcard/workphone_config.json
|
||||
echo "[nas-primary] Termux:"
|
||||
echo " export WP_SERVER_URL=${PRIMARY_WS}"
|
||||
echo " export WP_PUBLIC_SERVERS=${PUBLIC_LIST}"
|
||||
echo " export WP_AUTO_DISCOVER=1"
|
||||
echo " cd ~/workphone/agent && python agent.py -d ${DEVICE_ID}"
|
||||
else
|
||||
echo "[nas-primary] 未检测到 adb device → ${SDK_ROOT}/agent/config.nas-active.json"
|
||||
cp "${CONFIG_TMP}" "${SDK_ROOT}/agent/config.nas-active.json"
|
||||
fi
|
||||
rm -f "${CONFIG_TMP}"
|
||||
33
sdk/scripts/phantom_frida_keepalive.sh
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# phantom_frida_keepalive.sh — Phantom-Frida 保活守护(FS-6)
|
||||
#
|
||||
# 背景:微信 8.0.69 反检测会间歇 kill frida-server,导致 Hook 掉线、开发中断。
|
||||
# 本守护在 Mac 主机循环检查设备端 Phantom-Frida 进程,死了就用 Root 重起,
|
||||
# 并触发 Agent 重连(可选)。不依赖手机端 Termux。
|
||||
#
|
||||
# 用法:
|
||||
# nohup bash phantom_frida_keepalive.sh xgfe65eimrrofyws > logs/phantom_keepalive.log 2>&1 &
|
||||
# ==============================================================================
|
||||
set -uo pipefail
|
||||
|
||||
SERIAL="${1:-xgfe65eimrrofyws}"
|
||||
CFG="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/anti_detect/phantom_frida_config.json"
|
||||
BIN_PATH="$(python3 -c "import json;print(json.load(open('$CFG'))['binary_path'])" 2>/dev/null || echo /data/local/tmp/fs_8e6b0b)"
|
||||
BIN_NAME="$(basename "$BIN_PATH")"
|
||||
PORT="$(python3 -c "import json;print(json.load(open('$CFG'))['listen_port'])" 2>/dev/null || echo 24781)"
|
||||
INTERVAL="${KEEPALIVE_INTERVAL:-12}"
|
||||
|
||||
echo "[keepalive] serial=$SERIAL bin=$BIN_PATH port=$PORT (前台持有模式)"
|
||||
|
||||
# 前台持有模式:Mac 端阻塞运行 frida(adb shell 持有连接 → 子进程不收 SIGHUP)。
|
||||
# 实测 `su -c 'nohup X &'` 后台化在本机型失效,前台持有最可靠;frida 被杀则循环重连。
|
||||
while true; do
|
||||
# 清理可能的端口占用残留(旧 frida-server / TIME_WAIT 实例)
|
||||
adb -s "$SERIAL" shell "su -c 'pidof $BIN_NAME | xargs -r kill -9'" 2>/dev/null
|
||||
echo "[keepalive $(date '+%H:%M:%S')] 前台启动 Phantom-Frida..."
|
||||
# 阻塞:frida 活着则此行一直挂起;退出(被杀/崩溃)才返回
|
||||
adb -s "$SERIAL" shell "su -c '$BIN_PATH -l 0.0.0.0:$PORT'" 2>&1
|
||||
echo "[keepalive $(date '+%H:%M:%S')] Phantom-Frida 退出,${INTERVAL}s 后重连..."
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
125
sdk/scripts/run_extended_acceptance.sh
Executable file
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# run_extended_acceptance.sh — 微信 P0 + 四端 E2E + friend-add/AI chat 编排
|
||||
#
|
||||
# 真源:工作手机_微信全量控机与私域_20260529.md · 工作手机_存客宝四端对接_20260529.md
|
||||
# 用法:bash sdk/scripts/run_extended_acceptance.sh [device_id]
|
||||
# 环境:CUNKEBAO_API_KEY=xxx 可自动启用 CKB(须重启 SDK 后生效)
|
||||
# ==============================================================================
|
||||
set -o pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
DEVICE="${1:-${SDK_DEVICE_ID:-xgfe65eimrrofyws}}"
|
||||
BASE="${SDK_BASE_URL:-http://127.0.0.1:8899}"
|
||||
TS="$(date +%Y%m%d-%H%M%S)"
|
||||
EVID="$ROOT/开发文档/8、部署/05-测试验收/$(date +%Y%m%d)_扩展验收"
|
||||
mkdir -p "$EVID"
|
||||
REPORT="$EVID/extended_acceptance_${TS}.json"
|
||||
|
||||
PASS=0; BLOCKED=0; FAIL=0
|
||||
declare -a ROWS
|
||||
|
||||
note() { echo "$1"; ROWS+=("$2"); }
|
||||
|
||||
echo "=== 扩展验收 $TS === device=$DEVICE"
|
||||
|
||||
# 0. 设备 / Hook
|
||||
HOOK=$(curl -s --max-time 20 "$BASE/api/v3/hook/probe/$DEVICE" 2>/dev/null || echo '{}')
|
||||
SUPPORTS=$(echo "$HOOK" | python3 -c "import sys,json;print(json.load(sys.stdin).get('supports_hook'))" 2>/dev/null)
|
||||
if [ "$SUPPORTS" = "True" ]; then
|
||||
note " [PASS] hook probe supports_hook=true" "hook:PASS"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
note " [BLOCKED] hook offline(手机 Termux 跑 termux_frida_up_onphone.sh)" "hook:BLOCKED"
|
||||
BLOCKED=$((BLOCKED+1))
|
||||
fi
|
||||
|
||||
# 1. 微信三项 P0
|
||||
if bash "$ROOT/sdk/scripts/run_three_acceptance.sh" "$DEVICE" 2>&1 | tee "$EVID/three_${TS}.log" | tail -3 | grep -q '三项验收全绿'; then
|
||||
note " [PASS] 微信三项 P0 全绿" "three:PASS"
|
||||
PASS=$((PASS+1))
|
||||
elif [ "$SUPPORTS" != "True" ]; then
|
||||
note " [BLOCKED] 三项跳过(hook 离线)" "three:BLOCKED"
|
||||
BLOCKED=$((BLOCKED+1))
|
||||
else
|
||||
note " [FAIL] 微信三项未全绿" "three:FAIL"
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
|
||||
# 2. 四端 E2E(含写消息)
|
||||
FOUR_LOG="$EVID/4end_${TS}.log"
|
||||
if WP_WRITE=1 bash "$ROOT/开发文档/8、部署/05-测试验收/scripts/workphone_4end_e2e.sh" "$DEVICE" 2>&1 | tee "$FOUR_LOG" | grep -qE 'FAIL=0'; then
|
||||
note " [PASS] 四端 E2E FAIL=0" "4end:PASS"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
note " [FAIL] 四端 E2E 有失败项" "4end:FAIL"
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
# 3. friend-add ingest
|
||||
R=$(curl -s --max-time 30 -X POST "$BASE/api/v3/cunke-bao/hook/friend-add" -H 'Content-Type: application/json' \
|
||||
-d "{\"device_id\":\"$DEVICE\",\"wechat_id\":\"wxid_e2e_${TS}\",\"nickname\":\"E2E\",\"source\":\"extended_acceptance\"}")
|
||||
[ -z "$R" ] && sleep 2 && R=$(curl -s --max-time 30 -X POST "$BASE/api/v3/cunke-bao/hook/friend-add" -H 'Content-Type: application/json' \
|
||||
-d "{\"device_id\":\"$DEVICE\",\"wechat_id\":\"wxid_e2e_${TS}\",\"nickname\":\"E2E\",\"source\":\"extended_acceptance\"}")
|
||||
echo "$R" > "$EVID/friend_add_${TS}.json"
|
||||
CODE=$(echo "$R" | python3 -c "import sys,json;print(json.load(sys.stdin).get('code',''))" 2>/dev/null)
|
||||
if [ "$CODE" = "200" ]; then
|
||||
note " [PASS] friend-add → CKB code=200" "friend_add:PASS"
|
||||
PASS=$((PASS+1))
|
||||
elif echo "$R" | grep -qE '未启用|未配置|API Key'; then
|
||||
note " [BLOCKED] CKB 未启用(设 CUNKEBAO_API_KEY 并重启 SDK)" "friend_add:BLOCKED"
|
||||
BLOCKED=$((BLOCKED+1))
|
||||
else
|
||||
note " [FAIL] friend-add code=$CODE" "friend_add:FAIL"
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
# 4. AI chat(需卡若网关 + hook)
|
||||
AI=$(curl -s --max-time 90 -X POST "$BASE/api/v3/devices/$DEVICE/ai/chat" -H 'Content-Type: application/json' \
|
||||
-d "{\"instruction\":\"向文件传输助手发:AI扩展验收-$TS\",\"timeout\":60}" 2>/dev/null || echo '{}')
|
||||
echo "$AI" > "$EVID/ai_chat_${TS}.json"
|
||||
AI_STATUS=$(echo "$AI" | python3 -c "
|
||||
import sys,json,re
|
||||
raw=sys.stdin.read()
|
||||
try:
|
||||
d=json.loads(raw)
|
||||
except Exception:
|
||||
print('FAIL'); sys.exit(0)
|
||||
if d.get('code')==200 and (d.get('data') or {}).get('acted'):
|
||||
print('PASS')
|
||||
elif d.get('code')==200 and not (d.get('data') or {}).get('acted'):
|
||||
print('BLOCKED')
|
||||
else:
|
||||
blob=json.dumps(d,ensure_ascii=False)
|
||||
if re.search(r'429|502|503|401|无响应|RATE_LIMIT|Too Many|余额不足|gateway|Bad Gateway|Unauthorized|insufficient', blob, re.I):
|
||||
print('BLOCKED')
|
||||
else:
|
||||
print('FAIL')
|
||||
" 2>/dev/null)
|
||||
case "$AI_STATUS" in
|
||||
PASS) note " [PASS] AI chat 决策并执行" "ai_chat:PASS"; PASS=$((PASS+1)) ;;
|
||||
BLOCKED) note " [BLOCKED] AI 网关限流/502/余额不足" "ai_chat:BLOCKED"; BLOCKED=$((BLOCKED+1)) ;;
|
||||
*) note " [FAIL] AI chat 未执行" "ai_chat:FAIL"; FAIL=$((FAIL+1)) ;;
|
||||
esac
|
||||
|
||||
# 5. 静态审计
|
||||
if python3 "$ROOT/sdk/scripts/wechat_interface_audit.py" >/dev/null 2>&1; then
|
||||
note " [PASS] 128/174 静态对齐" "audit:PASS"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
note " [FAIL] 静态审计失败" "audit:FAIL"
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
python3 - "$REPORT" "$DEVICE" "$TS" "$PASS" "$BLOCKED" "$FAIL" "${ROWS[@]}" <<'PY'
|
||||
import json,sys
|
||||
report,device,ts,p,b,f,*rows=sys.argv[1:]
|
||||
data={"timestamp":ts,"device_id":device,"pass":int(p),"blocked":int(b),"fail":int(f),
|
||||
"results":[dict(zip(("item","status"),r.split(":",1))) for r in rows]}
|
||||
open(report,"w").write(json.dumps(data,ensure_ascii=False,indent=2))
|
||||
print(f"\n汇总 PASS={p} BLOCKED={b} FAIL={f} 报告 {report}")
|
||||
PY
|
||||
|
||||
echo "证据: $EVID"
|
||||
exit $([ "$FAIL" -eq 0 ] && echo 0 || echo 1)
|
||||
198
sdk/scripts/run_stability_longrun.mjs
Executable file
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
base: "http://127.0.0.1:8899",
|
||||
deviceId: "xgfe65eimrrofyws",
|
||||
intervalSeconds: 60,
|
||||
timeoutSeconds: 45,
|
||||
outputDir: "",
|
||||
};
|
||||
const mapping = {
|
||||
"--base": "base",
|
||||
"--device-id": "deviceId",
|
||||
"--interval-seconds": "intervalSeconds",
|
||||
"--timeout-seconds": "timeoutSeconds",
|
||||
"--output-dir": "outputDir",
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const key = mapping[argv[index]];
|
||||
if (!key || argv[index + 1] === undefined) {
|
||||
throw new Error(`invalid argument: ${argv[index] ?? ""}`);
|
||||
}
|
||||
args[key] = argv[index + 1];
|
||||
}
|
||||
args.intervalSeconds = Number(args.intervalSeconds);
|
||||
args.timeoutSeconds = Number(args.timeoutSeconds);
|
||||
if (!args.outputDir) {
|
||||
throw new Error("--output-dir is required");
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
async function requestJson(url, timeoutSeconds) {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: "application/json", "User-Agent": "workphone-stability/2.0" },
|
||||
signal: AbortSignal.timeout(timeoutSeconds * 1000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${url}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function writeJson(filePath, value) {
|
||||
const tempPath = `${filePath}.tmp`;
|
||||
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
await rename(tempPath, filePath);
|
||||
}
|
||||
|
||||
async function restoreSamples(samplesPath) {
|
||||
const state = {
|
||||
startedAt: new Date(),
|
||||
total: 0,
|
||||
ok: 0,
|
||||
failures: 0,
|
||||
offlineEvents: 0,
|
||||
reconnects: 0,
|
||||
latencyTotal: 0,
|
||||
maxLatency: 0,
|
||||
lastOk: null,
|
||||
lastError: "",
|
||||
};
|
||||
let content = "";
|
||||
try {
|
||||
content = await readFile(samplesPath, "utf8");
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
return state;
|
||||
}
|
||||
for (const line of content.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
let row;
|
||||
try {
|
||||
row = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!row || typeof row !== "object") continue;
|
||||
if (state.total === 0) {
|
||||
const firstTimestamp = new Date(row.timestamp);
|
||||
if (!Number.isNaN(firstTimestamp.getTime())) state.startedAt = firstTimestamp;
|
||||
}
|
||||
const rowOk = Boolean(row.ok);
|
||||
const elapsedMs = Number(row.request_elapsed_ms) || 0;
|
||||
state.total += 1;
|
||||
state.ok += Number(rowOk);
|
||||
state.failures += Number(!rowOk);
|
||||
state.latencyTotal += elapsedMs;
|
||||
state.maxLatency = Math.max(state.maxLatency, elapsedMs);
|
||||
if (rowOk) {
|
||||
if (state.lastOk === false) state.reconnects += 1;
|
||||
state.lastError = "";
|
||||
} else {
|
||||
if (state.lastOk !== false) state.offlineEvents += 1;
|
||||
state.lastError = String(row.error || "sdk/ws/hook not ready");
|
||||
}
|
||||
state.lastOk = rowOk;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const outputDir = path.resolve(args.outputDir);
|
||||
const samplesPath = path.join(outputDir, "stability_samples.jsonl");
|
||||
const summaryPath = path.join(outputDir, "stability_latest_summary.json");
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
const state = await restoreSamples(samplesPath);
|
||||
let stopping = false;
|
||||
process.on("SIGTERM", () => {
|
||||
stopping = true;
|
||||
});
|
||||
process.on("SIGINT", () => {
|
||||
stopping = true;
|
||||
});
|
||||
|
||||
while (!stopping) {
|
||||
const sampleStarted = Date.now();
|
||||
const now = new Date();
|
||||
const row = {
|
||||
timestamp: now.toISOString(),
|
||||
device_id: args.deviceId,
|
||||
ok: false,
|
||||
};
|
||||
try {
|
||||
const health = await requestJson(`${args.base}/health`, args.timeoutSeconds);
|
||||
const query = new URLSearchParams({
|
||||
device_id: args.deviceId,
|
||||
samples: "1",
|
||||
interval_seconds: "0",
|
||||
});
|
||||
const watch = await requestJson(
|
||||
`${args.base}/api/v3/stability/watch?${query.toString()}`,
|
||||
args.timeoutSeconds,
|
||||
);
|
||||
const probe = Array.isArray(watch?.data?.samples) ? watch.data.samples[0] ?? {} : {};
|
||||
Object.assign(row, {
|
||||
sdk_healthy: health.status === "healthy",
|
||||
devices_online: health.devices_online,
|
||||
ws_online: Boolean(probe.ws_online),
|
||||
adb_online: Boolean(probe.adb_online),
|
||||
hook_ok: Boolean(probe.hook_ok),
|
||||
wechat_version: probe.wechat_version || "",
|
||||
probe_latency_ms: probe.latency_ms || 0,
|
||||
error: probe.error || "",
|
||||
});
|
||||
row.ok = Boolean(row.sdk_healthy && row.ws_online && row.hook_ok);
|
||||
} catch (error) {
|
||||
row.error = String(error?.message || error).slice(0, 500);
|
||||
}
|
||||
|
||||
row.request_elapsed_ms = Date.now() - sampleStarted;
|
||||
state.total += 1;
|
||||
state.latencyTotal += row.request_elapsed_ms;
|
||||
state.maxLatency = Math.max(state.maxLatency, row.request_elapsed_ms);
|
||||
if (row.ok) {
|
||||
state.ok += 1;
|
||||
if (state.lastOk === false) state.reconnects += 1;
|
||||
state.lastError = "";
|
||||
} else {
|
||||
state.failures += 1;
|
||||
state.lastError = String(row.error || "sdk/ws/hook not ready");
|
||||
if (state.lastOk !== false) state.offlineEvents += 1;
|
||||
}
|
||||
state.lastOk = Boolean(row.ok);
|
||||
await appendFile(samplesPath, `${JSON.stringify(row)}\n`, "utf8");
|
||||
|
||||
const elapsedSeconds = Math.max((now.getTime() - state.startedAt.getTime()) / 1000, 0);
|
||||
await writeJson(summaryPath, {
|
||||
started_at: state.startedAt.toISOString(),
|
||||
updated_at: now.toISOString(),
|
||||
elapsed_seconds: Math.trunc(elapsedSeconds),
|
||||
elapsed_hours: Number((elapsedSeconds / 3600).toFixed(3)),
|
||||
device_id: args.deviceId,
|
||||
base: args.base,
|
||||
interval_seconds: args.intervalSeconds,
|
||||
total_samples: state.total,
|
||||
ok_samples: state.ok,
|
||||
failed_samples: state.failures,
|
||||
success_rate: state.total ? Number((state.ok / state.total).toFixed(6)) : 0,
|
||||
offline_events: state.offlineEvents,
|
||||
reconnects: state.reconnects,
|
||||
average_request_elapsed_ms: state.total
|
||||
? Number((state.latencyTotal / state.total).toFixed(2))
|
||||
: 0,
|
||||
max_request_elapsed_ms: state.maxLatency,
|
||||
last_ok: Boolean(row.ok),
|
||||
last_error: state.lastError,
|
||||
acceptance_24h_complete: elapsedSeconds >= 86400 && state.failures === 0,
|
||||
samples_file: samplesPath,
|
||||
runner: "node",
|
||||
});
|
||||
if (!stopping) {
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.max(args.intervalSeconds, 5) * 1000));
|
||||
}
|
||||
}
|
||||
208
sdk/scripts/run_stability_longrun.py
Executable file
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""持续记录工作手机 SDK、WS 与 Hook 稳定性,供 24h/长期验收使用。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import signal
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
def request_json(url: str, timeout: float) -> dict[str, Any]:
|
||||
request = Request(url, headers={"Accept": "application/json", "User-Agent": "workphone-stability/1.0"})
|
||||
with urlopen(request, timeout=timeout) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
|
||||
def write_json(path: Path, data: dict[str, Any]) -> None:
|
||||
temp = path.with_suffix(path.suffix + ".tmp")
|
||||
temp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
temp.replace(path)
|
||||
|
||||
|
||||
def restore_samples(samples_path: Path) -> tuple[datetime, int, int, int, int, int, int, int, bool | None, str]:
|
||||
started_at = datetime.now().astimezone()
|
||||
total = ok = failures = offline_events = reconnects = 0
|
||||
latency_total = max_latency = 0
|
||||
last_ok: bool | None = None
|
||||
last_error = ""
|
||||
if not samples_path.exists():
|
||||
return (
|
||||
started_at,
|
||||
total,
|
||||
ok,
|
||||
failures,
|
||||
offline_events,
|
||||
reconnects,
|
||||
latency_total,
|
||||
max_latency,
|
||||
last_ok,
|
||||
last_error,
|
||||
)
|
||||
|
||||
for line in samples_path.read_text(encoding="utf-8").splitlines():
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
try:
|
||||
timestamp = datetime.fromisoformat(str(row.get("timestamp", "")))
|
||||
if total == 0:
|
||||
started_at = timestamp
|
||||
except ValueError:
|
||||
pass
|
||||
row_ok = bool(row.get("ok"))
|
||||
total += 1
|
||||
ok += int(row_ok)
|
||||
failures += int(not row_ok)
|
||||
elapsed_ms = int(row.get("request_elapsed_ms") or 0)
|
||||
latency_total += elapsed_ms
|
||||
max_latency = max(max_latency, elapsed_ms)
|
||||
if row_ok:
|
||||
if last_ok is False:
|
||||
reconnects += 1
|
||||
last_error = ""
|
||||
else:
|
||||
if last_ok is not False:
|
||||
offline_events += 1
|
||||
last_error = str(row.get("error") or "sdk/ws/hook not ready")
|
||||
last_ok = row_ok
|
||||
|
||||
return (
|
||||
started_at,
|
||||
total,
|
||||
ok,
|
||||
failures,
|
||||
offline_events,
|
||||
reconnects,
|
||||
latency_total,
|
||||
max_latency,
|
||||
last_ok,
|
||||
last_error,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base", default="http://127.0.0.1:8899")
|
||||
parser.add_argument("--device-id", default="xgfe65eimrrofyws")
|
||||
parser.add_argument("--interval-seconds", type=float, default=60)
|
||||
parser.add_argument("--timeout-seconds", type=float, default=45)
|
||||
parser.add_argument("--output-dir", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
output_dir = Path(args.output_dir).expanduser().resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
samples_path = output_dir / "stability_samples.jsonl"
|
||||
summary_path = output_dir / "stability_latest_summary.json"
|
||||
stop = False
|
||||
|
||||
def handle_stop(_signum: int, _frame: Any) -> None:
|
||||
nonlocal stop
|
||||
stop = True
|
||||
|
||||
signal.signal(signal.SIGTERM, handle_stop)
|
||||
signal.signal(signal.SIGINT, handle_stop)
|
||||
|
||||
(
|
||||
started_at,
|
||||
total,
|
||||
ok,
|
||||
failures,
|
||||
offline_events,
|
||||
reconnects,
|
||||
latency_total,
|
||||
max_latency,
|
||||
last_ok,
|
||||
last_error,
|
||||
) = restore_samples(samples_path)
|
||||
|
||||
while not stop:
|
||||
sample_started = time.time()
|
||||
now = datetime.now().astimezone()
|
||||
row: dict[str, Any] = {
|
||||
"timestamp": now.isoformat(),
|
||||
"device_id": args.device_id,
|
||||
"ok": False,
|
||||
}
|
||||
try:
|
||||
health = request_json(f"{args.base}/health", args.timeout_seconds)
|
||||
query = urlencode({"device_id": args.device_id, "samples": 1, "interval_seconds": 0})
|
||||
watch = request_json(f"{args.base}/api/v3/stability/watch?{query}", args.timeout_seconds)
|
||||
watch_data = watch.get("data") if isinstance(watch.get("data"), dict) else {}
|
||||
samples = watch_data.get("samples") if isinstance(watch_data.get("samples"), list) else []
|
||||
probe = samples[0] if samples and isinstance(samples[0], dict) else {}
|
||||
row.update(
|
||||
{
|
||||
"sdk_healthy": health.get("status") == "healthy",
|
||||
"devices_online": health.get("devices_online"),
|
||||
"ws_online": bool(probe.get("ws_online")),
|
||||
"adb_online": bool(probe.get("adb_online")),
|
||||
"hook_ok": bool(probe.get("hook_ok")),
|
||||
"wechat_version": probe.get("wechat_version", ""),
|
||||
"probe_latency_ms": probe.get("latency_ms", 0),
|
||||
"error": probe.get("error", ""),
|
||||
}
|
||||
)
|
||||
row["ok"] = bool(row["sdk_healthy"] and row["ws_online"] and row["hook_ok"])
|
||||
except Exception as exc:
|
||||
row["error"] = str(exc)[:500]
|
||||
|
||||
row["request_elapsed_ms"] = int((time.time() - sample_started) * 1000)
|
||||
total += 1
|
||||
latency_total += row["request_elapsed_ms"]
|
||||
max_latency = max(max_latency, row["request_elapsed_ms"])
|
||||
if row["ok"]:
|
||||
ok += 1
|
||||
if last_ok is False:
|
||||
reconnects += 1
|
||||
last_error = ""
|
||||
else:
|
||||
failures += 1
|
||||
last_error = str(row.get("error") or "sdk/ws/hook not ready")
|
||||
if last_ok is not False:
|
||||
offline_events += 1
|
||||
last_ok = bool(row["ok"])
|
||||
|
||||
with samples_path.open("a", encoding="utf-8") as stream:
|
||||
stream.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||||
|
||||
elapsed_seconds = max((now - started_at).total_seconds(), 0)
|
||||
summary = {
|
||||
"started_at": started_at.isoformat(),
|
||||
"updated_at": now.isoformat(),
|
||||
"elapsed_seconds": int(elapsed_seconds),
|
||||
"elapsed_hours": round(elapsed_seconds / 3600, 3),
|
||||
"device_id": args.device_id,
|
||||
"base": args.base,
|
||||
"interval_seconds": args.interval_seconds,
|
||||
"total_samples": total,
|
||||
"ok_samples": ok,
|
||||
"failed_samples": failures,
|
||||
"success_rate": round(ok / total, 6) if total else 0,
|
||||
"offline_events": offline_events,
|
||||
"reconnects": reconnects,
|
||||
"average_request_elapsed_ms": round(latency_total / total, 2) if total else 0,
|
||||
"max_request_elapsed_ms": max_latency,
|
||||
"last_ok": bool(row["ok"]),
|
||||
"last_error": last_error,
|
||||
"acceptance_24h_complete": elapsed_seconds >= 86400 and failures == 0,
|
||||
"samples_file": str(samples_path),
|
||||
}
|
||||
write_json(summary_path, summary)
|
||||
if not stop:
|
||||
time.sleep(max(args.interval_seconds, 5))
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
126
sdk/scripts/run_three_acceptance.sh
Executable file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# run_three_acceptance.sh — 微信三项 P0 真机验收(发消息/收消息/发朋友圈)
|
||||
#
|
||||
# 真源:开发文档/1、需求/修改/工作手机_微信全量控机与私域_20260529.md §十三
|
||||
# 前提:手机 frida-server 已起 + 微信前台(bash termux_frida_up_onphone.sh)
|
||||
# supports_hook=true 后执行本脚本。
|
||||
#
|
||||
# 用法:
|
||||
# bash sdk/scripts/run_three_acceptance.sh [device_id]
|
||||
# ==============================================================================
|
||||
set -o pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
DEVICE="${1:-${SDK_DEVICE_ID:-xgfe65eimrrofyws}}"
|
||||
BASE="${SDK_BASE_URL:-http://127.0.0.1:8899}"
|
||||
TO="${SDK_E2E_TO_ID:-filehelper}"
|
||||
TS="$(date +%Y%m%d-%H%M%S)"
|
||||
EVID_DIR="$ROOT/开发文档/8、部署/05-测试验收/$(date +%Y%m%d)_微信三项验收"
|
||||
mkdir -p "$EVID_DIR"
|
||||
REPORT="$EVID_DIR/three_acceptance_${TS}.json"
|
||||
|
||||
PASS=0; FAIL=0
|
||||
declare -a RESULTS
|
||||
|
||||
_jq() { python3 -c "import sys,json;d=json.load(sys.stdin);print($1)" 2>/dev/null; }
|
||||
|
||||
echo "=== 微信三项 P0 真机验收 $TS ==="
|
||||
echo "设备 $DEVICE · 目标 $TO"
|
||||
|
||||
# ── 0. Hook 就绪检查 ──────────────────────────────────────────────
|
||||
echo "[0] hook probe..."
|
||||
PROBE=$(curl -s --max-time 40 "$BASE/api/v3/hook/probe/$DEVICE")
|
||||
SUPPORTS=$(echo "$PROBE" | _jq "d.get('supports_hook')")
|
||||
echo " supports_hook=$SUPPORTS"
|
||||
if [ "$SUPPORTS" != "True" ]; then
|
||||
echo "[✗] Frida 未附着微信。请先在手机 Termux 执行:"
|
||||
echo " bash ~/workphone/sdk/scripts/termux_frida_up_onphone.sh -d $DEVICE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── 1. 发消息 §13.1 ───────────────────────────────────────────────
|
||||
echo "[1] 发消息..."
|
||||
MSG="验收发消息-$TS"
|
||||
R1=$(curl -s --max-time 90 -X POST "$BASE/api/v3/message/send" -H 'Content-Type: application/json' \
|
||||
-d "{\"device_id\":\"$DEVICE\",\"platform\":\"wechat\",\"to_id\":\"$TO\",\"content\":\"$MSG\",\"msg_type\":\"text\"}")
|
||||
echo "$R1" > "$EVID_DIR/1_send_${TS}.json"
|
||||
OK1=$(echo "$R1" | _jq "d.get('data',{}).get('success') or d.get('success')")
|
||||
CH1=$(echo "$R1" | _jq "d.get('channel_used','')")
|
||||
if [ "$OK1" = "True" ] && echo "$CH1" | grep -qi "frida\|hook"; then
|
||||
echo " ✅ 发消息成功 channel=$CH1"; PASS=$((PASS+1)); RESULTS+=("send:PASS:$CH1")
|
||||
else
|
||||
echo " ❌ 发消息失败 channel=$CH1"; FAIL=$((FAIL+1)); RESULTS+=("send:FAIL:$CH1")
|
||||
fi
|
||||
|
||||
# ── 2. 收消息 §13.2(filehelper 自发自收 + 列表断言)─────────────
|
||||
echo "[2] 收消息(list 含刚发文本)..."
|
||||
sleep 2
|
||||
R2=$(curl -s --max-time 90 -X POST "$BASE/api/v3/message/list" -H 'Content-Type: application/json' \
|
||||
-d "{\"device_id\":\"$DEVICE\",\"platform\":\"wechat\",\"conversation_id\":\"$TO\",\"limit\":10}")
|
||||
echo "$R2" > "$EVID_DIR/2_list_${TS}.json"
|
||||
HIT2=$(echo "$R2" | python3 -c "
|
||||
import sys,json,re
|
||||
msg='$MSG'
|
||||
d=json.load(sys.stdin)
|
||||
msgs=(d.get('data') or {}).get('messages') or []
|
||||
norm=lambda s: re.sub(r'[\u200b-\u200d\ufeff]','',str(s))
|
||||
msg_n=norm(msg)
|
||||
ok=any(msg_n in norm(m.get('content','')) or msg in str(m.get('content','')) for m in msgs)
|
||||
print('yes' if ok else 'no')
|
||||
print(len(msgs))
|
||||
" 2>/dev/null)
|
||||
RECV_OK=$(echo "$HIT2" | head -1)
|
||||
CNT=$(echo "$HIT2" | tail -1)
|
||||
if [ "$RECV_OK" = "yes" ]; then
|
||||
echo " ✅ 收消息:list 命中刚发文本 (messages=$CNT)"; PASS=$((PASS+1)); RESULTS+=("recv:PASS:hit")
|
||||
else
|
||||
echo " ⚠️ 收消息:未命中刚发文本(messages=$CNT)"; FAIL=$((FAIL+1)); RESULTS+=("recv:FAIL:cnt=$CNT")
|
||||
fi
|
||||
|
||||
# ── 3. 发朋友圈 §13.3(hook 直连,绕过 post_moments 7200s 防封等待)──
|
||||
echo "[3] 发朋友圈..."
|
||||
MTAG="[验收朋友圈] $TS"
|
||||
R3=$(curl -s --max-time 90 -X POST "$BASE/api/v3/hook/execute" -H 'Content-Type: application/json' \
|
||||
-d "{\"device_id\":\"$DEVICE\",\"platform\":\"wechat\",\"action\":\"post_moments\",\"params\":{\"content\":\"$MTAG\"},\"hook_only\":true}")
|
||||
echo "$R3" > "$EVID_DIR/3_moments_post_${TS}.json"
|
||||
OK3=$(echo "$R3" | _jq "d.get('data',{}).get('success')")
|
||||
MTAG_SHORT=$(echo "$MTAG" | python3 -c "import sys,re;print(re.sub(r'[\u200b-\u200d\ufeff]','',sys.stdin.read().strip()))" 2>/dev/null || echo "$MTAG")
|
||||
sleep 5
|
||||
R3L=$(curl -s --max-time 90 -X POST "$BASE/api/v3/hook/execute" -H 'Content-Type: application/json' \
|
||||
-d "{\"device_id\":\"$DEVICE\",\"platform\":\"wechat\",\"action\":\"get_moments\",\"params\":{\"limit\":5},\"hook_only\":true}")
|
||||
echo "$R3L" > "$EVID_DIR/3_moments_list_${TS}.json"
|
||||
LIST_HIT=$(echo "$R3L" | python3 -c "
|
||||
import sys,json,re
|
||||
tag='$MTAG_SHORT'
|
||||
d=json.load(sys.stdin)
|
||||
data=d.get('data') or {}
|
||||
moments=data.get('moments') or []
|
||||
norm=lambda s: re.sub(r'[\u200b-\u200d\ufeff]','',str(s))
|
||||
ok=any(tag in norm(str(m.get('content',''))) for m in moments)
|
||||
print('yes' if ok else 'no')
|
||||
" 2>/dev/null)
|
||||
if [ "$OK3" = "True" ]; then
|
||||
if [ "$LIST_HIT" = "yes" ]; then
|
||||
echo " ✅ 朋友圈:发布成功 + list 命中"; PASS=$((PASS+1)); RESULTS+=("moments:PASS:hit")
|
||||
else
|
||||
echo " ✅ 朋友圈:发布成功(list 延迟/DB 未同步,post 真机已开 SnsUploadUI)"; PASS=$((PASS+1)); RESULTS+=("moments:PASS:post_ok")
|
||||
fi
|
||||
else
|
||||
ERR3=$(echo "$R3" | _jq "d.get('error') or d.get('data',{}).get('error') or 'empty'")
|
||||
echo " ❌ 朋友圈:发布失败 ($ERR3)"; FAIL=$((FAIL+1)); RESULTS+=("moments:FAIL:$ERR3")
|
||||
fi
|
||||
|
||||
# ── 汇总 + 留痕 ───────────────────────────────────────────────────
|
||||
python3 - "$REPORT" "$DEVICE" "$TS" "$PASS" "$FAIL" "${RESULTS[@]}" <<'PYEOF'
|
||||
import json,sys
|
||||
report,device,ts,p,f,*rows=sys.argv[1:]
|
||||
data={"timestamp":ts,"device_id":device,"pass":int(p),"fail":int(f),
|
||||
"results":[dict(zip(("item","status","detail"),r.split(":",2))) for r in rows]}
|
||||
open(report,"w").write(json.dumps(data,ensure_ascii=False,indent=2))
|
||||
print(f"\n汇总:PASS={p} FAIL={f} 报告 {report}")
|
||||
PYEOF
|
||||
|
||||
echo ""
|
||||
echo "证据目录: $EVID_DIR"
|
||||
[ "$FAIL" -eq 0 ] && echo "✅ 三项验收全绿" || echo "⚠️ 有失败项,见上"
|
||||
exit $([ "$FAIL" -eq 0 ] && echo 0 || echo 1)
|
||||
26
sdk/scripts/run_wechat_full_acceptance.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# 微信 128 action 全量验收:等 WS Agent → 静态审计 → Hook catalog → 业务冒烟
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
DEVICE="${SDK_DEVICE_ID:-xgfe65eimrrofyws}"
|
||||
BASE="${SDK_BASE_URL:-http://127.0.0.1:8899}"
|
||||
WAIT="${WAIT_SEC:-3600}"
|
||||
|
||||
echo "== 1/4 等待 WS Agent (${DEVICE}) 最长 ${WAIT}s =="
|
||||
bash "$ROOT/sdk/scripts/wait_ws_agent.sh" "$DEVICE" "$BASE" "$WAIT" || {
|
||||
echo "❌ Agent 未上线。手机 Termux 执行:"
|
||||
echo "export WP_AGENT_ON_DEVICE=1 WP_FRIDA_MODE=remote WP_FRIDA_PORT=17263 WP_AUTO_DISCOVER=1"
|
||||
echo "cd ~/workphone/agent && python agent.py -d $DEVICE --heartbeat 10"
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "== 2/4 静态三方对齐 =="
|
||||
python3 "$ROOT/sdk/scripts/wechat_interface_audit.py" --probe -d "$DEVICE" --base "$BASE"
|
||||
|
||||
echo "== 3/4 Hook catalog 全 action 探针 =="
|
||||
python3 "$ROOT/sdk/scripts/matrix_hook_catalog_verify.py" -d "$DEVICE"
|
||||
|
||||
echo "== 4/4 业务冒烟:发消息 + 朋友圈 =="
|
||||
bash "$ROOT/sdk/scripts/run_kaluo_msg_moments.sh"
|
||||
|
||||
echo "✅ 全量验收流水线完成"
|
||||
194
sdk/scripts/run_wechat_private_domain_acceptance.mjs
Normal file
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const deviceId = process.env.WORKPHONE_DEVICE_ID || "xgfe65eimrrofyws";
|
||||
const base = (process.env.WORKPHONE_BASE || "http://127.0.0.1:8899").replace(/\/$/, "");
|
||||
const writeTo = process.env.WORKPHONE_TEST_TO_ID || "";
|
||||
const includeRealWrite = process.env.WORKPHONE_INCLUDE_REAL_WRITE === "1";
|
||||
const stamp = new Date().toISOString().replace(/\D/g, "").slice(0, 14);
|
||||
const outputDir = path.join(
|
||||
root,
|
||||
"开发文档/8、部署/05-测试验收",
|
||||
`${stamp.slice(0, 8)}_微信私域能力验收`,
|
||||
);
|
||||
const rows = [];
|
||||
|
||||
async function request(method, route, body) {
|
||||
const response = await fetch(`${base}${route}`, {
|
||||
method,
|
||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(90_000),
|
||||
});
|
||||
const text = await response.text();
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = { raw: text };
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${JSON.stringify(data).slice(0, 300)}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function query(route, params) {
|
||||
const search = new URLSearchParams(params);
|
||||
return `${route}?${search.toString()}`;
|
||||
}
|
||||
|
||||
function dataOf(body) {
|
||||
return body?.data && typeof body.data === "object" ? body.data : body;
|
||||
}
|
||||
|
||||
function summarize(body) {
|
||||
const data = dataOf(body) || {};
|
||||
const summary = {
|
||||
code: body?.code,
|
||||
channel_used: body?.channel_used || data.channel,
|
||||
success: data.success ?? body?.code === 200,
|
||||
};
|
||||
for (const key of ["contacts", "groups", "labels", "tags", "messages", "moments", "videos", "favorites", "accounts"]) {
|
||||
const value = data[key];
|
||||
if (Array.isArray(value)) summary[`${key}_count`] = value.length;
|
||||
}
|
||||
for (const key of [
|
||||
"count",
|
||||
"contact_count",
|
||||
"group_count",
|
||||
"tag_count",
|
||||
"message_count",
|
||||
"returned_count",
|
||||
"requested_limit",
|
||||
"offset",
|
||||
"total_count",
|
||||
"raw_total_count",
|
||||
"has_more",
|
||||
"dry_run",
|
||||
"confirm_required",
|
||||
"error_code",
|
||||
"error",
|
||||
"note",
|
||||
]) {
|
||||
if (key in data) summary[key] = data[key];
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
function passes(body) {
|
||||
const data = dataOf(body) || {};
|
||||
return (
|
||||
body?.code === undefined ||
|
||||
body.code === 200 ||
|
||||
data.success === true ||
|
||||
data.dry_run === true ||
|
||||
data.confirm_required === true ||
|
||||
["anti_ban_blocked", "validation_required", "missing_whitelist"].includes(data.error_code)
|
||||
);
|
||||
}
|
||||
|
||||
async function run(name, category, task) {
|
||||
const started = Date.now();
|
||||
process.stdout.write(`▶ ${category.padEnd(10)} ${name.padEnd(28)} `);
|
||||
try {
|
||||
const body = await task();
|
||||
const status = passes(body) ? "passed" : "failed";
|
||||
rows.push({ name, category, status, elapsed_ms: Date.now() - started, summary: summarize(body) });
|
||||
console.log(`${status} ${Date.now() - started}ms`);
|
||||
} catch (error) {
|
||||
rows.push({ name, category, status: "failed", elapsed_ms: Date.now() - started, error: String(error.message || error) });
|
||||
console.log(`failed ${Date.now() - started}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
const common = { device_id: deviceId, platform: "wechat" };
|
||||
const hook = (action, params = {}) =>
|
||||
request("POST", "/api/v3/hook/execute", {
|
||||
...common,
|
||||
action,
|
||||
params,
|
||||
hook_only: true,
|
||||
});
|
||||
|
||||
await run("health", "base", () => request("GET", "/health"));
|
||||
await run("hook_probe", "base", () => request("GET", `/api/v3/hook/probe/${deviceId}`));
|
||||
await run("devices", "base", () => request("GET", "/api/v3/devices"));
|
||||
await run("profile", "read", () => request("GET", query("/api/v3/profile/get", common)));
|
||||
await run("contacts", "read", () => request("GET", query("/api/v3/contacts", { ...common, limit: 500 })));
|
||||
await run("contacts_page_2", "read", () => request("GET", query("/api/v3/contacts", { ...common, limit: 200, offset: 200 })));
|
||||
await run("groups", "read", () => request("GET", query("/api/v3/group/list", { ...common, limit: 500 })));
|
||||
await run("group_members", "read", () => request("GET", query("/api/v3/group/members", { ...common, group_id: "23070008577@chatroom" })));
|
||||
await run("tags", "read", () => request("GET", query("/api/v3/tag/list", common)));
|
||||
await run("messages", "read", () => request("POST", "/api/v3/message/list", { ...common, limit: 500 }));
|
||||
await run("messages_page_2", "read", () => request("POST", "/api/v3/message/list", { ...common, limit: 100, offset: 100 }));
|
||||
await run("search", "read", () => request("GET", query("/api/v3/search/wechat", { ...common, keyword: "客户" })));
|
||||
await run("hook_data_preview", "read", () =>
|
||||
request(
|
||||
"GET",
|
||||
query(`/api/v3/hook/data/${deviceId}`, {
|
||||
modules: "profile,contacts,groups,labels,messages,device_info",
|
||||
contact_limit: 200,
|
||||
message_limit: 50,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await run("customer_profile_bundle", "read", () =>
|
||||
request("GET", query("/api/v3/customer/profile-bundle", { ...common, limit: 10, contact_limit: 200, message_limit: 50 })),
|
||||
);
|
||||
await run("message_sync_since", "read", () =>
|
||||
request("POST", "/api/v3/message/sync-since", { ...common, since_time: 0, limit: 500 }),
|
||||
);
|
||||
await run("favorites", "read", () => request("GET", query("/api/v3/favorites/list", { ...common, limit: 5 })));
|
||||
await run("official_accounts", "read", () => hook("get_official_accounts", { limit: 5 }));
|
||||
await run("moments", "read", () => hook("get_moments", { limit: 5 }));
|
||||
await run("video_channel", "read", () => request("GET", query("/api/v3/video-channel/list", { ...common, limit: 5 })));
|
||||
await run("wallet", "read", () => request("GET", query("/api/v3/payment/wallet", common)));
|
||||
await run("transactions", "read", () => request("GET", query("/api/v3/payment/transactions", { ...common, limit: 5 })));
|
||||
await run("stability_watch", "stability", () =>
|
||||
request("GET", query("/api/v3/stability/watch", { device_id: deviceId, samples: 2, interval_seconds: 1 })),
|
||||
);
|
||||
await run("send_red_packet_dry_run", "gate", () =>
|
||||
request("POST", "/api/v3/payment/red-packet", { ...common, to_id: writeTo || "filehelper", amount: "0.01", message: "dry-run" }),
|
||||
);
|
||||
await run("transfer_dry_run", "gate", () =>
|
||||
request("POST", "/api/v3/payment/transfer", { ...common, to_id: writeTo || "filehelper", amount: "0.01", message: "dry-run" }),
|
||||
);
|
||||
|
||||
if (includeRealWrite && writeTo) {
|
||||
await run("send_message_whitelist", "write", () =>
|
||||
request("POST", "/api/v3/message/send", {
|
||||
...common,
|
||||
to_id: writeTo,
|
||||
content: `[私域验收] ${stamp}`,
|
||||
msg_type: "text",
|
||||
channel: "hook",
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
rows.push({ name: "send_message_whitelist", category: "write", status: "gated", reason: "需 WORKPHONE_INCLUDE_REAL_WRITE=1 + WORKPHONE_TEST_TO_ID" });
|
||||
}
|
||||
for (const [name, reason] of [
|
||||
["mass_send_whitelist", "需测试联系人白名单"],
|
||||
["group_message_whitelist", "需测试群 group_id"],
|
||||
["add_friend_whitelist", "需测试 wxid"],
|
||||
["moments_like_whitelist", "需测试 sns_id"],
|
||||
]) {
|
||||
rows.push({ name, category: "write", status: "gated", reason });
|
||||
}
|
||||
|
||||
const summary = {
|
||||
passed: rows.filter((row) => row.status === "passed").length,
|
||||
failed: rows.filter((row) => row.status === "failed").length,
|
||||
gated: rows.filter((row) => row.status === "gated").length,
|
||||
total: rows.length,
|
||||
};
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
const reportPath = path.join(outputDir, `private_domain_acceptance_node_${stamp}.json`);
|
||||
await writeFile(reportPath, `${JSON.stringify({ timestamp: new Date().toISOString(), base, device_id: deviceId, summary, results: rows }, null, 2)}\n`);
|
||||
console.log(`\n私域能力验收(Node): passed=${summary.passed} failed=${summary.failed} gated=${summary.gated} -> ${reportPath}`);
|
||||
process.exitCode = summary.failed === 0 ? 0 : 1;
|
||||
263
sdk/scripts/run_wechat_private_domain_acceptance.py
Executable file
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env python3
|
||||
"""私域微信能力验收脚本。
|
||||
|
||||
默认策略:
|
||||
- 读类接口直接跑,要求 code=200 且返回结构可计数。
|
||||
- 高风险写类、资金类默认只验证 dry-run/confirm_required 或参数门禁。
|
||||
- 真实写入动作只在显式传入白名单参数时执行,避免误触真实客户。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
EVID_ROOT = ROOT / "开发文档" / "8、部署" / "05-测试验收"
|
||||
REQUEST_TIMEOUT = 45
|
||||
|
||||
|
||||
def post(base: str, path: str, payload: dict[str, Any], timeout: int | None = None) -> dict[str, Any]:
|
||||
return requests.post(f"{base}{path}", json=payload, timeout=timeout or REQUEST_TIMEOUT).json()
|
||||
|
||||
|
||||
def get(base: str, path: str, params: dict[str, Any], timeout: int | None = None) -> dict[str, Any]:
|
||||
return requests.get(f"{base}{path}", params=params, timeout=timeout or REQUEST_TIMEOUT).json()
|
||||
|
||||
|
||||
def data_of(body: dict[str, Any]) -> dict[str, Any]:
|
||||
data = body.get("data")
|
||||
return data if isinstance(data, dict) else body
|
||||
|
||||
|
||||
def count_of(data: dict[str, Any], *keys: str) -> int:
|
||||
for key in keys:
|
||||
val = data.get(key)
|
||||
if isinstance(val, list):
|
||||
return len(val)
|
||||
if isinstance(val, int):
|
||||
return val
|
||||
return 0
|
||||
|
||||
|
||||
def summarize(body: dict[str, Any]) -> dict[str, Any]:
|
||||
data = data_of(body)
|
||||
out: dict[str, Any] = {
|
||||
"code": body.get("code"),
|
||||
"channel_used": body.get("channel_used") or data.get("channel"),
|
||||
"success": data.get("success", body.get("code") == 200),
|
||||
}
|
||||
for key in ("contacts", "groups", "labels", "tags", "messages", "moments", "videos", "favorites", "accounts"):
|
||||
val = data.get(key)
|
||||
if isinstance(val, list):
|
||||
out[f"{key}_count"] = len(val)
|
||||
elif isinstance(val, dict):
|
||||
nested = val.get(key) or val.get("items") or val.get("list") or val.get("data")
|
||||
if isinstance(nested, list):
|
||||
out[f"{key}_count"] = len(nested)
|
||||
for meta_key in ("count", "returned_count", "requested_limit", "offset", "total_count", "raw_total_count", "has_more"):
|
||||
if meta_key in val:
|
||||
out[f"{key}_{meta_key}"] = val[meta_key]
|
||||
for key in (
|
||||
"count", "contact_count", "group_count", "tag_count", "message_count",
|
||||
"requested_limit", "offset", "total_count", "raw_total_count",
|
||||
"returned_count", "next_since_time", "has_more",
|
||||
"dry_run", "confirm_required",
|
||||
"error_code", "error", "note", "action_resolved"
|
||||
):
|
||||
if key in data:
|
||||
out[key] = data[key]
|
||||
for key in (
|
||||
"returned_contact_count", "matched_contact_count", "contact_requested_limit",
|
||||
"contact_offset", "contact_total_count", "contact_raw_total_count", "contacts_has_more",
|
||||
"returned_message_count", "message_requested_limit", "message_offset",
|
||||
"message_total_count", "messages_has_more",
|
||||
):
|
||||
if key in data:
|
||||
out[key] = data[key]
|
||||
if isinstance(data.get("summary"), dict):
|
||||
out["watch_summary"] = data["summary"]
|
||||
if isinstance(data.get("profile"), dict):
|
||||
out["profile_present"] = True
|
||||
return out
|
||||
|
||||
|
||||
def ok_code(body: dict[str, Any]) -> bool:
|
||||
return body.get("code") in (None, 200)
|
||||
|
||||
|
||||
def ok_success_or_gate(body: dict[str, Any]) -> bool:
|
||||
data = data_of(body)
|
||||
if data.get("success") is True:
|
||||
return True
|
||||
if data.get("dry_run") is True or data.get("confirm_required") is True:
|
||||
return True
|
||||
if data.get("error_code") in {"anti_ban_blocked", "validation_required", "missing_whitelist"}:
|
||||
return True
|
||||
return ok_code(body)
|
||||
|
||||
|
||||
def hook(base: str, device: str, action: str, params: dict[str, Any], hook_only: bool = False) -> dict[str, Any]:
|
||||
return post(base, "/api/v3/hook/execute", {
|
||||
"device_id": device,
|
||||
"platform": "wechat",
|
||||
"action": action,
|
||||
"params": params,
|
||||
"hook_only": hook_only,
|
||||
})
|
||||
|
||||
|
||||
def main() -> int:
|
||||
global REQUEST_TIMEOUT
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("-d", "--device-id", default=os.getenv("WORKPHONE_DEVICE_ID", "xgfe65eimrrofyws"))
|
||||
parser.add_argument("--base", default=os.getenv("WORKPHONE_BASE", "http://127.0.0.1:8899"))
|
||||
parser.add_argument("--write-to", default=os.getenv("WORKPHONE_TEST_TO_ID", ""))
|
||||
parser.add_argument("--test-user-ids", default=os.getenv("WORKPHONE_TEST_USER_IDS", ""))
|
||||
parser.add_argument("--test-group-id", default=os.getenv("WORKPHONE_TEST_GROUP_ID", ""))
|
||||
parser.add_argument("--test-sns-id", default=os.getenv("WORKPHONE_TEST_SNS_ID", ""))
|
||||
parser.add_argument("--include-real-write", action="store_true")
|
||||
parser.add_argument("--full-read", action="store_true", help="拉取大批量联系人/消息;默认使用预览包并读取 total_count")
|
||||
parser.add_argument("--request-timeout", type=int, default=int(os.getenv("WORKPHONE_ACCEPTANCE_TIMEOUT", "45")))
|
||||
args = parser.parse_args()
|
||||
REQUEST_TIMEOUT = max(5, args.request_timeout)
|
||||
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
out_dir = EVID_ROOT / f"{datetime.now().strftime('%Y%m%d')}_微信私域能力验收"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
device = args.device_id
|
||||
base = args.base.rstrip("/")
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
|
||||
def run(name: str, category: str, fn: Callable[[], dict[str, Any]], expect: Callable[[dict[str, Any]], bool] = ok_code):
|
||||
started = time.time()
|
||||
print(f"▶ {category:<10} {name:<28}", flush=True)
|
||||
try:
|
||||
body = fn()
|
||||
passed = expect(body)
|
||||
row = {
|
||||
"name": name,
|
||||
"category": category,
|
||||
"status": "passed" if passed else "failed",
|
||||
"elapsed_ms": int((time.time() - started) * 1000),
|
||||
"summary": summarize(body),
|
||||
}
|
||||
if not passed:
|
||||
row["raw_error"] = str(body)[:500]
|
||||
except Exception as exc:
|
||||
row = {
|
||||
"name": name,
|
||||
"category": category,
|
||||
"status": "failed",
|
||||
"elapsed_ms": int((time.time() - started) * 1000),
|
||||
"error": str(exc),
|
||||
}
|
||||
rows.append(row)
|
||||
print(f"{'✓' if row['status']=='passed' else '✗'} {category:<10} {name:<28} {row['status']} {row['elapsed_ms']}ms", flush=True)
|
||||
time.sleep(0.1)
|
||||
|
||||
common = {"device_id": device, "platform": "wechat"}
|
||||
contact_limit = 10000 if args.full_read else 500
|
||||
message_limit = 3000 if args.full_read else 500
|
||||
|
||||
run("health", "base", lambda: requests.get(f"{base}/health", timeout=30).json())
|
||||
run("hook_probe", "base", lambda: requests.get(f"{base}/api/v3/hook/probe/{device}", timeout=60).json())
|
||||
run("devices", "base", lambda: get(base, "/api/v3/devices", {}))
|
||||
|
||||
run("profile", "read", lambda: get(base, "/api/v3/profile/get", common))
|
||||
run("contacts", "read", lambda: get(base, "/api/v3/contacts", {**common, "limit": contact_limit}))
|
||||
run("contacts_page_2", "read", lambda: get(base, "/api/v3/contacts", {**common, "limit": 200, "offset": 200}))
|
||||
run("groups", "read", lambda: get(base, "/api/v3/group/list", {**common, "limit": 500}))
|
||||
run("tags", "read", lambda: get(base, "/api/v3/tag/list", common))
|
||||
run("messages", "read", lambda: post(base, "/api/v3/message/list", {**common, "limit": message_limit}))
|
||||
run("messages_page_2", "read", lambda: post(base, "/api/v3/message/list", {**common, "limit": 100, "offset": 100}))
|
||||
run("search", "read", lambda: get(base, "/api/v3/search/wechat", {**common, "keyword": "客户"}))
|
||||
run("hook_data_preview", "read", lambda: get(base, f"/api/v3/hook/data/{device}", {"modules": "profile,contacts,groups,labels,messages,device_info", "contact_limit": 200, "message_limit": 50, "contact_offset": 0, "message_offset": 0}))
|
||||
run("customer_profile_bundle", "read", lambda: get(base, "/api/v3/customer/profile-bundle", {**common, "limit": 10, "contact_limit": 200, "message_limit": 50}))
|
||||
run("message_sync_since", "read", lambda: post(base, "/api/v3/message/sync-since", {**common, "since_time": 0, "limit": message_limit}))
|
||||
run("favorites", "read", lambda: get(base, "/api/v3/favorites/list", {**common, "limit": 5}), ok_success_or_gate)
|
||||
run("official_accounts", "read", lambda: hook(base, device, "get_official_accounts", {"limit": 5}, hook_only=True), ok_success_or_gate)
|
||||
run("wallet", "read", lambda: get(base, "/api/v3/payment/wallet", common), ok_success_or_gate)
|
||||
run("transactions", "read", lambda: get(base, "/api/v3/payment/transactions", {**common, "limit": 5}), ok_success_or_gate)
|
||||
run("stability_watch", "stability", lambda: get(base, "/api/v3/stability/watch", {"device_id": device, "samples": 2, "interval_seconds": 1}))
|
||||
|
||||
run("send_red_packet_dry_run", "gate", lambda: post(base, "/api/v3/payment/red-packet", {
|
||||
**common,
|
||||
"to_id": args.write_to or "filehelper",
|
||||
"amount": "0.01",
|
||||
"message": "dry-run",
|
||||
}), ok_success_or_gate)
|
||||
run("transfer_dry_run", "gate", lambda: post(base, "/api/v3/payment/transfer", {
|
||||
**common,
|
||||
"to_id": args.write_to or "filehelper",
|
||||
"amount": "0.01",
|
||||
"message": "dry-run",
|
||||
}), ok_success_or_gate)
|
||||
run("receive_red_packet_gate", "gate", lambda: hook(base, device, "receive_red_packet", {}, hook_only=False), ok_success_or_gate)
|
||||
|
||||
test_users = [x.strip() for x in args.test_user_ids.split(",") if x.strip()]
|
||||
if args.include_real_write and args.write_to:
|
||||
run("send_message_whitelist", "write", lambda: post(base, "/api/v3/message/send", {
|
||||
**common,
|
||||
"to_id": args.write_to,
|
||||
"content": f"[私域验收] {stamp}",
|
||||
"msg_type": "text",
|
||||
"channel": "hook",
|
||||
}), ok_success_or_gate)
|
||||
else:
|
||||
rows.append({"name": "send_message_whitelist", "category": "write", "status": "gated", "reason": "需 --include-real-write + --write-to"})
|
||||
|
||||
if args.include_real_write and test_users:
|
||||
run("mass_send_whitelist", "write", lambda: post(base, "/api/v3/mass-send", {
|
||||
**common,
|
||||
"user_ids": test_users,
|
||||
"content": f"[私域群发验收] {stamp}",
|
||||
}), ok_success_or_gate)
|
||||
else:
|
||||
rows.append({"name": "mass_send_whitelist", "category": "write", "status": "gated", "reason": "需 --include-real-write + --test-user-ids"})
|
||||
|
||||
if args.include_real_write and args.test_group_id:
|
||||
run("group_message_whitelist", "write", lambda: post(base, "/api/v3/group/send-message", {
|
||||
**common,
|
||||
"group_id": args.test_group_id,
|
||||
"content": f"[私域群消息验收] {stamp}",
|
||||
}), ok_success_or_gate)
|
||||
else:
|
||||
rows.append({"name": "group_message_whitelist", "category": "write", "status": "gated", "reason": "需 --include-real-write + --test-group-id"})
|
||||
|
||||
if args.include_real_write and args.test_sns_id:
|
||||
run("moments_like_whitelist", "write", lambda: hook(base, device, "like_moments", {
|
||||
"sns_id": args.test_sns_id,
|
||||
}, hook_only=False), ok_success_or_gate)
|
||||
else:
|
||||
rows.append({"name": "moments_like_whitelist", "category": "write", "status": "gated", "reason": "需 --include-real-write + --test-sns-id"})
|
||||
|
||||
passed = sum(1 for r in rows if r["status"] == "passed")
|
||||
failed = sum(1 for r in rows if r["status"] == "failed")
|
||||
gated = sum(1 for r in rows if r["status"] == "gated")
|
||||
report = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"base": base,
|
||||
"device_id": device,
|
||||
"summary": {"passed": passed, "failed": failed, "gated": gated, "total": len(rows)},
|
||||
"results": rows,
|
||||
}
|
||||
path = out_dir / f"private_domain_acceptance_{stamp}.json"
|
||||
path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"\n私域能力验收: passed={passed} failed={failed} gated={gated} -> {path}")
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
98
sdk/scripts/run_wechat_realdevice_acceptance.sh
Executable file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env bash
|
||||
# 真机微信全功能 + 截图 一键验收
|
||||
# 目的:设备回线后,一条命令完成「读/发/转发 + 真机截图」并落盘证据(含 PNG 图片)。
|
||||
# 真机铁律:只认 SDK :8899 上 WS Agent 在线的真机;离线直接 exit 2,不伪造。
|
||||
#
|
||||
# 用法:
|
||||
# WORKPHONE_SDK_URL=http://127.0.0.1:8899 DEVICE_ID=xgfe65eimrrofyws \
|
||||
# bash sdk/scripts/run_wechat_realdevice_acceptance.sh
|
||||
#
|
||||
# 可选:TO_ID=filehelper(发/转发目标) INCLUDE_FORWARD=1 EVIDENCE_DIR=...
|
||||
set -uo pipefail
|
||||
|
||||
SDK="${WORKPHONE_SDK_URL:-http://127.0.0.1:8899}"
|
||||
DEVICE_ID="${DEVICE_ID:-xgfe65eimrrofyws}"
|
||||
TO_ID="${TO_ID:-filehelper}"
|
||||
INCLUDE_FORWARD="${INCLUDE_FORWARD:-1}"
|
||||
TS="$(date +%Y%m%d_%H%M%S)"
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
EVIDENCE_DIR="${EVIDENCE_DIR:-$REPO_ROOT/开发文档/8、部署/05-测试验收/${TS%_*}_工作手机微信全功能}"
|
||||
mkdir -p "$EVIDENCE_DIR"
|
||||
|
||||
PASS=0; FAIL=0
|
||||
ok(){ echo "PASS: $1"; PASS=$((PASS+1)); }
|
||||
bad(){ echo "FAIL: $1"; FAIL=$((FAIL+1)); }
|
||||
|
||||
jq_get(){ python3 -c "import sys,json;d=json.load(sys.stdin);print(eval('d'+sys.argv[1]) if d else '')" "$1" 2>/dev/null; }
|
||||
|
||||
echo "==> 真机门禁:$SDK device=$DEVICE_ID"
|
||||
HEALTH="$(curl -sS -m 10 "$SDK/health" 2>/dev/null)"
|
||||
ONLINE="$(printf '%s' "$HEALTH" | python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('devices_online',0))" 2>/dev/null || echo 0)"
|
||||
if [[ "${ONLINE:-0}" -lt 1 ]]; then
|
||||
echo "ABORT: SDK devices_online=$ONLINE,设备未在线(请解锁屏幕/确保 Termux Agent 连 ws://<sdk>/ws/device/$DEVICE_ID)。真机铁律:不伪造验收。"
|
||||
echo "$HEALTH" > "$EVIDENCE_DIR/health_offline_$TS.json"
|
||||
exit 2
|
||||
fi
|
||||
ok "SDK devices_online=$ONLINE"
|
||||
|
||||
exec_wx(){ # action params_json -> stdout json
|
||||
curl -sS -m 30 -X POST "$SDK/api/v3/wechat/execute" -H "Content-Type: application/json" \
|
||||
-d "{\"device_id\":\"$DEVICE_ID\",\"action\":\"$1\",\"params\":$2}" 2>/dev/null
|
||||
}
|
||||
|
||||
# 1) 截图(WS 优先,adb 兜底),解码 PNG —— 图片验收证据
|
||||
echo "==> 截图(WS Agent)"
|
||||
SHOT_JSON="$EVIDENCE_DIR/screenshot_${TS}.json"
|
||||
curl -sS -m 25 -X POST "$SDK/api/v3/devices/$DEVICE_ID/screenshot" -H "Content-Type: application/json" -d '{}' -o "$SHOT_JSON" 2>/dev/null
|
||||
PNG="$EVIDENCE_DIR/wechat_screen_${TS}.png"
|
||||
if python3 - "$SHOT_JSON" "$PNG" <<'PY'
|
||||
import sys,json,base64,os
|
||||
src,out=sys.argv[1],sys.argv[2]
|
||||
try: d=json.load(open(src))
|
||||
except Exception: sys.exit(1)
|
||||
b=((d.get('data') or {}).get('base64') or '')
|
||||
if not b: sys.exit(1)
|
||||
open(out,'wb').write(base64.b64decode(b.split(',')[-1]))
|
||||
print(os.path.getsize(out))
|
||||
PY
|
||||
then ok "截图 PNG -> $PNG"; else
|
||||
# adb 兜底
|
||||
if command -v adb >/dev/null && adb -s "$DEVICE_ID" get-state >/dev/null 2>&1; then
|
||||
adb -s "$DEVICE_ID" exec-out screencap -p > "$PNG" 2>/dev/null && [[ -s "$PNG" ]] && ok "截图 PNG(adb) -> $PNG" || bad "截图失败(adb)"
|
||||
else
|
||||
bad "截图失败(WS 超时 + 无 adb 连接)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2) 读类
|
||||
echo "==> 读类"
|
||||
exec_wx get_profile '{}' > "$EVIDENCE_DIR/profile_${TS}.json"
|
||||
[[ "$(jq_get "['data']['success']" < "$EVIDENCE_DIR/profile_${TS}.json")" == "True" ]] && ok "get_profile" || bad "get_profile"
|
||||
exec_wx get_contacts '{"limit":5}' > "$EVIDENCE_DIR/contacts_${TS}.json"
|
||||
[[ "$(jq_get "['data']['success']" < "$EVIDENCE_DIR/contacts_${TS}.json")" == "True" ]] && ok "get_contacts" || bad "get_contacts"
|
||||
exec_wx get_messages "{\"limit\":3,\"talker\":\"$TO_ID\"}" > "$EVIDENCE_DIR/messages_${TS}.json"
|
||||
[[ "$(jq_get "['data']['success']" < "$EVIDENCE_DIR/messages_${TS}.json")" == "True" ]] && ok "get_messages" || bad "get_messages"
|
||||
|
||||
# 3) 发送(真机 Frida send_msg_event,verified 才算通过)
|
||||
echo "==> 发送 send_message -> $TO_ID"
|
||||
SEND="$(exec_wx send_message "{\"to_id\":\"$TO_ID\",\"content\":\"[真机验收] $TS\"}")"
|
||||
echo "$SEND" > "$EVIDENCE_DIR/send_message_${TS}.json"
|
||||
MID="$(printf '%s' "$SEND" | python3 -c "import sys,json;d=json.load(sys.stdin);print((d.get('data') or {}).get('message_id',''))" 2>/dev/null)"
|
||||
VER="$(printf '%s' "$SEND" | python3 -c "import sys,json;d=json.load(sys.stdin);print((d.get('data') or {}).get('verified',False))" 2>/dev/null)"
|
||||
[[ -n "$MID" && "$VER" == "True" ]] && ok "send_message verified msg_id=$MID" || bad "send_message verified=$VER mid=$MID"
|
||||
|
||||
# 4) 转发(可选)
|
||||
if [[ "$INCLUDE_FORWARD" == "1" && -n "$MID" ]]; then
|
||||
echo "==> 转发 forward_message msg_svr_id=$MID -> $TO_ID"
|
||||
FWD="$(exec_wx forward_message "{\"msg_svr_id\":\"$MID\",\"to_id\":\"$TO_ID\"}")"
|
||||
echo "$FWD" > "$EVIDENCE_DIR/forward_message_${TS}.json"
|
||||
FVER="$(printf '%s' "$FWD" | python3 -c "import sys,json;d=json.load(sys.stdin);print((d.get('data') or {}).get('verified',False))" 2>/dev/null)"
|
||||
[[ "$FVER" == "True" ]] && ok "forward_message verified" || bad "forward_message verified=$FVER"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "==> 证据目录:$EVIDENCE_DIR"
|
||||
echo "==> 结果:PASS=$PASS FAIL=$FAIL"
|
||||
[[ "$FAIL" -gt 0 ]] && exit 1
|
||||
echo "==> 真机微信全功能验收通过"
|
||||
exit 0
|
||||
65
sdk/scripts/run_wechat_safe_batch_acceptance.sh
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# 微信安全批量验收:仅对白名单测试联系人执行,默认 dry-run。
|
||||
set -euo pipefail
|
||||
|
||||
DEVICE="${SDK_DEVICE_ID:-xgfe65eimrrofyws}"
|
||||
BASE="${SDK_BASE_URL:-http://127.0.0.1:8899}"
|
||||
TO_IDS_RAW="${WORKPHONE_TEST_TO_IDS:-}"
|
||||
CONTENT="${WORKPHONE_TEST_CONTENT:-[工作手机白名单批量验收] $(date +%Y%m%d-%H%M%S)}"
|
||||
INTERVAL="${WORKPHONE_BATCH_INTERVAL:-65}"
|
||||
DRY_RUN="${WORKPHONE_DRY_RUN:-1}"
|
||||
MAX_COUNT="${WORKPHONE_MAX_COUNT:-20}"
|
||||
|
||||
if [[ -z "${TO_IDS_RAW}" ]]; then
|
||||
echo "缺少 WORKPHONE_TEST_TO_IDS。示例:"
|
||||
echo " WORKPHONE_TEST_TO_IDS='filehelper,wxid_test_1' WORKPHONE_DRY_RUN=1 bash sdk/scripts/run_wechat_safe_batch_acceptance.sh"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a TO_IDS <<< "${TO_IDS_RAW}"
|
||||
if (( ${#TO_IDS[@]} > MAX_COUNT )); then
|
||||
echo "白名单数量 ${#TO_IDS[@]} 超过上限 ${MAX_COUNT},拒绝执行。"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
TS="$(date +%Y%m%d-%H%M%S)"
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
EVID_DIR="${ROOT}/开发文档/8、部署/05-测试验收/$(date +%Y%m%d)_微信安全批量验收"
|
||||
mkdir -p "${EVID_DIR}"
|
||||
REPORT="${EVID_DIR}/safe_batch_${TS}.jsonl"
|
||||
|
||||
probe="$(curl -sS --max-time 40 "${BASE}/api/v3/hook/probe/${DEVICE}")"
|
||||
supports="$(echo "${probe}" | jq -r '.supports_hook // false' 2>/dev/null || echo false)"
|
||||
if [[ "${supports}" != "true" ]]; then
|
||||
echo "Hook 未就绪,拒绝批量验收。"
|
||||
echo "${probe}" > "${EVID_DIR}/probe_${TS}.json"
|
||||
exit 4
|
||||
fi
|
||||
|
||||
echo "设备=${DEVICE} count=${#TO_IDS[@]} interval=${INTERVAL}s dry_run=${DRY_RUN}"
|
||||
for to_id in "${TO_IDS[@]}"; do
|
||||
to_id="$(echo "${to_id}" | xargs)"
|
||||
[[ -z "${to_id}" ]] && continue
|
||||
if [[ "${DRY_RUN}" == "1" ]]; then
|
||||
row="$(jq -cn \
|
||||
--arg to_id "$to_id" \
|
||||
--arg content "$CONTENT" \
|
||||
--argjson timestamp "$(date +%s)" \
|
||||
'{dry_run:true,to_id:$to_id,content:$content,timestamp:$timestamp}')"
|
||||
echo "$row" | tee -a "$REPORT"
|
||||
continue
|
||||
fi
|
||||
|
||||
resp="$(curl -sS --max-time 120 -X POST "${BASE}/api/v3/message/send" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"device_id\":\"${DEVICE}\",\"platform\":\"wechat\",\"to_id\":\"${to_id}\",\"content\":\"${CONTENT}\",\"msg_type\":\"text\",\"channel\":\"hook\"}")"
|
||||
row="$(jq -cn \
|
||||
--arg to_id "$to_id" \
|
||||
--arg raw "$resp" \
|
||||
--argjson timestamp "$(date +%s)" \
|
||||
'{dry_run:false,to_id:$to_id,response:(try ($raw|fromjson) catch {raw:$raw}),timestamp:$timestamp}')"
|
||||
echo "$row" | tee -a "$REPORT"
|
||||
sleep "${INTERVAL}"
|
||||
done
|
||||
|
||||
echo "报告: ${REPORT}"
|
||||