feat: publish workphone public SDK deployment and API docs

This commit is contained in:
Manus AI
2026-07-14 18:03:20 +08:00
parent 0bd2ae2e81
commit 8c759fad17
145 changed files with 9243 additions and 111 deletions

55
.cursorignore Normal file
View 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
View File

@@ -0,0 +1,8 @@
# 仅排除索引搜索Agent 仍可 @ 读取(工作手机反编译目录)
资料/**
sdk/tmp/**
sdk/scripts/gadget_work/**
**/jadx_out/**
**/wechat_decompiled/**
**/smali/**
**/smali_classes*/**

14
.gitignore vendored
View File

@@ -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
View 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

View File

@@ -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/*

View 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前。"
}

View File

@@ -0,0 +1,59 @@
"""
设备端 SDK 自动发现 — 监听 UDP beacon8898无线主控免手填 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

View File

@@ -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\""
}

View File

@@ -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>

View File

@@ -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})")
}
}

View File

@@ -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))

View File

@@ -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?> {

View File

@@ -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() {

View File

@@ -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

View File

@@ -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())

View File

@@ -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,7 +99,14 @@ class AgentForegroundService : Service() {
isRunning = true
startForeground(NOTIFICATION_ID, createNotification("正在连接..."))
initAndStartEngine(serverUrl, projectId, deviceId)
synchronized(this) {
if (engine != null || engineStartJob?.isActive == true) {
Logger.i("AgentEngine 已存在或正在启动,忽略重复 START仅触发重连")
engine?.onNetworkAvailable()
} else {
initAndStartEngine(serverUrl, projectId, deviceId)
}
}
}
ACTION_STOP -> {
engine?.stop()
@@ -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)

View File

@@ -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()

View File

@@ -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) {

View File

@@ -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 或未拉起)"
}

View File

@@ -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) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

View File

@@ -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" />

View File

@@ -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"

View File

@@ -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 -->

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1 @@
# 设备端 Agent 模块包ai_brain、skills 等)

View File

@@ -0,0 +1,59 @@
"""
设备端 SDK 自动发现 — 监听 UDP beacon8898无线主控免手填 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

View File

@@ -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}"

View File

@@ -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

View 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
}

View File

@@ -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": {

View File

@@ -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:

View File

@@ -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()

View File

@@ -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,
},
}),
}

View 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="目标连接方案 idjiqing / 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}

View File

@@ -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
View 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,
},
}

View 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

View 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()}

View File

@@ -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 页面"""

View File

@@ -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")
# 系统提示词

View 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,
}

View File

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

View File

@@ -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)

View File

@@ -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

View 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 + 设备本机 FridaHook
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否则 offlineu2 专属动作除外)。
"""
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 SessionAndroid/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()

View 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 类不可用",
},
}

View 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_keysdk/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

View 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",
},
}

View File

@@ -0,0 +1,96 @@
"""宝塔侧 Frida RPC 桥。
手机通过认证的反向隧道暴露 frida-serverSDK 容器在本模块中持有真正的
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()

View 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

View File

@@ -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;

View File

@@ -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
View 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.

View 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;
}

View 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

View 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>

View 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

View 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"

View File

@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""
奥创 SDK 通信协议(客服通信接口总目录 · EnumMsgType 指令类 Task↔ 工作手机 SDK action 覆盖矩阵。
目的:对照奥创 proto 的服务端→设备「指令类任务」(1070-1270),核对本 SDK 是否已具备
可下发的等价 actionACTION_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 期望 actionNone = 缺口)
# 依据 资料/工作手机客服通信接口总目录(带通信协议)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())

View 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)等手机重启Agentretry ${INTERVAL}s" >> "$LOG"
sleep "$INTERVAL"
done
echo "[auto-accept] timeout, frida not ready $(date)" | tee -a "$LOG"
exit 1

View 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
View 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 为主连接写入 configpublic_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="手机 OTANAS→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())

View 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 rebootMagisk 模块变更后)"
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

View 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

View 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"

View File

@@ -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' \

View File

@@ -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

View 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 ADBexecute/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 在手机本机运行Rootphantom 端口见 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

View 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"

View 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"

View 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
View 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

View 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}"

View 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 端阻塞运行 fridaadb 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

View 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)

View 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));
}
}

View 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())

View 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.2filehelper 自发自收 + 列表断言)─────────────
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.3hook 直连,绕过 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)

View 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 "✅ 全量验收流水线完成"

View 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;

View 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())

View 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_eventverified 才算通过)
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

View 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}"

Some files were not shown because too many files have changed in this diff Show More