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