diff --git a/sdk/agent/agent.py b/sdk/agent/agent.py index db10169279..ed130a4420 100644 --- a/sdk/agent/agent.py +++ b/sdk/agent/agent.py @@ -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"), ) # 信号处理:优雅关闭 diff --git a/sdk/agent/skills/wechat/skill.py b/sdk/agent/skills/wechat/skill.py index fed1c83ea7..338c314f44 100644 --- a/sdk/agent/skills/wechat/skill.py +++ b/sdk/agent/skills/wechat/skill.py @@ -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_text(dump 不到时失败)+ 坐标兜底 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}"} diff --git a/sdk/app/agent/agent.py b/sdk/app/agent/agent.py index affaab3fb1..f15b250ca4 100644 --- a/sdk/app/agent/agent.py +++ b/sdk/app/agent/agent.py @@ -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 不可用 → 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 @@ -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_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 = "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 不可用时降级到 u2(UI自动化) 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) + + # 微信 Hook:Frida 未连时尝试 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=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 @@ -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_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 = [] @@ -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"), ) # 信号处理:优雅关闭 diff --git a/sdk/app/agent/skills/wechat/skill.py b/sdk/app/agent/skills/wechat/skill.py index ca6e200245..ce81e51c63 100644 --- a/sdk/app/agent/skills/wechat/skill.py +++ b/sdk/app/agent/skills/wechat/skill.py @@ -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)} \ No newline at end of file + return {"success": False, "error": str(e)} diff --git a/sdk/scripts/probe_wp_wx04_when_ws_online.sh b/sdk/scripts/probe_wp_wx04_when_ws_online.sh new file mode 100755 index 0000000000..e80a8f42d8 --- /dev/null +++ b/sdk/scripts/probe_wp_wx04_when_ws_online.sh @@ -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 Docker:localhost(IPv6) 才打到 workphone-sdk;127.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 diff --git a/开发文档/1、需求/修改/工作手机_进行中_20260613.md b/开发文档/1、需求/修改/工作手机_进行中_20260613.md index 07972b7fd7..1abe7ec631 100644 --- a/开发文档/1、需求/修改/工作手机_进行中_20260613.md +++ b/开发文档/1、需求/修改/工作手机_进行中_20260613.md @@ -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 1(adb=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` 真机不再 503(Frida/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` 真机不再 503(Frida/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/50(30%)· 真机 E2E 通道已建立 | | 2026-06-27 | **WP-APK-UI-01** 阶段 15:WP-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/50(30%) | | 2026-06-27 | **WP-APK-UI-01** 阶段 14:assembleDebug 打包验证(`./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/50(30%) · 剩余项需真机/跨端授权 | @@ -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` | -