fix(wp-wx-04): improve scan probe and u2 fallback

This commit is contained in:
Manus AI
2026-06-28 06:09:29 +08:00
parent ad477806be
commit 9947c02c63
6 changed files with 1594 additions and 204 deletions

View File

@@ -57,8 +57,10 @@ import signal
import time
import base64
import random
import subprocess
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, Callable, List
# 确保 agent/ 目录在 sys.path 中,方便 Termux 等环境直接运行
_AGENT_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -110,12 +112,24 @@ class WorkPhoneAgent:
heartbeat_interval: int = 30,
project_id: str = "default",
ai_config: Optional[Dict[str, Any]] = None,
public_servers: Optional[list] = None,
):
self.device_id = device_id
# 传输序列u2/frida 连接所用的 adb 目标,可与逻辑 device_id 分离。
# adb-over-WiFi 无 USB 主控时设 WP_DEVICE_SERIAL=ip:5555如 192.168.110.80:5555
# WS 身份仍用 device_id保持 SDK/hub 设备身份不变u2/frida 走 WiFi adb。
self.adb_serial = os.environ.get("WP_DEVICE_SERIAL", "").strip() or device_id
self.server_url = server_url
self.heartbeat_interval = heartbeat_interval
self.project_id = project_id
# BIND-03 公网主服有序回退:主连接 + 公网候选(断 LAN 后逐个健康探测)
self.public_servers = [s for s in (public_servers or []) if s]
self.server_candidates = self._build_server_candidates(server_url, self.public_servers, device_id)
self._candidate_idx = 0
# BIND-07 当前寻服阶段lan/primary/public/retry供日志与上报
self.connect_stage = "primary"
self.ws: Optional[websockets.WebSocketClientProtocol] = None
self.running = False
self.connected = False
@@ -125,6 +139,11 @@ class WorkPhoneAgent:
self.start_time = None
self._loop: Optional[asyncio.AbstractEventLoop] = None
# u2 非线程安全:命令与连接守护必须串行,否则长任务期间 ATX 挂死 → WS 断连
self._u2_lock = asyncio.Lock()
self._u2_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="wp-u2")
self._cmd_semaphore = asyncio.Semaphore(1)
# 设备控制通道u2 优先,降级 LocalDevice
self.d = None
self._init_u2()
@@ -160,28 +179,66 @@ class WorkPhoneAgent:
f"{'' if self.touch_hardener else ''} Touch")
logger.info(f" AI Brain: {'✅ 已启用' if self.ai_brain else '⬜ 未启用'}")
def _running_on_device(self) -> bool:
"""True = Termux 等手机本机运行False = Mac/PC 通过 ADB 控制"""
import os
if os.environ.get("WP_AGENT_ON_DEVICE", "").strip().lower() in ("1", "true", "yes"):
return True
return os.path.exists("/data/data/com.termux/files/usr/bin")
def _adb_serial_candidates(self) -> List[str]:
"""优先用显式指定串口;掉线时自动回退逻辑 device_id通常是 USB serial"""
serials: List[str] = []
for serial in (self.adb_serial, self.device_id):
serial = (serial or "").strip()
if serial and serial not in serials:
serials.append(serial)
return serials
def _connect_u2_with_fallback(self):
"""u2 连接:优先 WP_DEVICE_SERIAL失败则回退 device_id。"""
last_error = None
for serial in self._adb_serial_candidates():
try:
d = u2.connect(serial)
d.implicitly_wait(10.0)
d.settings['operation_delay'] = (0, 0)
d.settings['operation_delay_methods'] = []
if serial != self.adb_serial:
logger.info(f"u2 串口自动回退: {self.adb_serial} -> {serial}")
self.adb_serial = serial
return d
except Exception as e:
last_error = e
raise last_error or RuntimeError("u2 connect failed")
def _init_u2(self):
"""
初始化设备控制(优先 u2降级到 LocalDevice
连接策略:
1. 有 ADB 环境 → u2.connect(serial)
2. ADB 不可用 → LocalDeviceATX HTTP 直连)
3. ATX 也不可用 → self.d 保持 None运行时重试
2. 仅手机本机Termux→ LocalDeviceATX HTTP 直连)
3. Mac/PC 无 ADB → self.d 保持 None守护循环定期重试 u2
"""
import shutil
has_adb = shutil.which("adb") is not None
if has_adb and u2:
try:
self.d = u2.connect(self.device_id)
self.d.implicitly_wait(10.0)
self.d.settings['operation_delay'] = (0, 0)
self.d.settings['operation_delay_methods'] = []
logger.info(f"u2 通过 ADB 连接: {self.d.info.get('productName', 'Unknown')}")
self.d = self._connect_u2_with_fallback()
logger.info(f"u2 通过 ADB 连接({self.adb_serial}): {self.d.info.get('productName', 'Unknown')}")
return
except Exception as e:
logger.info(f"u2 ADB 连接失败: {e},尝试 LocalDevice")
logger.info(f"u2 ADB 连接失败: {e}")
if not self._running_on_device():
logger.warning(
"Mac/PC Agent等待 ADB 设备(请 USB 授权或 adb connect不降级 LocalDevice"
)
return
if not self._running_on_device():
return
try:
from local_device import LocalDevice
@@ -198,17 +255,22 @@ class WorkPhoneAgent:
import json as _json
_mode = (_os.environ.get("WP_FRIDA_MODE") or "").strip().lower()
_serial = _os.environ.get("WP_DEVICE_SERIAL") or None
_serial = self.adb_serial # = WP_DEVICE_SERIAL or device_idadb-over-WiFi 时为 ip:5555
_frida_port = int(_os.environ.get("WP_FRIDA_PORT") or "0")
# 自动读取 phantom frida-server 配置(反检测随机端口)
_on_device = self._running_on_device()
_use_adb_forward = not _on_device
if not _mode or _mode == "auto":
_mode = "gadget"
for _cfg_path in (
_mode = "remote" if _on_device else "gadget"
_cfg_candidates = [
_os.path.join(_os.path.dirname(__file__), "..", "..", "scripts", "anti_detect", "phantom_frida_config.json"),
_os.path.join(_os.path.dirname(__file__), "..", "scripts", "anti_detect", "phantom_frida_config.json"),
"/Users/karuo/Documents/开发/2、私域银行/工作手机/sdk/scripts/anti_detect/phantom_frida_config.json",
):
"/data/data/com.termux/files/home/workphone/phantom_frida_config.json",
"/sdcard/workphone/phantom_frida_config.json",
]
for _cfg_path in _cfg_candidates:
_cfg_path = _os.path.abspath(_cfg_path)
if not _os.path.isfile(_cfg_path):
continue
@@ -216,7 +278,7 @@ class WorkPhoneAgent:
with open(_cfg_path, "r", encoding="utf-8") as _f:
_pc = _json.load(_f)
_ps = (_pc.get("device_serial") or "").strip()
if _ps and (_ps == self.device_id or not _serial):
if _ps and (_ps == self.adb_serial or not _serial):
_serial = _ps or _serial
if _pc.get("listen_port"):
_frida_port = int(_pc["listen_port"])
@@ -226,15 +288,22 @@ class WorkPhoneAgent:
except Exception as _e:
logger.debug(f"读取 phantom 配置失败 {_cfg_path}: {_e}")
if _on_device and _mode in ("gadget", "remote"):
_mode = "remote"
if not _frida_port:
_frida_port = 10431
if _mode not in ("usb", "gadget", "remote"):
_mode = "gadget"
_mode = "remote" if _on_device else "gadget"
if not _serial and _mode == "usb":
_serial = self.device_id
_serial = self.adb_serial
self.frida_mgr = FridaManager(
device_serial=_serial,
mode=_mode,
gadget_port=_frida_port if _frida_port else 0,
gadget_host="127.0.0.1",
use_adb_forward=_use_adb_forward,
on_event=self._on_frida_event,
auto_reconnect=True,
)
@@ -331,11 +400,75 @@ class WorkPhoneAgent:
# 一、连接管理(核心:主动连接 + 心跳保活 + 指数退避重连)
# ====================================================================
@staticmethod
def _build_server_candidates(primary_url: str, public_servers: list, device_id: str) -> list:
"""BIND-03构建有序候选 WS 列表 [主连接, 公网1, 公网2, ...],去重。
public_servers 元素可为含/不含 /ws/device 的基址,自动补全 + 拼 device_id。"""
def _full(u: str) -> str:
u = u.strip().rstrip("/")
if not u:
return ""
# 已经带 device_id结尾段不是 ws/device 关键字)则原样
if u.endswith(f"/{device_id}"):
return u
if "/ws/device" not in u:
u = f"{u}/ws/device"
return f"{u}/{device_id}"
candidates = []
for u in [primary_url] + list(public_servers or []):
full = _full(u)
if full and full not in candidates:
candidates.append(full)
return candidates or [primary_url]
@staticmethod
def _probe_ws_base(ws_url: str, timeout: float = 3.0) -> bool:
"""BIND-03 健康探测:对候选 WS 的 host:port 做一次 TCP 连接,可达才尝试 WS 握手。"""
import socket
from urllib.parse import urlparse
try:
p = urlparse(ws_url)
host = p.hostname
port = p.port or (443 if p.scheme == "wss" else 8899)
if not host:
return False
with socket.create_connection((host, port), timeout=timeout):
return True
except Exception:
return False
def _select_reachable_candidate(self) -> Optional[str]:
"""从当前候选起,按序探测,返回第一个 TCP 可达的候选并更新阶段标记。"""
n = len(self.server_candidates)
if n == 0:
return self.server_url
for offset in range(n):
idx = (self._candidate_idx + offset) % n
cand = self.server_candidates[idx]
stage = "primary" if idx == 0 else "public"
if self._probe_ws_base(cand):
self._candidate_idx = idx
self.connect_stage = stage
if idx != 0:
logger.info(f"🌐 [寻服:公网回退] 候选 {idx+1}/{n} 可达 → {cand}")
return cand
logger.info(f"🔎 [寻服:探测] 候选 {idx+1}/{n} 不可达 → {cand}")
# 全部不可达:返回主连接让 websockets 自行报错并触发重连/重发现
self._candidate_idx = 0
self.connect_stage = "retry"
return self.server_candidates[0]
async def connect(self):
"""主连接循环 - 永不停止断连时切换离线AI自主模式"""
while self.running:
try:
logger.info(f"📡 正在连接服务器: {self.server_url}")
# BIND-03/07按候选有序探测选第一个可达全不可达则回主连接重试
if len(self.server_candidates) > 1:
chosen = self._select_reachable_candidate()
if chosen:
self.server_url = chosen
logger.info(f"📡 [寻服:{self.connect_stage}] 正在连接服务器: {self.server_url}")
# 重连成功 → 停止离线自主模式
self._stop_autonomous_mode()
@@ -415,6 +548,13 @@ class WorkPhoneAgent:
if self.running:
self.reconnect_attempts += 1
# BIND-03/07连接失败 → 轮换到下一个候选服务器(公网回退链)
if len(self.server_candidates) > 1:
self._candidate_idx = (self._candidate_idx + 1) % len(self.server_candidates)
nxt = self.server_candidates[self._candidate_idx]
self.connect_stage = "primary" if self._candidate_idx == 0 else "public"
logger.info(f"🔁 [寻服:{self.connect_stage}] 切换候选 {self._candidate_idx+1}/{len(self.server_candidates)}{nxt}")
# 断连后启动离线自主模式AI Brain 接管)
self._start_autonomous_mode()
@@ -482,6 +622,18 @@ class WorkPhoneAgent:
# 添加随机抖动避免雪崩
jitter = random.uniform(0, delay * 0.2)
return delay + jitter
async def _run_u2_sync(self, fn: Callable[[], Any]) -> Any:
"""单线程池 + 互斥锁执行 u2 同步 I/O保证长任务期间连接守护仍可排队而不并发打挂 ATX。"""
async with self._u2_lock:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(self._u2_executor, fn)
async def _run_frida_sync(self, fn: Callable[[], Any]) -> Any:
"""Frida RPC/reload 放线程池,避免阻塞 asyncio 导致 WS 心跳发不出被 sweeper 踢线。"""
async with self._u2_lock:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(self._u2_executor, fn)
async def _register(self):
"""发送设备注册信息"""
@@ -556,7 +708,6 @@ class WorkPhoneAgent:
await asyncio.sleep(60)
U2_FORCE_ACTIONS = frozenset({
"check_login_state",
"login_by_password",
"ensure_logged_in",
"unblock_via_customer_service",
@@ -564,6 +715,73 @@ class WorkPhoneAgent:
"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",
"send_red_packet",
"receive_red_packet",
"send_transfer",
"receive_transfer",
"show_payment_code",
"receive_payment",
"get_wallet_balance",
"get_transaction_history",
"generate_my_qr_code",
"generate_group_qr_code",
"scan_qr_code",
"add_friend_by_qr",
"extract_qr_from_image",
"add_friend_from_image",
"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
# DB 取原文复用 _sendMessageInternal / 撤回资格预检),不再强制 u2走 Frida 主通道
"get_official_account_articles",
"reply_comment",
})
async def _execute_with_frida_priority(
@@ -597,30 +815,40 @@ class WorkPhoneAgent:
self._init_frida()
# 通道1: Frida Hook仅微信脚本有 wechat_hook RPC 映射)
frida_result = None
frida_err = ""
if self.frida_mgr and self.frida_mgr.connected and script == "wechat":
try:
from hook.hook_executor import HookExecutor
executor = HookExecutor(self.frida_mgr)
frida_result = executor.execute(action, params)
frida_result = await self._run_frida_sync(
lambda: executor.execute(action, params)
)
if frida_result and frida_result.get("success", False):
logger.debug(f"[Frida] {script}.{action} 执行成功")
return {"code": 200, "data": frida_result, "channel": "websocket/frida"}
logger.debug(f"[Frida] {script}.{action} 执行失败,降级 u2")
frida_err = str((frida_result or {}).get("error") or (frida_result or {}).get("message") or "RPC 返回 success=false")
logger.debug(f"[Frida] {script}.{action} 执行失败: {frida_err},降级 u2")
except ImportError:
pass
frida_err = "hook_executor 不可用"
except Exception as e:
frida_err = str(e)
logger.warning(f"[Frida] {script}.{action} 异常: {e},降级 u2")
if hook_only:
# 真机铁律 #3/#14诚实透传 Frida 原始失败原因(方法不存在 / RPC 异常 / success=false
return {
"code": 503,
"message": "Hook 不可用或 RPC 未成功hook_only 禁止 u2/ADB 降级)",
"message": f"Hook 未成功hook_only 禁止降级): {frida_err or '微信 Frida 未附着'}",
"data": {
"script": script,
"action": action,
"success": False,
"frida_connected": bool(
self.frida_mgr and self.frida_mgr.connected
),
"frida_error": frida_err,
"frida_result": frida_result,
},
}
@@ -646,13 +874,16 @@ class WorkPhoneAgent:
if not self.connected or not self.ws:
break
status = self._get_quick_status()
status["u2"] = bool(self.d)
heartbeat_data = {
"type": "heartbeat",
"timestamp": int(time.time()),
"device_id": self.device_id,
"uptime": int(time.time() - self.start_time) if self.start_time else 0,
"commands_executed": self.commands_executed,
"status": {"online": True, "u2": bool(self.d)},
"status": status,
}
await self.ws.send(json.dumps(heartbeat_data))
@@ -683,16 +914,26 @@ class WorkPhoneAgent:
if has_adb and u2:
try:
d = u2.connect(self.device_id)
d.implicitly_wait(10.0)
d.settings['operation_delay'] = (0, 0)
d.settings['operation_delay_methods'] = []
d = self._connect_u2_with_fallback()
self.d = d
self._connection_guard = None
logger.info(f"🔄 u2 重连成功: {d.info.get('productName', 'Unknown')}")
logger.info(f"🔄 u2 重连成功({self.adb_serial}): {d.info.get('productName', 'Unknown')}")
# G2注册常早于 u2 就绪model=Unknownu2 连上后重发一次注册补真实硬件指纹
if not getattr(self, "_hw_registered", False) and self.connected and self.ws:
self._hw_registered = True
try:
import asyncio as _a
_a.get_running_loop().create_task(self._register())
logger.info("🔄 u2 就绪,重发注册补硬件指纹 (G2)")
except Exception as _re:
logger.debug(f"G2 重注册调度失败: {_re}")
return
except Exception:
pass
if not self._running_on_device():
return
if not self._running_on_device():
return
try:
from local_device import LocalDevice
@@ -748,16 +989,27 @@ class WorkPhoneAgent:
break
scan_count += 1
result = await asyncio.get_event_loop().run_in_executor(
None, guard.dismiss_popups
)
if result.get("count", 0) > 0:
logger.info(f"🛡️ 守护扫描 #{scan_count}: 处理了 {result['count']} 个弹窗")
if self.ws and self.connected:
await self._send_event("popup_dismissed", {
"dismissed": result.get("dismissed", []),
"scan_count": scan_count,
})
if scan_count % 6 == 0:
result = await self._run_u2_sync(guard.full_guard_cycle)
popup_count = (result.get("popups") or {}).get("count", 0)
if not result.get("all_ok", True):
logger.warning(f"🛡️ 完整守护 #{scan_count}: 连接异常 {result}")
if popup_count > 0:
logger.info(f"🛡️ 完整守护 #{scan_count}: 处理了 {popup_count} 个弹窗")
if self.ws and self.connected:
await self._send_event("popup_dismissed", {
"dismissed": (result.get("popups") or {}).get("dismissed", []),
"scan_count": scan_count,
})
else:
result = await self._run_u2_sync(guard.dismiss_popups)
if result.get("count", 0) > 0:
logger.info(f"🛡️ 守护扫描 #{scan_count}: 处理了 {result['count']} 个弹窗")
if self.ws and self.connected:
await self._send_event("popup_dismissed", {
"dismissed": result.get("dismissed", []),
"scan_count": scan_count,
})
except asyncio.CancelledError:
break
@@ -790,6 +1042,8 @@ class WorkPhoneAgent:
def _get_quick_status(self) -> dict:
"""获取快速状态摘要(心跳用,低开销)"""
status = {"online": True}
# BIND-07心跳附带寻服阶段供中台实时显示连接来源lan/primary/public/retry
status["connect_stage"] = getattr(self, "connect_stage", "primary")
if self.d:
try:
@@ -825,7 +1079,9 @@ class WorkPhoneAgent:
try:
battery_out = self.d.shell("dumpsys battery | grep level").output.strip()
if "level:" in battery_out:
status["battery"] = int(battery_out.split(":")[1].strip())
level = int(battery_out.split(":")[1].strip())
status["battery"] = level
status["battery_level"] = level
except Exception:
pass
@@ -952,27 +1208,29 @@ class WorkPhoneAgent:
logger.info(f"📩 收到命令: {msg_type} (id={command_id})")
if msg_type == "execute":
cmd_data = dict(data.get("data") or {})
# 兼容 ws_hub Android 扁平协议action/params/script 在顶层
for _k in ("script", "action", "params", "channel", "hook_only"):
if _k not in cmd_data and _k in data:
cmd_data[_k] = data[_k]
result = await self._execute_command(cmd_data)
self.commands_executed += 1
await self._send_response(command_id, result)
# 技能执行完毕后上报事件,供服务端落库/转发
if cmd_data.get("script"):
await self._send_event("skill_done", {
"script": cmd_data.get("script"),
"action": cmd_data.get("action"),
"code": result.get("code", 200),
"success": result.get("code") == 200,
})
async with self._cmd_semaphore:
cmd_data = dict(data.get("data") or {})
# 兼容 ws_hub Android 扁平协议action/params/script 在顶层
for _k in ("script", "action", "params", "channel", "hook_only"):
if _k not in cmd_data and _k in data:
cmd_data[_k] = data[_k]
result = await self._execute_command(cmd_data)
self.commands_executed += 1
await self._send_response(command_id, result)
# 技能执行完毕后上报事件,供服务端落库/转发
if cmd_data.get("script"):
await self._send_event("skill_done", {
"script": cmd_data.get("script"),
"action": cmd_data.get("action"),
"code": result.get("code", 200),
"success": result.get("code") == 200,
})
elif msg_type == "agent_execute":
result = await self._execute_agent_task(data.get("data", {}))
self.commands_executed += 1
await self._send_response(command_id, result)
async with self._cmd_semaphore:
result = await self._execute_agent_task(data.get("data", {}))
self.commands_executed += 1
await self._send_response(command_id, result)
elif msg_type == "ai_task":
if self.ai_brain:
@@ -984,6 +1242,26 @@ class WorkPhoneAgent:
else:
await self._send_response(command_id, {"code": 503, "message": "AI Brain未启用"})
elif msg_type == "ai_chat":
if self.ai_brain:
task_data = data.get("data", {})
instruction = (
task_data.get("instruction", "")
or task_data.get("message", "")
or task_data.get("task", "")
).strip()
if not instruction:
await self._send_response(command_id, {"code": 400, "message": "instruction 不能为空"})
else:
result = await self.ai_brain.chat_and_execute(
instruction,
self._get_quick_status(),
self._execute_with_frida_priority,
)
await self._send_response(command_id, result)
else:
await self._send_response(command_id, {"code": 503, "message": "AI Brain未启用"})
elif msg_type == "standing_order":
if self.ai_brain:
order = data.get("data", {}).get("order", "")
@@ -993,6 +1271,85 @@ class WorkPhoneAgent:
elif msg_type == "config_update":
await self._handle_config_update(data.get("data", {}))
elif msg_type == "frida_reload":
if self.frida_mgr:
result = await self._run_frida_sync(self.frida_mgr.reload_script)
else:
self._init_frida()
if self.frida_mgr:
result = await self._run_frida_sync(self.frida_mgr.reload_script)
else:
result = {"success": False, "error": "Frida 未初始化"}
await self._send_response(command_id, {
"code": 200 if result.get("success") else 503,
"message": "Hook 脚本已重载" if result.get("success") else "Hook 脚本重载失败",
"data": result,
})
elif msg_type == "command":
action = data.get("action", "")
params = data.get("params") or {}
if action == "frida_connect":
if params.get("port"):
os.environ["WP_FRIDA_PORT"] = str(params.get("port"))
if params.get("mode"):
os.environ["WP_FRIDA_MODE"] = str(params.get("mode"))
self._init_frida()
connected = bool(self.frida_mgr and getattr(self.frida_mgr, "connected", False))
await self._send_response(command_id, {
"code": 200 if connected else 503,
"message": "Frida 已连接" if connected else "Frida 未初始化",
"data": {
"success": connected,
"wechat_attached": connected,
"supported_actions": 112 if connected else 0,
},
})
elif action == "frida_disconnect":
if self.frida_mgr:
self.frida_mgr.stop()
self.frida_mgr = None
await self._send_response(command_id, {
"code": 200,
"message": "Frida 已断开",
"data": {"success": True},
})
elif action == "frida_diagnostics":
diagnostics = {
"success": True,
"frida_connected": bool(self.frida_mgr and getattr(self.frida_mgr, "connected", False)),
"env_mode": os.environ.get("WP_FRIDA_MODE", ""),
"env_port": os.environ.get("WP_FRIDA_PORT", ""),
}
if self.d:
checks = {
"wechat_pid": "pidof com.tencent.mm || true",
"frida_pids": "pidof fs_3f823a frida-server || true",
"frida_listen": "cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep -i ':38C9\\|:69A2' || true",
"frida_binary": "ls -l /data/local/tmp/fs_3f823a /data/local/tmp/frida-server 2>/dev/null || true",
}
shell = {}
for key, cmd in checks.items():
try:
shell[key] = (self.d.shell(cmd, timeout=4).output or "").strip()
except Exception as e:
shell[key] = f"ERROR: {e}"
diagnostics["shell"] = shell
else:
diagnostics["success"] = False
diagnostics["error"] = "uiautomator2未连接"
await self._send_response(command_id, {
"code": 200 if diagnostics.get("success") else 503,
"message": "Frida 诊断完成" if diagnostics.get("success") else "Frida 诊断失败",
"data": diagnostics,
})
else:
await self._send_response(command_id, {
"code": 400,
"message": f"未知 command action: {action}",
"data": {"success": False, "action": action},
})
elif msg_type == "device_request_ack":
ack_data = data.get("data") or {}
if ack_data.get("heartbeat_interval") is not None:
@@ -1114,8 +1471,6 @@ class WorkPhoneAgent:
if not self.d:
return {"code": 503, "message": "uiautomator2未连接"}
loop = asyncio.get_running_loop()
def _run_basic():
if action == "screenshot":
return self._screenshot()
@@ -1161,7 +1516,7 @@ class WorkPhoneAgent:
else:
return {"code": 400, "message": f"未知操作: {action}"}
return await loop.run_in_executor(None, _run_basic)
return await self._run_u2_sync(_run_basic)
except Exception as e:
logger.error(f"执行命令错误: {e}")
@@ -1191,8 +1546,63 @@ class WorkPhoneAgent:
}
skill = skill_class(self.d, anti_ban_ctx=anti_ban_ctx)
# u2 分发别名SDK 标准 action 名 ↔ 设备端 skill 方法名不一致时转换
# 如二维码素材generate_my_qr_code 的真实现是 skill.show_my_qr
_U2_ACTION_ALIASES = {
"generate_my_qr_code": "show_my_qr",
"generate_group_qr_code": "show_group_qr",
"add_friend_by_qr": "scan_add_friend",
"set_friend_remark": "set_remark",
"revoke_message": "recall_message",
"search_contacts": "search_contact",
"get_contact_info": "get_friend_info",
"set_group_announcement": "set_group_notice",
"send_transfer": "transfer",
"receive_transfer": "receive_transfer",
"get_wallet_balance": "view_wallet",
"get_transaction_history": "view_transactions",
"show_payment_code": "show_payment_code",
"receive_payment": "receive_payment",
"batch_send": "mass_send",
"get_safety_center": "safety_center",
"get_top_stories": "top_stories",
"get_wechat_steps": "get_steps",
"like_wechat_steps": "like_steps",
"global_search": "wechat_search",
"get_recent_mini_programs": "get_recent_mini_programs",
"share_mini_program": "share_mini_program",
"send_voice": "send_voice_message",
"send_file": "send_file_from_chat",
"add_favorite": "add_to_favorites",
"delete_favorite": "delete_favorite",
"unfollow_official_account": "unfollow_official_account",
"add_custom_emoji": "add_custom_emoji",
"add_to_float": "add_to_float",
"remove_from_float": "remove_from_float",
"set_privacy": "set_moments_privacy",
"set_do_not_disturb": "set_mute_chat",
"pin_chat": "set_chat_top",
"set_sex": "set_gender",
"set_what_up": "set_status",
"get_login_devices": "get_login_devices",
"remove_login_device": "remove_login_device",
"enable_fingerprint": "enable_fingerprint",
"set_account_protection": "set_account_protection",
"browse_channels": "get_video_list",
"like_channel_video": "like_video",
"comment_channel_video": "comment_video",
"follow_channel": "follow_video_creator",
"unfollow_channel": "unfollow_video_creator",
"share_channel_video": "share_video",
"forward_multiple": "forward_multiple",
"get_official_account_articles": "get_official_account_articles",
"reply_comment": "reply_comment",
}
resolved_action = action
if not hasattr(skill, action) and action in _U2_ACTION_ALIASES:
resolved_action = _U2_ACTION_ALIASES[action]
# 调用方法
method = getattr(skill, action, None)
method = getattr(skill, resolved_action, None)
if not method:
return {"code": 404, "message": f"技能{script}不支持操作: {action}"}
@@ -1201,8 +1611,7 @@ class WorkPhoneAgent:
if inspect.iscoroutinefunction(method):
result = await method(**params)
else:
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, lambda: method(**params))
result = await self._run_u2_sync(lambda: method(**params))
return {"code": 200, "data": result}
@@ -1259,10 +1668,30 @@ class WorkPhoneAgent:
# ====================================================================
def _screenshot(self) -> dict:
"""截图"""
"""截图raw 不支持时回退 PNG bytes"""
try:
img = self.d.screenshot(format='raw')
b64 = base64.b64encode(img).decode('utf-8')
raw_bytes = None
serial = self.adb_serial
try:
raw_bytes = subprocess.check_output(
["adb", "-s", serial, "exec-out", "screencap", "-p"],
timeout=10,
)
except Exception:
raw_bytes = None
try:
if raw_bytes is None:
raw_bytes = self.d.screenshot(format='raw')
except Exception:
shot = self.d.screenshot()
if isinstance(shot, bytes):
raw_bytes = shot
else:
import io
buf = io.BytesIO()
shot.save(buf, format='PNG')
raw_bytes = buf.getvalue()
b64 = base64.b64encode(raw_bytes).decode('utf-8')
info = self.d.info
return {
"code": 200,
@@ -1270,7 +1699,7 @@ class WorkPhoneAgent:
"base64": b64,
"width": info.get("displayWidth", 0),
"height": info.get("displayHeight", 0),
"size": len(img),
"size": len(raw_bytes),
}
}
except Exception as e:
@@ -1294,6 +1723,47 @@ class WorkPhoneAgent:
},
"screen_on": d_info.get("screenOn", False),
}
# G2 防封修复2026-05-31采集真实硬件指纹字段供 compute_fingerprint
# 一机一指纹getprop 失败不阻断注册。
try:
_props = (
"ro.product.manufacturer ro.product.model ro.product.brand "
"ro.build.version.sdk ro.product.cpu.abi ro.serialno ro.build.fingerprint"
)
_out = self.d.shell(f"for p in {_props}; do echo \"$p=$(getprop $p)\"; done").output or ""
_kv = {}
for _line in _out.strip().splitlines():
if "=" in _line:
_k, _, _v = _line.partition("=")
_kv[_k.strip()] = _v.strip()
fp_map = {
"manufacturer": "ro.product.manufacturer",
"model": "ro.product.model",
"brand": "ro.product.brand",
"sdk_version": "ro.build.version.sdk",
"cpu_abi": "ro.product.cpu.abi",
"serial": "ro.serialno",
"fingerprint": "ro.build.fingerprint",
}
for _field, _prop in fp_map.items():
_val = _kv.get(_prop, "")
if _val and _val.lower() not in ("", "unknown"):
info[_field] = _val
# android_idsettings secure
try:
_aid = (self.d.shell("settings get secure android_id").output or "").strip()
if _aid and _aid.lower() != "null":
info["android_id"] = _aid
except Exception:
pass
# 屏幕宽高与密度(供 compute_fingerprint 扁平键)
info["screen_width"] = d_info.get("displayWidth", 0)
info["screen_height"] = d_info.get("displayHeight", 0)
if d_info.get("displaySizeDpX"):
info["density"] = d_info.get("displaySizeDpX")
except Exception as _fe:
logger.debug(f"[G2] 硬件指纹采集失败(不阻断): {_fe}")
# 检测已安装的APP
apps = []
@@ -1353,7 +1823,11 @@ class WorkPhoneAgent:
info["agent_version"] = self.VERSION
info["project_id"] = self.project_id
# BIND-07上报当前寻服阶段与候选数供中台/工作台显示连接来源
info["connect_stage"] = getattr(self, "connect_stage", "primary")
info["server_url"] = self.server_url
info["server_candidate_count"] = len(getattr(self, "server_candidates", []) or [])
return info
# ====================================================================
@@ -1454,8 +1928,23 @@ def _resolve_config(args) -> dict:
os.environ.get("WP_SERVER_URL")
or args.server
or config.get("server_url")
or "ws://192.168.1.100:8899/ws/device"
).rstrip("/")
)
auto_discover = (
os.environ.get("WP_AUTO_DISCOVER", "1").lower() in ("1", "true", "yes")
or getattr(args, "discover", False)
)
if not server_base and auto_discover:
try:
from sdk_discovery import discover_sdk_ws_base
discovered = discover_sdk_ws_base(timeout=float(os.environ.get("WP_DISCOVER_TIMEOUT", "20")))
if discovered:
server_base = discovered
logger.info(f"📡 UDP 发现 SDK: {server_base}")
except Exception as e:
logger.warning(f"SDK 自动发现失败: {e}")
if not server_base:
server_base = "ws://192.168.1.100:8899/ws/device"
server_base = server_base.rstrip("/")
heartbeat = int(
os.environ.get("WP_HEARTBEAT")
or (args.heartbeat if args.heartbeat is not None else 0)
@@ -1471,11 +1960,27 @@ def _resolve_config(args) -> dict:
server_url = f"{server_base}/{device_id}"
# BIND-03 公网主服有序回退列表(环境变量 > 命令行 > config.json.public_servers
public_raw = (
os.environ.get("WP_PUBLIC_SERVERS")
or getattr(args, "public_servers", None)
or ""
)
public_servers = [s.strip() for s in public_raw.split(",") if s.strip()]
if not public_servers and isinstance(config.get("public_servers"), list):
public_servers = [str(s).strip() for s in config["public_servers"] if str(s).strip()]
if public_servers:
logger.info(f"🌐 公网主服回退列表: {public_servers}")
# AI Brain 配置(环境变量 > config.json.ai_brain
ai_cfg = config.get("ai_brain", {})
_ai_enabled_env = os.environ.get("WP_AI_ENABLED")
if _ai_enabled_env is None:
_ai_enabled = bool(ai_cfg.get("enabled", False))
else:
_ai_enabled = _ai_enabled_env.strip().lower() in ("1", "true", "yes", "on")
ai_config = {
"enabled": (os.environ.get("WP_AI_ENABLED", "").lower() in ("1", "true")
or ai_cfg.get("enabled", False)),
"enabled": _ai_enabled,
"api_url": os.environ.get("WP_AI_API_URL") or ai_cfg.get("api_url", "http://localhost:3102"),
"api_key": os.environ.get("WP_AI_API_KEY") or ai_cfg.get("api_key", ""),
"model": os.environ.get("WP_AI_MODEL") or ai_cfg.get("model", "auto"),
@@ -1490,6 +1995,7 @@ def _resolve_config(args) -> dict:
"heartbeat_interval": heartbeat,
"project_id": project_id,
"ai_config": ai_config,
"public_servers": public_servers,
}
@@ -1522,10 +2028,13 @@ def main():
"""
)
parser.add_argument('--device-id', '-d', default=None, help='设备ID默认自动检测')
parser.add_argument('--server', '-s', default=None, help='服务器WebSocket基础地址')
parser.add_argument('--server', '-s', default=None, help='服务器WebSocket基础地址(省略则 UDP 自动发现)')
parser.add_argument('--discover', action='store_true', help='强制 UDP beacon 发现 SDK默认 WP_AUTO_DISCOVER=1')
parser.add_argument('--heartbeat', '-hb', type=int, default=None, help='心跳间隔(秒)建议5/10/30')
parser.add_argument('--project', '-p', default=None, help='项目ID')
parser.add_argument('--config', '-c', default=None, help='配置文件路径(默认 config.json')
parser.add_argument('--public-servers', dest='public_servers', default=None,
help='BIND-03 公网主服有序回退列表(逗号分隔,断 LAN 后逐个探测)')
args = parser.parse_args()
cfg = _resolve_config(args)
@@ -1536,6 +2045,7 @@ def main():
heartbeat_interval=cfg["heartbeat_interval"],
project_id=cfg["project_id"],
ai_config=cfg.get("ai_config"),
public_servers=cfg.get("public_servers"),
)
# 信号处理:优雅关闭