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"),
)
# 信号处理:优雅关闭

View File

@@ -3072,10 +3072,36 @@ class WechatSkill(BaseSkill):
def _save_qr_image_to_album(self, image_base64: str = "", image_url: str = "") -> str:
"""WP-WX-04二维码图落盘相册并触发媒体扫描失败返回空串。"""
import base64
import shutil
import subprocess
import tempfile
dst = f"/sdcard/Pictures/wp_qr_{int(time.time())}.png"
try:
self.d.shell(f"mkdir -p {shlex.quote(os.path.dirname(dst))}", timeout=8)
def adb_push_fallback(local_path: str) -> bool:
adb_bin = shutil.which("adb")
serial = (
getattr(self.d, "serial", "")
or getattr(self, "device_id", "")
or os.environ.get("ANDROID_SERIAL", "")
)
if not adb_bin or not serial:
return False
try:
subprocess.run(
[adb_bin, "-s", serial, "push", local_path, dst],
check=True,
capture_output=True,
text=True,
timeout=30,
)
return True
except Exception as adb_err:
logger.warning("save_qr_image_to_album adb push fallback failed: %s", adb_err)
return False
if image_url:
self.d.shell(
f"curl -sL -o {shlex.quote(dst)} {shlex.quote(image_url)}",
@@ -3088,7 +3114,14 @@ class WechatSkill(BaseSkill):
f.write(raw)
local = f.name
try:
self.d.push(local, dst)
pushed = False
try:
self.d.push(local, dst)
pushed = True
except Exception as push_err:
logger.warning("save_qr_image_to_album u2 push failed: %s", push_err)
if not pushed and not adb_push_fallback(local):
return ""
finally:
os.unlink(local)
else:
@@ -3114,48 +3147,68 @@ class WechatSkill(BaseSkill):
- u2 dump_hierarchy 不到微信节点SurfaceView/自绘限制)
- u2 click 被 MIUI INJECT_EVENTS 拦截
- 改用 root am start + root input tap 坐标点击
- 坐标基于真机截图分析验证
- 权限弹窗与 AlbumPreviewUI 用多坐标兜底
"""
import subprocess as _sp
try:
def current_focus() -> str:
try:
return self.d.shell("dumpsys window | grep mCurrentFocus").output or ""
except Exception:
return ""
def tap(x: int, y: int, wait_sec: float = 1.5) -> None:
self.d.shell(f"su -c 'input tap {x} {y}'")
self.sleep(wait_sec)
# 1. root am start 直接打开扫一扫(绕过 u2 click "+" 菜单)
self.d.shell("su -c 'am start -n com.tencent.mm/.plugin.scanner.ui.BaseScanUI'")
self.sleep(2)
# 2. 点相册按钮937,2012 · 真机验证)
self.d.shell("su -c 'input tap 937 2012'")
self.sleep(2)
# 3. 处理可能的 MIUI 权限弹窗(第一次点相册会弹存储权限)
try:
focus = self.d.shell("dumpsys window | grep mCurrentFocus").output or ""
if "GrantPermissions" in focus:
self.d.shell("su -c 'input tap 540 1700'") # 允许
self.sleep(2)
tap(937, 2012, 2)
# 3. 处理 MIUI/微信自有权限弹窗;焦点有时不叫 GrantPermissions
focus = current_focus()
focus_norm = focus.lower()
if any(key in focus_norm for key in ("grantpermissions", "permission", "permis")):
for allow_x, allow_y in ((830, 1710), (540, 1710)):
tap(allow_x, allow_y, 1.5)
self.d.shell("su -c 'am start -n com.tencent.mm/.plugin.scanner.ui.BaseScanUI'")
self.sleep(1.5)
self.d.shell("su -c 'input tap 937 2012'") # 再点相册
self.sleep(2)
except Exception:
pass
# 4. 检查是否进入相册(焦点应含 AlbumPreviewUI 或 AlbumPickerUI
try:
focus2 = self.d.shell("dumpsys window | grep mCurrentFocus").output or ""
if "gallery" not in focus2.lower() and "album" not in focus2.lower():
return {"success": False, "error": f"未进入相册界面(焦点={focus2[:80]})"}
except Exception:
pass
tap(937, 2012, 2)
focus_norm = current_focus().lower()
if "gallery" in focus_norm or "album" in focus_norm:
break
# 4. 检查是否进入相册(焦点应含 AlbumPreviewUI / AlbumPickerUI / gallery
focus2 = current_focus()
focus2_norm = focus2.lower()
if "gallery" not in focus2_norm and "album" not in focus2_norm:
return {"success": False, "error": f"未进入相册界面(焦点={focus2[:120]})"}
# 5. 点第一张图270,400 · 3列网格第一张 · 真机验证)
self.d.shell("su -c 'input tap 270 400'")
self.sleep(2)
tap(270, 400, 2)
# 6. 在 AlbumPreviewUI 点"完成"按钮
# 尝试 u2 click_textdump 不到时失败)+ 坐标兜底
ok = self.click_text("完成") or self.click_contains("完成")
if not ok:
# 坐标兜底:完成按钮通常在右下角(真机待精确定位,暂用常见位置)
self.d.shell("su -c 'input tap 950 2300'")
self.sleep(2)
# `/tmp/find_btn_out.txt` 像素分析显示底部中心高亮明显,优先点底部中心,再试右下/右上。
for finish_x, finish_y in ((528, 2330), (950, 2300), (950, 120)):
tap(finish_x, finish_y, 1.5)
xml_try = self.d.dump_hierarchy(compressed=True)
if any(flag in xml_try for flag in ("添加到通讯录", "发消息", "二维码已过期", "二维码无效")):
ok = True
break
xml = self.d.dump_hierarchy(compressed=True)
texts = re.findall(r'text="([^"]+)"', xml)[:15]
return {"success": True, "note": "已从相册选取图片识别二维码", "page_texts": texts}
if any(flag in xml for flag in ("添加到通讯录", "发消息", "二维码已过期", "二维码无效")):
return {"success": True, "note": "已从相册选取图片识别二维码", "page_texts": texts}
return {
"success": False,
"error": "已选取图片但未进入二维码结果页",
"page_texts": texts,
}
except Exception as e:
return {"success": False, "error": f"扫一扫相册流程异常: {e}"}

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
@@ -123,6 +137,12 @@ class WorkPhoneAgent:
self.last_heartbeat_ack = time.time()
self.commands_executed = 0
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
@@ -159,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
@@ -194,15 +252,58 @@ class WorkPhoneAgent:
try:
from hook.frida_manager import FridaManager
import os as _os
_mode = (_os.environ.get("WP_FRIDA_MODE") or "gadget").strip().lower()
import json as _json
_mode = (_os.environ.get("WP_FRIDA_MODE") or "").strip().lower()
_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 = "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
try:
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.adb_serial or not _serial):
_serial = _ps or _serial
if _pc.get("listen_port"):
_frida_port = int(_pc["listen_port"])
_mode = "remote"
logger.info(f"已加载 Phantom Frida 配置: port={_frida_port} serial={_serial}")
break
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"
_serial = _os.environ.get("WP_DEVICE_SERIAL") or (
self.device_id if _mode == "usb" else None
)
_mode = "remote" if _on_device else "gadget"
if not _serial and _mode == "usb":
_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,
)
@@ -222,8 +323,13 @@ class WorkPhoneAgent:
"""Frida 事件回调Hook 事件上报)"""
event_type = payload.get("event", "unknown")
logger.debug(f"[Frida事件] {event_type}: {str(payload)[:100]}")
if self.connected and self.ws:
asyncio.ensure_future(self._send_event(f"hook_{event_type}", payload))
loop = self._loop
if self.connected and self.ws and loop and loop.is_running():
loop.call_soon_threadsafe(
lambda et=event_type, pl=payload: asyncio.create_task(
self._send_event(f"hook_{et}", pl)
)
)
def _init_anti_ban(self):
"""初始化设备端深层防护模块"""
@@ -294,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()
@@ -378,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()
@@ -445,6 +622,12 @@ 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 _register(self):
"""发送设备注册信息"""
@@ -518,6 +701,82 @@ class WorkPhoneAgent:
logger.error(f"养号循环异常: {e}")
await asyncio.sleep(60)
U2_FORCE_ACTIONS = frozenset({
"login_by_password",
"ensure_logged_in",
"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",
"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",
"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",
"get_official_account_articles",
"reply_comment",
})
async def _execute_with_frida_priority(
self, script: str, action: str, params: dict, hook_only: bool = False
) -> dict:
@@ -527,7 +786,30 @@ class WorkPhoneAgent:
2. Frida 不可用时降级到 u2UI自动化
3. hook_only=True 时禁止降级(纯 Hawk Hook 联调)
"""
if script == "wechat" and action in self.U2_FORCE_ACTIONS:
if not self.d:
self._try_reconnect_u2()
if not self.d:
return {
"code": 503,
"message": "uiautomator2未连接请确认 USB 调试已授权且 adb devices 可见",
"data": {
"success": False,
"error": "u2_offline",
"action": action,
},
"channel": "u2",
}
return await self._execute_skill(script, action, params)
# 微信 HookFrida 未连时尝试 lazy 重连(用户稍后手动打开微信的场景)
if script == "wechat" and (not self.frida_mgr or not getattr(self.frida_mgr, "connected", False)):
logger.info("[Frida] 未连接,尝试 lazy 重新初始化…")
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
@@ -535,23 +817,29 @@ class WorkPhoneAgent:
frida_result = 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": "frida"}
logger.debug(f"[Frida] {script}.{action} 执行失败,降级 u2")
return {"code": 200, "data": frida_result, "channel": "websocket/frida"}
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,
},
}
@@ -577,13 +865,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))
@@ -614,16 +905,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
@@ -679,16 +980,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
@@ -721,6 +1033,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:
@@ -883,27 +1197,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:
@@ -915,6 +1231,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", "")
@@ -924,6 +1260,21 @@ 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 = self.frida_mgr.reload_script()
else:
self._init_frida()
if self.frida_mgr:
result = 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 == "device_request_ack":
ack_data = data.get("data") or {}
if ack_data.get("heartbeat_interval") is not None:
@@ -1045,8 +1396,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()
@@ -1092,7 +1441,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}")
@@ -1122,8 +1471,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}"}
@@ -1132,8 +1536,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}
@@ -1190,10 +1593,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,
@@ -1201,7 +1624,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:
@@ -1225,6 +1648,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 = []
@@ -1284,7 +1748,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
# ====================================================================
@@ -1293,6 +1761,7 @@ class WorkPhoneAgent:
async def start(self):
"""启动Agent"""
self._loop = asyncio.get_running_loop()
self.running = True
self.start_time = time.time()
@@ -1384,8 +1853,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)
@@ -1401,11 +1885,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"),
@@ -1420,6 +1920,7 @@ def _resolve_config(args) -> dict:
"heartbeat_interval": heartbeat,
"project_id": project_id,
"ai_config": ai_config,
"public_servers": public_servers,
}
@@ -1452,10 +1953,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)
@@ -1466,6 +1970,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"),
)
# 信号处理:优雅关闭

View File

@@ -15,6 +15,7 @@
import time
import logging
import re
import shlex
import sys
import os
from typing import Dict, Any, List, Optional
@@ -85,6 +86,37 @@ class WechatSkill(BaseSkill):
PACKAGE = "com.tencent.mm"
NAME = "微信"
SEND_MESSAGE_TIMEOUT = 45
MAX_TEST_PAYMENT_AMOUNT = 1.0
def _payment_guard(self, amount: str = "", confirm: bool = False, **kwargs) -> Dict[str, Any]:
"""资金类动作保护:默认 dry-run真确认必须显式授权且金额在测试上限内。"""
try:
amt = float(str(amount or "0").strip() or "0")
except Exception:
return {"pass": False, "error": "amount 必须是数字"}
if amt < 0:
return {"pass": False, "error": "amount 不能为负数"}
if not confirm:
return {
"pass": True,
"dry_run": True,
"confirm_required": True,
"amount": amt,
"note": "已按内部测试保护策略停止在最终确认前",
}
if amt > self.MAX_TEST_PAYMENT_AMOUNT:
return {
"pass": False,
"error": f"测试金额超过上限 {self.MAX_TEST_PAYMENT_AMOUNT}",
"amount": amt,
}
if not kwargs.get("test_whitelist_ok", False):
return {
"pass": False,
"error": "资金动作需要 test_whitelist_ok=true",
"amount": amt,
}
return {"pass": True, "dry_run": False, "confirm_required": False, "amount": amt}
def send_message_with_vision(self, to_id: str, content: str, msg_type: str = "text") -> Dict[str, Any]:
"""
@@ -163,34 +195,37 @@ class WechatSkill(BaseSkill):
# ── 2. 打开搜索(坐标:标题栏放大镜,在 + 号左侧) ──
search_x = int(W * 0.82)
search_y = int(H * 0.06)
self.d.click(search_x, search_y)
self.click(search_x, search_y)
self.sleep(1.5)
curr = self.d.app_current()
if "FTSMainUI" not in (curr.get("activity") or ""):
self.d.click(search_x - 60, search_y)
self.click(search_x - 60, search_y)
self.sleep(1)
# ── 3. 输入联系人 ──
self.d.click(int(W * 0.5), int(H * 0.035))
self.click(int(W * 0.5), int(H * 0.035))
self.sleep(0.3)
self.d.set_input_ime(True)
try:
self.d.set_input_ime(True)
except Exception as e:
logger.warning(f"ADBKeyboard 启用失败,使用 root input text 兜底: {e}")
self.sleep(0.2)
self.d.send_keys(to_id)
self.input_text(to_id, clear=False)
self.sleep(2.5)
# ── 4. 点击搜索结果(微信自定义控件不在 a11y tree用坐标 ──
result_x = int(W * 0.3)
entered_chat = False
for result_y in [int(H * 0.20), int(H * 0.22), int(H * 0.18), int(H * 0.25)]:
self.d.click(result_x, result_y)
self.click(result_x, result_y)
self.sleep(2)
curr = self.d.app_current()
if "ChattingUI" in (curr.get("activity") or ""):
entered_chat = True
break
if "FTSMainUI" not in (curr.get("activity") or ""):
self.d.press("back")
self.press("back")
self.sleep(0.5)
if not entered_chat:
@@ -207,13 +242,16 @@ class WechatSkill(BaseSkill):
# ── 5. 聚焦输入框 ──
input_y = int(H * 0.955)
self.d.click(int(W * 0.3), input_y)
self.click(int(W * 0.3), input_y)
self.sleep(0.5)
# ── 6. 输入消息 ──
self.d.set_input_ime(True)
try:
self.d.set_input_ime(True)
except Exception as e:
logger.warning(f"ADBKeyboard 启用失败,使用 root input text 兜底: {e}")
self.sleep(0.2)
self.d.send_keys(content)
self.input_text(content, clear=False)
self.sleep(0.8)
# ── 7. 点击发送按钮(先尝试 xpath/text再坐标兜底 ──
@@ -230,7 +268,7 @@ class WechatSkill(BaseSkill):
if not sent:
send_x = int(W * 0.91)
self.d.click(send_x, input_y)
self.click(send_x, input_y)
self.sleep(0.3)
self.sleep(0.5)
@@ -2080,6 +2118,17 @@ class WechatSkill(BaseSkill):
def send_red_packet(self, to_id: str = "", amount: str = "", message: str = "恭喜发财", **kwargs) -> Dict[str, Any]:
"""发红包 [u2, ~10s]"""
try:
guard = self._payment_guard(amount, bool(kwargs.get("confirm")), **kwargs)
if not guard.get("pass"):
return {"success": False, **guard}
if guard.get("dry_run"):
return {
"success": True,
"action": "send_red_packet",
"to_id": to_id,
"message": message,
**guard,
}
self.launch()
self.wait_for_app_ready()
self.search(to_id)
@@ -2091,13 +2140,26 @@ class WechatSkill(BaseSkill):
self.sleep(1)
if amount:
self.input_text(amount)
return {"success": True, "note": "已打开红包页面"}
if not guard.get("dry_run") and (self.click_text("塞钱进红包") or self.click_contains("塞钱")):
return {"success": True, "note": "已进入红包支付确认", **guard}
return {"success": True, "note": "已打开红包页面", **guard}
except Exception as e:
return {"success": False, "error": str(e)}
def transfer(self, to_id: str = "", amount: str = "", message: str = "", **kwargs) -> Dict[str, Any]:
"""转账 [u2, ~10s]"""
try:
guard = self._payment_guard(amount, bool(kwargs.get("confirm")), **kwargs)
if not guard.get("pass"):
return {"success": False, **guard}
if guard.get("dry_run"):
return {
"success": True,
"action": "transfer",
"to_id": to_id,
"message": message,
**guard,
}
self.launch()
self.wait_for_app_ready()
self.search(to_id)
@@ -2109,7 +2171,9 @@ class WechatSkill(BaseSkill):
self.sleep(1)
if amount:
self.input_text(amount)
return {"success": True, "note": "已打开转账页面"}
if not guard.get("dry_run") and (self.click_text("转账") or self.click_text("确认转账")):
return {"success": True, "note": "已进入转账支付确认", **guard}
return {"success": True, "note": "已打开转账页面", **guard}
except Exception as e:
return {"success": False, "error": str(e)}
@@ -2477,18 +2541,208 @@ class WechatSkill(BaseSkill):
except Exception as e:
return {"success": False, "error": str(e)}
def extract_qr_from_image(self, **kwargs) -> Dict[str, Any]:
"""从图片识别二维码 [u2, ~5s]"""
def _save_qr_image_to_album(self, image_base64: str = "", image_url: str = "") -> str:
"""WP-WX-04二维码图落盘相册并触发媒体扫描失败返回空串。"""
import base64
import shutil
import subprocess
import tempfile
dst = f"/sdcard/Pictures/wp_qr_{int(time.time())}.png"
try:
self.launch()
self.wait_for_app_ready()
self.click_desc("+") or self.click_text("+")
self.sleep(0.5)
self.click_text("扫一扫")
self.d.shell(f"mkdir -p {shlex.quote(os.path.dirname(dst))}", timeout=8)
def adb_push_fallback(local_path: str) -> bool:
adb_bin = shutil.which("adb")
serial = (
getattr(self.d, "serial", "")
or getattr(self, "device_id", "")
or os.environ.get("ANDROID_SERIAL", "")
)
if not adb_bin or not serial:
return False
try:
subprocess.run(
[adb_bin, "-s", serial, "push", local_path, dst],
check=True,
capture_output=True,
text=True,
timeout=30,
)
return True
except Exception as adb_err:
logger.warning("save_qr_image_to_album adb push fallback failed: %s", adb_err)
return False
if image_url:
self.d.shell(
f"curl -sL -o {shlex.quote(dst)} {shlex.quote(image_url)}",
timeout=30,
)
elif image_base64:
b64 = image_base64.split(",", 1)[-1].strip()
raw = base64.b64decode(b64)
with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f:
f.write(raw)
local = f.name
try:
pushed = False
try:
self.d.push(local, dst)
pushed = True
except Exception as push_err:
logger.warning("save_qr_image_to_album u2 push failed: %s", push_err)
if not pushed and not adb_push_fallback(local):
return ""
finally:
os.unlink(local)
else:
return ""
size_out = (self.d.shell(f"wc -c < {shlex.quote(dst)}").output or "").strip()
if not size_out or size_out == "0":
return ""
self.d.shell(
"am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE "
f"-d file://{dst}",
timeout=8,
)
self.sleep(1)
self.click_text("相册") or self.click_contains("相册")
self.sleep(1)
return {"success": True, "note": "已打开相册选择"}
return dst
except Exception as e:
logger.warning("save_qr_image_to_album failed: %s", e)
return ""
def _open_scan_pick_album_first(self) -> Dict[str, Any]:
"""微信扫一扫 → 相册 → 点第一张图。
真机验证2026-06-27 Redmi K70 1080x2400
- u2 dump_hierarchy 不到微信节点SurfaceView/自绘限制)
- u2 click 被 MIUI INJECT_EVENTS 拦截
- 改用 root am start + root input tap 坐标点击
- 权限弹窗与 AlbumPreviewUI 用多坐标兜底
"""
try:
def current_focus() -> str:
try:
return self.d.shell("dumpsys window | grep mCurrentFocus").output or ""
except Exception:
return ""
def tap(x: int, y: int, wait_sec: float = 1.5) -> None:
self.d.shell(f"su -c 'input tap {x} {y}'")
self.sleep(wait_sec)
self.d.shell("su -c 'am start -n com.tencent.mm/.plugin.scanner.ui.BaseScanUI'")
self.sleep(2)
tap(937, 2012, 2)
focus = current_focus()
focus_norm = focus.lower()
if any(key in focus_norm for key in ("grantpermissions", "permission", "permis")):
for allow_x, allow_y in ((830, 1710), (540, 1710)):
tap(allow_x, allow_y, 1.5)
self.d.shell("su -c 'am start -n com.tencent.mm/.plugin.scanner.ui.BaseScanUI'")
self.sleep(1.5)
tap(937, 2012, 2)
focus_norm = current_focus().lower()
if "gallery" in focus_norm or "album" in focus_norm:
break
focus2 = current_focus()
focus2_norm = focus2.lower()
if "gallery" not in focus2_norm and "album" not in focus2_norm:
return {"success": False, "error": f"未进入相册界面(焦点={focus2[:120]})"}
tap(270, 400, 2)
ok = self.click_text("完成") or self.click_contains("完成")
if not ok:
for finish_x, finish_y in ((528, 2330), (950, 2300), (950, 120)):
tap(finish_x, finish_y, 1.5)
xml_try = self.d.dump_hierarchy(compressed=True)
if any(flag in xml_try for flag in ("添加到通讯录", "发消息", "二维码已过期", "二维码无效")):
ok = True
break
xml = self.d.dump_hierarchy(compressed=True)
texts = re.findall(r'text="([^"]+)"', xml)[:15]
if any(flag in xml for flag in ("添加到通讯录", "发消息", "二维码已过期", "二维码无效")):
return {"success": True, "note": "已从相册选取图片识别二维码", "page_texts": texts}
return {
"success": False,
"error": "已选取图片但未进入二维码结果页",
"page_texts": texts,
}
except Exception as e:
return {"success": False, "error": f"扫一扫相册流程异常: {e}"}
def extract_qr_from_image(
self, image_base64: str = "", image_url: str = "", **kwargs
) -> Dict[str, Any]:
"""从图片识别二维码(支持 image_base64 / image_url 落盘后再扫) [u2, ~8s]"""
try:
saved = ""
if image_base64 or image_url:
saved = self._save_qr_image_to_album(
image_base64=image_base64, image_url=image_url
)
if not saved:
return {
"success": False,
"error": "图片落盘相册失败image_base64/image_url 无效或写入失败)",
}
result = self._open_scan_pick_album_first()
if saved:
result["saved_path"] = saved
return result
except Exception as e:
return {"success": False, "error": str(e)}
def add_friend_from_image(
self,
image_base64: str = "",
image_url: str = "",
verify_message: str = "",
**kwargs,
) -> Dict[str, Any]:
"""WP-WX-04二维码图 → 扫一扫相册识别 → 发送好友申请。"""
try:
ext = self.extract_qr_from_image(
image_base64=image_base64, image_url=image_url
)
if not ext.get("success"):
return ext
self.sleep(2)
xml = self.d.dump_hierarchy(compressed=True)
if "添加到通讯录" in xml:
self.click_text("添加到通讯录") or self.click_contains("添加到通讯录")
self.sleep(1)
if verify_message:
xml2 = self.d.dump_hierarchy(compressed=True)
if "发送添加朋友申请" in xml2 or "申请添加朋友" in xml2:
(
self.click_text("发送添加朋友申请")
or self.click_contains("申请添加朋友")
)
self.sleep(0.5)
self.human_type(verify_message, clear=True)
self.sleep(0.3)
self.click_text("发送") or self.click_text("完成")
return {
"success": True,
"note": "已识别二维码并发送好友请求",
"verified": True,
"verify_message": verify_message,
}
if "该二维码已过期" in xml or "二维码无效" in xml:
return {"success": False, "error": "二维码已过期/无效"}
if "已是好友" in xml or ("发消息" in xml and "添加到通讯录" not in xml):
return {"success": True, "note": "对方已是好友", "already_friend": True}
texts = re.findall(r'text="([^"]+)"', xml)[:10]
return {
"success": False,
"error": "未识别到好友二维码或未进入添加页",
"page_texts": texts,
}
except Exception as e:
return {"success": False, "error": str(e)}
@@ -2898,4 +3152,4 @@ class WechatSkill(BaseSkill):
b64 = base64.b64encode(buf.getvalue()).decode()
return {"success": True, "screenshot": b64[:100] + "...", "size": len(b64)}
except Exception as e:
return {"success": False, "error": str(e)}
return {"success": False, "error": str(e)}

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# WP-WX-04真机 WS 在线后一键探针(扫码加友契约层)
# 用法DEVICE_ID=xgfe65eimrrofyws bash sdk/scripts/probe_wp_wx04_when_ws_online.sh
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
DEVICE_ID="${DEVICE_ID:-xgfe65eimrrofyws}"
# Mac Dockerlocalhost(IPv6) 才打到 workphone-sdk127.0.0.1 可能落到旧进程
BASE="${WORKPHONE_SDK_URL:-http://localhost:8899}"
BASE="${BASE%/}"
DEFAULT_IMAGE_FILE="$ROOT_DIR/开发文档/8、部署/05-测试验收/screenshots/verification_110/H26_二维码_generate_my_qr_code_1779086272.png"
IMAGE_URL="${IMAGE_URL:-}"
IMAGE_FILE="${IMAGE_FILE:-}"
IMAGE_BASE64="${IMAGE_BASE64:-}"
VERIFY_MESSAGE="${VERIFY_MESSAGE:-WP-WX-04 probe}"
if [[ -z "$IMAGE_URL" && -z "$IMAGE_FILE" && -z "$IMAGE_BASE64" && -f "$DEFAULT_IMAGE_FILE" ]]; then
IMAGE_FILE="$DEFAULT_IMAGE_FILE"
fi
TMP_EXTRACT="$(mktemp /tmp/wpwx04_extract.XXXXXX.json)"
TMP_ADD="$(mktemp /tmp/wpwx04_add.XXXXXX.json)"
cleanup() {
rm -f "$TMP_EXTRACT" "$TMP_ADD"
}
trap cleanup EXIT
python3 - "$TMP_EXTRACT" "$TMP_ADD" "$DEVICE_ID" "$IMAGE_FILE" "$IMAGE_BASE64" "$IMAGE_URL" "$VERIFY_MESSAGE" <<'PY'
import base64
import json
import os
import sys
extract_path, add_path, device_id, image_file, image_b64, image_url, verify_message = sys.argv[1:]
payload = {"device_id": device_id, "platform": "wechat"}
if image_url:
payload["image_url"] = image_url
elif image_file:
with open(image_file, "rb") as f:
payload["image_base64"] = base64.b64encode(f.read()).decode("ascii")
elif image_b64:
payload["image_base64"] = image_b64
else:
raise SystemExit("需提供 IMAGE_FILE / IMAGE_URL / IMAGE_BASE64 之一")
with open(extract_path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False)
payload["verify_message"] = verify_message
with open(add_path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False)
PY
echo "▶ SDK=$BASE DEVICE=$DEVICE_ID"
curl -sS --connect-timeout 3 --max-time 8 "$BASE/health" | python3 -m json.tool 2>/dev/null || true
echo "▶ connection/status"
curl -sS --connect-timeout 3 --max-time 10 "$BASE/api/v3/connection/status" \
| python3 -c "import sys,json;d=json.load(sys.stdin)['data'];print('online_ws_count=',d.get('online_ws_count'),'devices=',d.get('online_device_ids'))"
echo "▶ POST /scan/extract-qr"
curl -sS --connect-timeout 3 --max-time 90 -X POST "$BASE/api/v3/scan/extract-qr" \
-H 'Content-Type: application/json' \
--data-binary "@$TMP_EXTRACT" \
| python3 -m json.tool 2>/dev/null || true
echo "▶ POST /scan/add-friend-from-image"
curl -sS --connect-timeout 3 --max-time 120 -X POST "$BASE/api/v3/scan/add-friend-from-image" \
-H 'Content-Type: application/json' \
--data-binary "@$TMP_ADD" \
| python3 -m json.tool 2>/dev/null || true

View File

@@ -133,7 +133,7 @@
| ⬜ | WP-0608-12 | 白名单写类 mass_send/add_friend/朋友圈互动 | 0% |
| ⬜ | WP-WX-02 | batch-send/batch-add 真机频控验收 | 0% |
| ⬜ | WP-WX-03 | 解封联系客服 A→E 真机 success 样本 | 30% |
| 🟡 | WP-WX-04 | AI 传二维码图 → 微信扫一扫识别 → 自动加好友 | 50% |
| 🟡 | WP-WX-04 | AI 传二维码图 → 微信扫一扫识别 → 自动加好友 | 80% |
| 🟡 | WP-WX-HOOK | 微信 176 action Frida Hook 全量打勾 §七(修正误判 · companion 84→62 | 58/176 |
| ⬜ | WP-AGENT-01 | 24h Agent 驻留 + Termux/APK 去 Mac 依赖 | 0% |
| ⬜ | WP-COORD-01 | 四端业务切 USE_FOR_* / 触客宝 kefu 切 BFF | COORD |
@@ -185,7 +185,7 @@
- [x] Agent `extract_qr_from_image` / `add_friend_from_image` u2 落盘+相册扫图 ✅ **2026-06-16**`sdk/agent/skills/wechat/skill.py``py_compile` exit 0
- [x] 离线路由+skill 对齐 pytest ✅ **2026-06-16 10:15**`test_wp_wx04_scan_routes_and_agent_skill` 1 passed · gate exit 1adb=0·devices_online=0
- [x] integration manifest 登记 `/scan/add-friend-from-image`**2026-06-16 10:25**`localhost:8899` consumers/cunkebao 可见;`probe_wp_wx04_when_ws_online.sh`
- [ ] `extract_qr_from_image` 真机不再 503Frida/u2 在线)— 🟡 **2026-06-27 23:55** 探针进展(~70%):✅ SDK adb 通道已修复5038→5037 共享 · 3 文件改动 · `adb_devices=2`)· ✅ u2 通道已通(`channel_used: websocket/u2` · 不再 u2_offline)· ✅ root am start 打开扫一扫验证(`BaseScanUI`)· ✅ 相册按钮坐标 (937,2012) 真机验证 · ✅ 选图坐标 (270,400) 验证进入 `AlbumPreviewUI` · ✅ `_open_scan_pick_album_first` 已改用 root am start + 坐标点击 · 🟡 权限弹窗处理逻辑需改进(焦点含 `permis` 而非 `GrantPermissions` · 微信自有权限 Activity· 🟡 u2 dump 不到微信节点SurfaceView 限制 · 已用坐标绕过)· 🟡 AlbumPreviewUI "完成"按钮坐标待精确定位
- [ ] `extract_qr_from_image` 真机不再 503Frida/u2 在线)— 🟡 **2026-06-28 06:05** 探针进展(~85%):✅ `probe_wp_wx04_when_ws_online.sh` 支持 `IMAGE_FILE`/默认样本图 + 临时 JSON payload修复 `curl: Argument list too long`;✅ `python-agent` 串口自动回退(`192.168.110.80:5555 -> xgfe65eimrrofyws`WS 恢复在线;✅ `extract_qr_from_image` / `add_friend_from_image` **不再 503 / 不再 `u2_offline` / 不再“图片落盘相册失败”**;✅ 图片已真实写入相册(`saved_path=/sdcard/Pictures/wp_qr_*.png`);✅ `_open_scan_pick_album_first` 已补 MIUI 权限关键词兜底 + AlbumPreviewUI 多坐标兜底(含 `/tmp/find_btn_out.txt` 像素中心 528,2330🟡 当前新卡点已下钻为 **“已选取图片但未进入二维码结果页”**`page_texts` 显示桌面图标(天气/相册/小米视频/主题商店…),说明 503 环节已解除,剩余是 **AlbumPreviewUI → 二维码结果页** 点击链/焦点问题
- [ ] AI 数智员工 / BFF 可向工作手机下发「二维码图片 URL/base64」→ 真机执行全链路 verified
### 2.7 P1 · Agent / 部署WP-AGENT-01
@@ -538,6 +538,7 @@ WORKPHONE_TEST_TO_ID=filehelper node sdk/scripts/run_wechat_private_domain_accep
| 日期 | 说明 |
|:---|:---|
| 2026-06-28 | **WP-WX-04** 真机续跑:① `probe_wp_wx04_when_ws_online.sh` 改为支持 `IMAGE_FILE`/默认样本图 + temp JSON payload修复真实二维码大 payload 触发的 `curl: Argument list too long``sdk/agent/skills/wechat/skill.py``sdk/app/agent/skills/wechat/skill.py` 同步:`_open_scan_pick_album_first` 改为 root `am start BaseScanUI` + MIUI 权限关键词兜底(`permission/permis`+ AlbumPreviewUI 多坐标兜底(含 528,2330+ 更诚实结果页判断;`_save_qr_image_to_album``adb push` 兜底 ③ `sdk/agent/agent.py``sdk/app/agent/agent.py` 新增 u2 串口自动回退(`WP_DEVICE_SERIAL` 掉线时自动切 `device_id` / USB serial真机日志验证 `192.168.110.80:5555 -> xgfe65eimrrofyws`;真机探针结果推进为:**不再 503 / 不再 `u2_offline` / 不再图片落盘失败**,当前新卡点收敛到 **已选图但未进入二维码结果页**`page_texts` 为桌面图标§2.6b WP-WX-04 50%→80% |
| 2026-06-27 | **WP-APK-UI-01** 阶段 16-19 真机开发:① 阶段16 真机在线排查USB+网络 adb `192.168.110.80:5555` · Redmi K70 Android 13 · Magisk Delta Root ✅ · 微信 8.0.69 · Agent 在线)② 阶段17 修复 Magisk 包名 bug`SettingsFragment.launchMagisk` 改多包名兼容 `com.topjohnwu.magisk`+`io.github.vvb2060.magisk` · Magisk Delta 真机验证)③ 阶段18 启动 Frida 16.5.6`/data/local/tmp/frida-server-16` 端口 19886 · `frida -U`/`-H 192.168.110.80:19886` 双通道 ✅ · Magisk 模块 Shamiko+LSPosed ✅)④ 阶段19 WP-CKB-143 wxid 真机实现(`DeviceInfo.getWxid(context)` 读 APP files/wxid_cache.txt · `refresh_wxid_cache.sh` 守护脚本 adb su 读微信 SharedPreferences `login_weixin_username` 写入 · SELinux category 限制 APP su 读微信文件,改用 adb su 写 APP files 方案 · UI 显示 `wxid_5g37snchpv8e22` ✅);§十·六 WP-CKB-143 🟡→✅ · **§十·六 7/7 全 ✅** · 子项 15/5030%)· 真机 E2E 通道已建立 |
| 2026-06-27 | **WP-APK-UI-01** 阶段 15WP-CKB-143 wxid UI 占位(`DashboardFragment.tvHardwareInfo` buildString 加 wxid 行 · `hookOk` 时「待 Hook 注入」/ 否则「—」 · 工作手机 ID + 电量已有 · wxid 需 Frida Hook engine 暂无通道);`compileDebugKotlin` BUILD SUCCESSFUL in 1m§十·六 WP-CKB-143 🟡部分完成wxid 待 Hook· **§十·六 6/7 ✅ + 1 🟡** · 子项 15/5030% |
| 2026-06-27 | **WP-APK-UI-01** 阶段 14assembleDebug 打包验证(`./gradlew :app:assembleDebug` BUILD SUCCESSFUL in 1m 20s · APK 7.1M `app-debug.apk` · 验证资源/清单/打包流程全通过gitea 同步成功commit `0447726c3` · 25 文件 +3733/-391 · `34084867e..0447726c3 main`GitHub push 卡住已 stop网络慢gitea 内网已同步);**离线开发部分完成** · §十·六 6/7 ✅ · 子项 15/5030% · 剩余项需真机/跨端授权 |
@@ -562,4 +563,3 @@ WORKPHONE_TEST_TO_ID=filehelper node sdk/scripts/run_wechat_private_domain_accep
| 2026-06-13 | **换日分拣**0612 全文 → `modify需求归档_20260613/`;✅ 迁 `工作手机_0613已完成项归档`;§测试 ACC 入库 |
| 2026-06-13 | **WP-WX-03/04** 卡若口述 §九 |
| 2026-06-14 | **防自动中断**:主控 `CHECKLIST_CHUNK/PHASE`+Frida探针+`run_checklist_anti_interrupt.sh`+`fix_persistent_chat_no_interrupt.sh` |