fix(wp-wx-04): improve scan probe and u2 fallback
This commit is contained in:
@@ -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 不可用 → LocalDevice(ATX HTTP 直连)
|
||||
3. ATX 也不可用 → self.d 保持 None,运行时重试
|
||||
2. 仅手机本机(Termux)→ LocalDevice(ATX 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_id(adb-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=Unknown),u2 连上后重发一次注册补真实硬件指纹
|
||||
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_id(settings 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"),
|
||||
)
|
||||
|
||||
# 信号处理:优雅关闭
|
||||
|
||||
Reference in New Issue
Block a user