2080 lines
90 KiB
Python
2080 lines
90 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
工作手机Agent v3.1 - AI数字员工(Frida + AI Brain 增强版)
|
||
运行在Android手机上,主动连接SDK服务器,保持实时通信
|
||
内置 AI 大脑,支持离线自主运行
|
||
|
||
核心架构:
|
||
┌──────────────────────────────────────────────────────┐
|
||
│ 手机端 Agent (Python/Termux) v3.1 │
|
||
│ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ │
|
||
│ │ WebSocket │ │ AI Brain │ │ Frida Hook │ │
|
||
│ │ 服务器连接 │ │ 自主决策 │ │ APP控制 │ │
|
||
│ └──────┬──────┘ └──────┬──────┘ └──────┬───────┘ │
|
||
│ │ │ │ │
|
||
│ └────────────────┼────────────────┘ │
|
||
│ ↕ Frida RPC (优先) / u2 (兜底) │
|
||
│ ┌────────────────────────────────────┐ │
|
||
│ │ Android系统 + 微信/抖音等APP │ │
|
||
│ └────────────────────────────────────┘ │
|
||
└──────────────────────────────────────────────────────┘
|
||
↕ WebSocket (wss://)
|
||
┌──────────────────────────────────────────────────────┐
|
||
│ SDK服务器 (FastAPI) │
|
||
│ 存客宝/触客宝通过API调用 → 服务器下发命令到手机 │
|
||
└──────────────────────────────────────────────────────┘
|
||
|
||
运行模式:
|
||
- 在线模式: 服务器连接 → 接收指令 + AI 辅助 + Frida 执行
|
||
- 离线模式: 服务器断连 → AI Brain 自主决策 → Frida/u2 执行 → 结果缓冲
|
||
|
||
连接机制:
|
||
- 手机主动发起WebSocket连接到服务器
|
||
- 心跳保活: 每N秒发送心跳包,服务器响应超时则重连
|
||
- 指数退避重连: 2s → 4s → 8s → 16s → 30s(最大)
|
||
- 断线后 AI Brain 接管,自主运行直到重连
|
||
- Frida 优先通道: Hook > u2 > 降级
|
||
|
||
配置优先级: 环境变量 > 命令行参数 > config.json > 默认值
|
||
WP_DEVICE_ID 设备ID
|
||
WP_SERVER_URL WebSocket基础地址(如 ws://192.168.1.100:8899/ws/device)
|
||
WP_PROJECT_ID 项目ID
|
||
WP_HEARTBEAT 心跳间隔(秒)
|
||
WP_AI_API_URL 卡若AI API 地址
|
||
WP_AI_API_KEY 卡若AI API 密钥
|
||
|
||
@author 卡若
|
||
@version 3.1.0
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import argparse
|
||
import os
|
||
import sys
|
||
import signal
|
||
import time
|
||
import base64
|
||
import random
|
||
import subprocess
|
||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
|
||
from datetime import datetime
|
||
from typing import Optional, Dict, Any, Callable, List
|
||
|
||
# 确保 agent/ 目录在 sys.path 中,方便 Termux 等环境直接运行
|
||
_AGENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
if _AGENT_DIR not in sys.path:
|
||
sys.path.insert(0, _AGENT_DIR)
|
||
|
||
try:
|
||
import websockets
|
||
except ImportError:
|
||
print("❌ 请安装: pip install websockets>=12.0")
|
||
sys.exit(1)
|
||
|
||
try:
|
||
import uiautomator2 as u2
|
||
except ImportError:
|
||
u2 = None
|
||
print("⚠️ uiautomator2未安装,部分功能不可用(pip install uiautomator2>=3.0.0)")
|
||
|
||
# 配置日志
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format='%(asctime)s [%(levelname)s] %(message)s'
|
||
)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class WorkPhoneAgent:
|
||
"""
|
||
AI数字员工 - 手机端Agent(Frida + AI Brain 增强版)
|
||
|
||
核心职责:
|
||
1. 主动连接SDK服务器并保持实时通信
|
||
2. 接收服务器命令并在手机上执行(Frida优先 → u2兜底)
|
||
3. 实时上报设备状态(电量/网络/APP状态)
|
||
4. 支持微信/抖音/小红书等多APP控制
|
||
5. 内置AI大脑,心跳驱动自主决策
|
||
6. 离线模式:服务器断连时AI独立运行
|
||
"""
|
||
|
||
VERSION = "3.1.0"
|
||
|
||
MIN_RECONNECT_DELAY = 2
|
||
MAX_RECONNECT_DELAY = 30
|
||
|
||
def __init__(
|
||
self,
|
||
device_id: str,
|
||
server_url: str,
|
||
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
|
||
self.reconnect_attempts = 0
|
||
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
|
||
self._init_u2()
|
||
|
||
# Frida Manager(优先通道)
|
||
self.frida_mgr = None
|
||
self._init_frida()
|
||
|
||
# ── 设备端深层防护模块 ──
|
||
self.device_guard = None
|
||
self.risk_sentinel = None
|
||
self.sensor_sim = None
|
||
self.touch_hardener = None
|
||
self.nurture_scheduler = None
|
||
self._init_anti_ban()
|
||
|
||
# AI Brain(自主决策引擎)
|
||
self.ai_brain = None
|
||
self._autonomous_stop = asyncio.Event()
|
||
self._autonomous_task: Optional[asyncio.Task] = None
|
||
ai_cfg = ai_config or {}
|
||
if ai_cfg.get("enabled", False):
|
||
self._init_ai_brain(ai_cfg)
|
||
|
||
logger.info(f"🤖 AI数字员工 v{self.VERSION} 初始化完成")
|
||
logger.info(f" 设备ID: {device_id}")
|
||
logger.info(f" 服务器: {server_url}")
|
||
logger.info(f" 心跳间隔: {heartbeat_interval}秒")
|
||
logger.info(f" Frida: {'✅ 已加载' if self.frida_mgr else '❌ 未加载'}")
|
||
logger.info(f" 防护: {'✅' if self.device_guard else '❌'} Guard | "
|
||
f"{'✅' if self.risk_sentinel else '❌'} Sentinel | "
|
||
f"{'✅' if self.sensor_sim else '❌'} Sensor | "
|
||
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. 仅手机本机(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 = 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}")
|
||
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
|
||
self.d = LocalDevice()
|
||
logger.info("✅ LocalDevice 已启动(ATX HTTP 直连)")
|
||
except Exception as e:
|
||
logger.warning(f"LocalDevice 初始化失败: {e}(将在运行时重试)")
|
||
|
||
def _init_frida(self):
|
||
"""初始化 Frida Manager(优先控制通道)"""
|
||
try:
|
||
from hook.frida_manager import FridaManager
|
||
import os as _os
|
||
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 = "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,
|
||
)
|
||
startup_timeout = float(_os.environ.get("WP_FRIDA_START_TIMEOUT", "12") or "12")
|
||
logger.info(f"Frida 启动尝试: mode={_mode} serial={_serial} port={_frida_port or self.frida_mgr.gadget_port} timeout={startup_timeout}s")
|
||
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="wp-frida-start")
|
||
future = executor.submit(self.frida_mgr.start)
|
||
try:
|
||
started = future.result(timeout=startup_timeout)
|
||
except FutureTimeoutError:
|
||
logger.warning(f"⚠️ Frida 启动超过 {startup_timeout:.0f}s,先降级到 u2/ADB,避免 Agent WS 离线")
|
||
started = False
|
||
finally:
|
||
executor.shutdown(wait=False, cancel_futures=True)
|
||
if started:
|
||
logger.info("🔗 Frida 控制通道已就绪")
|
||
else:
|
||
logger.warning("⚠️ Frida 启动失败,将使用 u2 通道")
|
||
self.frida_mgr = None
|
||
except ImportError:
|
||
logger.info("Frida 模块未安装,跳过 Hook 通道")
|
||
except Exception as e:
|
||
logger.warning(f"Frida 初始化异常: {e}")
|
||
self.frida_mgr = None
|
||
|
||
def _on_frida_event(self, payload: dict):
|
||
"""Frida 事件回调(Hook 事件上报)"""
|
||
event_type = payload.get("event", "unknown")
|
||
logger.debug(f"[Frida事件] {event_type}: {str(payload)[:100]}")
|
||
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):
|
||
"""初始化设备端深层防护模块"""
|
||
if not self.d:
|
||
logger.warning("⚠️ u2 未连接,防护模块跳过初始化")
|
||
return
|
||
|
||
try:
|
||
from anti_ban.device_guard import DeviceGuard
|
||
self.device_guard = DeviceGuard(self.d)
|
||
guard_report = self.device_guard.run_full_check()
|
||
if guard_report.get("warnings"):
|
||
logger.warning(f"🛡️ 设备自检发现 {guard_report['warning_count']} 项警告")
|
||
except Exception as e:
|
||
logger.error(f"DeviceGuard 初始化失败: {e}")
|
||
|
||
try:
|
||
from anti_ban.risk_sentinel import RiskSentinel
|
||
self.risk_sentinel = RiskSentinel()
|
||
except Exception as e:
|
||
logger.error(f"RiskSentinel 初始化失败: {e}")
|
||
|
||
try:
|
||
from anti_ban.sensor_simulator import SensorSimulator
|
||
self.sensor_sim = SensorSimulator(device=self.d)
|
||
except Exception as e:
|
||
logger.error(f"SensorSimulator 初始化失败: {e}")
|
||
|
||
try:
|
||
from anti_ban.touch_hardener import TouchHardener
|
||
self.touch_hardener = TouchHardener(self.d)
|
||
except Exception as e:
|
||
logger.error(f"TouchHardener 初始化失败: {e}")
|
||
|
||
try:
|
||
from anti_ban.nurture_scheduler import NurtureScheduler
|
||
self.nurture_scheduler = NurtureScheduler()
|
||
except Exception as e:
|
||
logger.error(f"NurtureScheduler 初始化失败: {e}")
|
||
|
||
def _on_risk_alert(self, level: int, message: str, details: dict):
|
||
"""风控告警回调 — 上报服务器"""
|
||
logger.warning(f"🚨 风控告警 Lv{level}: {message}")
|
||
if self.connected and self.ws:
|
||
asyncio.ensure_future(self._send_event("risk_alert", {
|
||
"level": level,
|
||
"message": message,
|
||
"details": details,
|
||
}))
|
||
|
||
def _init_ai_brain(self, ai_cfg: dict):
|
||
"""初始化 AI Brain"""
|
||
try:
|
||
from ai_brain import AIBrain
|
||
self.ai_brain = AIBrain(
|
||
ai_api_url=ai_cfg.get("api_url", "http://localhost:3102"),
|
||
ai_api_key=ai_cfg.get("api_key", ""),
|
||
ai_model=ai_cfg.get("model", "auto"),
|
||
brain_interval=ai_cfg.get("brain_interval", 60),
|
||
standing_orders=ai_cfg.get("standing_orders", []),
|
||
enabled=True,
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"AI Brain 初始化失败: {e}")
|
||
self.ai_brain = None
|
||
|
||
# ====================================================================
|
||
# 一、连接管理(核心:主动连接 + 心跳保活 + 指数退避重连)
|
||
# ====================================================================
|
||
|
||
@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:
|
||
# 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()
|
||
|
||
async with websockets.connect(
|
||
self.server_url,
|
||
ping_interval=20,
|
||
ping_timeout=10,
|
||
close_timeout=5,
|
||
max_size=10 * 1024 * 1024,
|
||
) as ws:
|
||
self.ws = ws
|
||
self.connected = True
|
||
self.reconnect_attempts = 0
|
||
self.last_heartbeat_ack = time.time()
|
||
|
||
logger.info("✅ 服务器连接成功!")
|
||
|
||
if self.ai_brain:
|
||
self.ai_brain.online = True
|
||
|
||
# 1. 注册 + 上报 + 拉配置
|
||
await self._register()
|
||
await self._send_event("agent_started", {
|
||
"device_id": self.device_id,
|
||
"project_id": self.project_id,
|
||
"frida_available": self.frida_mgr is not None and self.frida_mgr.connected,
|
||
"ai_brain_enabled": self.ai_brain is not None,
|
||
"anti_ban": {
|
||
"device_guard": self.device_guard.report if self.device_guard else None,
|
||
"risk_sentinel": self.risk_sentinel is not None,
|
||
"sensor_sim": self.sensor_sim is not None,
|
||
"touch_hardener": self.touch_hardener is not None,
|
||
"nurture_scheduler": self.nurture_scheduler is not None,
|
||
},
|
||
})
|
||
await self._send_device_request("get_config", {})
|
||
|
||
# 2. 上传离线缓冲结果
|
||
await self._flush_offline_buffer()
|
||
|
||
# 3. 启动并发任务(含连接守护)
|
||
tasks = [
|
||
asyncio.create_task(self._heartbeat_loop()),
|
||
asyncio.create_task(self._status_report_loop()),
|
||
asyncio.create_task(self._message_loop()),
|
||
asyncio.create_task(self._connection_guard_loop()),
|
||
]
|
||
if self.ai_brain:
|
||
tasks.append(asyncio.create_task(self._ai_brain_loop()))
|
||
if self.nurture_scheduler:
|
||
tasks.append(asyncio.create_task(self._nurture_loop()))
|
||
|
||
done, pending = await asyncio.wait(
|
||
tasks, return_when=asyncio.FIRST_COMPLETED
|
||
)
|
||
|
||
for task in pending:
|
||
task.cancel()
|
||
try:
|
||
await task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
|
||
except websockets.ConnectionClosed as e:
|
||
logger.warning(f"🔌 连接断开: code={e.code}, reason={e.reason}")
|
||
except ConnectionRefusedError:
|
||
logger.warning("🚫 服务器拒绝连接")
|
||
except OSError as e:
|
||
logger.warning(f"🌐 网络错误: {e}")
|
||
except Exception as e:
|
||
logger.error(f"❌ 连接异常: {e}")
|
||
finally:
|
||
self.connected = False
|
||
self.ws = None
|
||
|
||
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()
|
||
|
||
# 尝试恢复网络
|
||
if self.d:
|
||
try:
|
||
from hawk import try_reconnect_network
|
||
nr = try_reconnect_network(self.d)
|
||
if nr.get("success"):
|
||
logger.info(f"🌐 网络已恢复: {nr.get('message', '')}")
|
||
else:
|
||
logger.info(f"🌐 网络恢复: {nr.get('message', '')}")
|
||
except ImportError:
|
||
pass
|
||
except Exception as e:
|
||
logger.debug(f"网络恢复尝试异常: {e}")
|
||
|
||
delay = self._get_reconnect_delay()
|
||
logger.info(f"⏳ {delay:.1f}秒后第{self.reconnect_attempts}次重连...")
|
||
await asyncio.sleep(delay)
|
||
|
||
def _start_autonomous_mode(self):
|
||
"""启动离线自主运行模式"""
|
||
if not self.ai_brain or self._autonomous_task:
|
||
return
|
||
self._autonomous_stop.clear()
|
||
self._autonomous_task = asyncio.ensure_future(
|
||
self.ai_brain.autonomous_loop(
|
||
device_status_fn=self._get_quick_status,
|
||
execute_fn=self._execute_with_frida_priority,
|
||
stop_event=self._autonomous_stop,
|
||
)
|
||
)
|
||
logger.info("🧠 已启动离线自主运行模式")
|
||
|
||
def _stop_autonomous_mode(self):
|
||
"""停止离线自主运行模式"""
|
||
if self._autonomous_task:
|
||
self._autonomous_stop.set()
|
||
self._autonomous_task = None
|
||
logger.info("🧠 已停止离线自主运行模式")
|
||
|
||
async def _flush_offline_buffer(self):
|
||
"""上传离线期间缓冲的执行结果"""
|
||
if not self.ai_brain:
|
||
return
|
||
buffered = self.ai_brain.flush_offline_buffer()
|
||
if not buffered:
|
||
return
|
||
logger.info(f"📤 上传 {len(buffered)} 条离线缓冲结果")
|
||
try:
|
||
await self._send_event("offline_buffer_upload", {
|
||
"count": len(buffered),
|
||
"results": buffered[-50:], # 最多上传最近50条
|
||
})
|
||
except Exception as e:
|
||
logger.error(f"离线缓冲上传失败: {e}")
|
||
|
||
def _get_reconnect_delay(self) -> float:
|
||
"""计算重连延迟(指数退避 + 随机抖动)"""
|
||
delay = min(
|
||
self.MIN_RECONNECT_DELAY * (2 ** self.reconnect_attempts),
|
||
self.MAX_RECONNECT_DELAY
|
||
)
|
||
# 添加随机抖动避免雪崩
|
||
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):
|
||
"""发送设备注册信息"""
|
||
device_info = self._get_device_info()
|
||
|
||
await self.ws.send(json.dumps({
|
||
"type": "register",
|
||
"data": {
|
||
**device_info,
|
||
"project_id": self.project_id,
|
||
"agent_version": self.VERSION,
|
||
"registered_at": datetime.now().isoformat(),
|
||
}
|
||
}))
|
||
|
||
logger.info(f"📋 设备已注册: {device_info.get('model', 'Unknown')}")
|
||
|
||
# ====================================================================
|
||
# 一B、AI Brain 循环 + Frida 优先执行
|
||
# ====================================================================
|
||
|
||
async def _ai_brain_loop(self):
|
||
"""AI Brain 在线模式循环 — 随心跳周期运行"""
|
||
if not self.ai_brain:
|
||
return
|
||
|
||
while self.connected:
|
||
try:
|
||
await asyncio.sleep(self.ai_brain.brain_interval)
|
||
if not self.connected:
|
||
break
|
||
|
||
status = self._get_quick_status()
|
||
result = await self.ai_brain.heartbeat_cycle(
|
||
device_status=status,
|
||
execute_fn=self._execute_with_frida_priority,
|
||
)
|
||
if result.get("acted"):
|
||
logger.info(f"🧠 AI Brain 在线执行: {len(result.get('results', []))}个操作")
|
||
await self._send_event("ai_brain_acted", {
|
||
"results_count": len(result.get("results", [])),
|
||
"reason": result.get("reason", ""),
|
||
})
|
||
|
||
except asyncio.CancelledError:
|
||
break
|
||
except Exception as e:
|
||
logger.error(f"AI Brain 循环异常: {e}")
|
||
await asyncio.sleep(10)
|
||
|
||
async def _nurture_loop(self):
|
||
"""养号调度循环 — 每 15 分钟检查一次"""
|
||
while self.connected:
|
||
try:
|
||
await asyncio.sleep(random.randint(800, 1000))
|
||
if not self.connected:
|
||
break
|
||
if self.risk_sentinel:
|
||
stats = self.risk_sentinel.get_stats()
|
||
if stats.get("_total_ops", 0) > 500:
|
||
continue
|
||
if self.nurture_scheduler:
|
||
plan = self.nurture_scheduler.get_nurture_plan(self.device_id)
|
||
if plan:
|
||
item = plan[0]
|
||
logger.info(f"🌱 养号计划: {item.get('action', '?')}")
|
||
await self._send_event("nurture_plan", {"plan": plan})
|
||
except asyncio.CancelledError:
|
||
break
|
||
except Exception as e:
|
||
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",
|
||
"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(
|
||
self, script: str, action: str, params: dict, hook_only: bool = False
|
||
) -> dict:
|
||
"""
|
||
Frida 优先执行通道:
|
||
1. 优先走 Frida RPC(Hook 级别,直接操作 APP 内部)
|
||
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
|
||
executor = HookExecutor(self.frida_mgr)
|
||
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"}
|
||
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:
|
||
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": 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,
|
||
},
|
||
}
|
||
|
||
# 通道2: u2 Skill(兜底)
|
||
return await self._execute_skill(script, action, params)
|
||
|
||
# ====================================================================
|
||
# 二、心跳机制(参考抖音心跳: 应用层心跳 + 状态上报)
|
||
# ====================================================================
|
||
|
||
async def _heartbeat_loop(self):
|
||
"""
|
||
轻量心跳循环 — 不做任何 u2/ADB I/O,仅发送业务层心跳并检测 ACK。
|
||
u2 是非线程安全的,并发访问会导致 HTTP 请求挂起。
|
||
守护任务(弹窗/网络/状态)由 _connection_guard_loop 独立处理。
|
||
"""
|
||
missed_count = 0
|
||
|
||
while self.connected:
|
||
try:
|
||
await asyncio.sleep(self.heartbeat_interval)
|
||
|
||
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": status,
|
||
}
|
||
|
||
await self.ws.send(json.dumps(heartbeat_data))
|
||
|
||
# 检查 ACK(宽容模式:5 分钟无 ACK 才断连,避免 u2 竞争导致误判)
|
||
time_since_ack = time.time() - self.last_heartbeat_ack
|
||
if time_since_ack > 300:
|
||
missed_count += 1
|
||
logger.warning(f"⚠️ 心跳无响应 ({missed_count}/3),上次ACK: {time_since_ack:.0f}秒前")
|
||
|
||
if missed_count >= 3:
|
||
logger.error("💔 连续3次心跳无响应,主动断开重连")
|
||
await self.ws.close()
|
||
break
|
||
else:
|
||
missed_count = 0
|
||
|
||
except websockets.ConnectionClosed:
|
||
break
|
||
except Exception as e:
|
||
logger.error(f"心跳错误: {e}")
|
||
break
|
||
|
||
def _try_reconnect_u2(self):
|
||
"""尝试重新连接设备控制(u2 或 LocalDevice)"""
|
||
import shutil
|
||
has_adb = shutil.which("adb") is not None
|
||
|
||
if has_adb and u2:
|
||
try:
|
||
d = self._connect_u2_with_fallback()
|
||
self.d = d
|
||
self._connection_guard = None
|
||
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:
|
||
if not self._running_on_device():
|
||
return
|
||
|
||
if not self._running_on_device():
|
||
return
|
||
|
||
try:
|
||
from local_device import LocalDevice
|
||
d = LocalDevice()
|
||
self.d = d
|
||
self._connection_guard = None
|
||
logger.info("🔄 LocalDevice 重连成功")
|
||
except Exception as e:
|
||
logger.debug(f"设备重连尝试失败: {e}")
|
||
|
||
def _get_connection_guard(self):
|
||
"""获取或创建连接守护技能实例"""
|
||
if not hasattr(self, '_connection_guard'):
|
||
self._connection_guard = None
|
||
if self._connection_guard is None and self.d:
|
||
try:
|
||
from skills.connection_guard import ConnectionGuardSkill
|
||
self._connection_guard = ConnectionGuardSkill(self.d, bus=None)
|
||
logger.info("🛡️ 连接守护技能已加载")
|
||
except Exception as e:
|
||
logger.debug(f"连接守护加载失败: {e}")
|
||
return self._connection_guard
|
||
|
||
async def _connection_guard_loop(self):
|
||
"""
|
||
常驻连接守护循环 — 独立于心跳,高频扫描系统弹窗
|
||
|
||
与心跳的分工:
|
||
- 本循环:每 5 秒扫描一次弹窗(USB授权、权限请求等),确保不会因弹窗阻塞设备
|
||
- 心跳循环:每 N 次心跳做一次完整守护(包含网络/u2/屏幕等重量级检查)
|
||
|
||
当 u2 不可用时,会每 30 秒尝试重新初始化,而不是立即退出
|
||
(立即退出会导致 asyncio.wait(FIRST_COMPLETED) 杀掉所有任务→断连循环)
|
||
"""
|
||
scan_count = 0
|
||
u2_retry_interval = 30
|
||
|
||
while self.connected:
|
||
try:
|
||
guard = self._get_connection_guard()
|
||
|
||
if not guard:
|
||
if not self.d:
|
||
self._try_reconnect_u2()
|
||
guard = self._get_connection_guard()
|
||
|
||
if not guard:
|
||
await asyncio.sleep(u2_retry_interval)
|
||
continue
|
||
|
||
await asyncio.sleep(5)
|
||
if not self.connected:
|
||
break
|
||
|
||
scan_count += 1
|
||
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
|
||
except Exception as e:
|
||
logger.debug(f"守护扫描异常: {e}")
|
||
await asyncio.sleep(10)
|
||
|
||
logger.info("🛡️ 连接守护循环已停止")
|
||
|
||
async def _status_report_loop(self):
|
||
"""定期上报详细设备状态(每5分钟)"""
|
||
while self.connected:
|
||
try:
|
||
await asyncio.sleep(300) # 5分钟
|
||
|
||
if not self.connected or not self.ws:
|
||
break
|
||
|
||
status = self._get_full_status()
|
||
await self.ws.send(json.dumps({
|
||
"type": "status_report",
|
||
"data": status
|
||
}))
|
||
|
||
logger.debug(f"📊 状态上报完成")
|
||
|
||
except Exception:
|
||
break
|
||
|
||
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:
|
||
info = self.d.info
|
||
status["screen_on"] = info.get("screenOn", False)
|
||
status["current_app"] = info.get("currentPackageName", "")
|
||
|
||
wechat_pkg = "com.tencent.mm"
|
||
try:
|
||
running = self.d.shell(f"pidof {wechat_pkg}").output.strip()
|
||
status["wechat_running"] = bool(running)
|
||
status["wechat_pid"] = running if running else None
|
||
except Exception:
|
||
status["wechat_running"] = False
|
||
|
||
status["wechat_foreground"] = (
|
||
status.get("current_app") == wechat_pkg
|
||
)
|
||
|
||
try:
|
||
net_out = self.d.shell(
|
||
"dumpsys connectivity | grep -m1 'type: WIFI\\|type: MOBILE' || echo 'none'"
|
||
).output.strip()
|
||
status["network_type"] = (
|
||
"wifi" if "WIFI" in net_out
|
||
else "mobile" if "MOBILE" in net_out
|
||
else "none"
|
||
)
|
||
except Exception:
|
||
status["network_type"] = "unknown"
|
||
|
||
# 电量(轻量级,心跳附带)
|
||
try:
|
||
battery_out = self.d.shell("dumpsys battery | grep level").output.strip()
|
||
if "level:" in battery_out:
|
||
level = int(battery_out.split(":")[1].strip())
|
||
status["battery"] = level
|
||
status["battery_level"] = level
|
||
except Exception:
|
||
pass
|
||
|
||
except Exception:
|
||
pass
|
||
|
||
status["guard_active"] = hasattr(self, '_connection_guard') and self._connection_guard is not None
|
||
|
||
return status
|
||
|
||
def _get_full_status(self) -> dict:
|
||
"""获取完整设备状态"""
|
||
status = {
|
||
"device_id": self.device_id,
|
||
"agent_version": self.VERSION,
|
||
"uptime": int(time.time() - self.start_time) if self.start_time else 0,
|
||
"commands_executed": self.commands_executed,
|
||
"connected": self.connected,
|
||
"timestamp": datetime.now().isoformat(),
|
||
}
|
||
|
||
if self.d:
|
||
try:
|
||
info = self.d.info
|
||
status.update({
|
||
"screen_on": info.get("screenOn", False),
|
||
"current_app": info.get("currentPackageName", ""),
|
||
"display": {
|
||
"width": info.get("displayWidth", 0),
|
||
"height": info.get("displayHeight", 0),
|
||
},
|
||
"rotation": info.get("displayRotation", 0),
|
||
})
|
||
|
||
# 获取电池信息
|
||
try:
|
||
battery = self.d.shell("dumpsys battery | grep -E 'level|status|plugged'").output
|
||
for line in battery.strip().split('\n'):
|
||
line = line.strip()
|
||
if 'level:' in line:
|
||
status["battery_level"] = int(line.split(':')[1].strip())
|
||
elif 'status:' in line:
|
||
status["battery_status"] = int(line.split(':')[1].strip())
|
||
elif 'plugged:' in line:
|
||
status["charging"] = int(line.split(':')[1].strip()) > 0
|
||
except:
|
||
pass
|
||
|
||
# 获取网络信息
|
||
try:
|
||
wifi = self.d.shell("dumpsys wifi | grep 'Wi-Fi is'").output.strip()
|
||
status["wifi"] = "enabled" in wifi.lower()
|
||
ssid_out = self.d.shell(
|
||
"dumpsys wifi | grep -m1 'mWifiInfo' || echo ''"
|
||
).output.strip()
|
||
if "SSID:" in ssid_out:
|
||
import re
|
||
m = re.search(r'SSID:\s*([^,]+)', ssid_out)
|
||
if m:
|
||
status["wifi_ssid"] = m.group(1).strip().strip('"')
|
||
ip_out = self.d.shell("ip route | grep -m1 'src'").output.strip()
|
||
if "src " in ip_out:
|
||
status["ip_address"] = ip_out.split("src ")[-1].split()[0]
|
||
except Exception:
|
||
pass
|
||
|
||
# 微信详细状态
|
||
wechat_pkg = "com.tencent.mm"
|
||
try:
|
||
pid = self.d.shell(f"pidof {wechat_pkg}").output.strip()
|
||
status["wechat"] = {
|
||
"installed": True,
|
||
"running": bool(pid),
|
||
"pid": pid or None,
|
||
"foreground": info.get("currentPackageName") == wechat_pkg,
|
||
}
|
||
if pid:
|
||
mem = self.d.shell(
|
||
f"dumpsys meminfo {wechat_pkg} | grep 'TOTAL PSS' || echo ''"
|
||
).output.strip()
|
||
if "TOTAL" in mem:
|
||
parts = mem.split()
|
||
for p in parts:
|
||
if p.replace(",", "").isdigit():
|
||
status["wechat"]["memory_kb"] = int(p.replace(",", ""))
|
||
break
|
||
except Exception:
|
||
status["wechat"] = {"installed": False, "running": False}
|
||
|
||
except Exception as e:
|
||
status["error"] = str(e)
|
||
|
||
return status
|
||
|
||
# ====================================================================
|
||
# 三、消息处理(接收服务器命令,执行操作)
|
||
# ====================================================================
|
||
|
||
async def _message_loop(self):
|
||
"""消息接收循环(非阻塞:长耗时命令用 create_task 并发执行,不阻塞心跳 ACK 接收)"""
|
||
try:
|
||
async for message in self.ws:
|
||
data = json.loads(message)
|
||
msg_type = data.get("type")
|
||
if msg_type in ("pong", "heartbeat_ack"):
|
||
self.last_heartbeat_ack = time.time()
|
||
continue
|
||
if msg_type == "registered":
|
||
logger.info("✅ 服务器确认注册")
|
||
continue
|
||
asyncio.create_task(self._handle_message(message))
|
||
except websockets.ConnectionClosed:
|
||
logger.info("消息循环: 连接已关闭")
|
||
except Exception as e:
|
||
logger.error(f"消息循环错误: {e}")
|
||
|
||
async def _handle_message(self, message: str):
|
||
"""处理服务器消息(心跳/注册已在 _message_loop 快速路径处理)"""
|
||
try:
|
||
data = json.loads(message)
|
||
msg_type = data.get("type")
|
||
command_id = data.get("command_id")
|
||
|
||
logger.info(f"📩 收到命令: {msg_type} (id={command_id})")
|
||
|
||
if msg_type == "execute":
|
||
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":
|
||
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:
|
||
task_data = data.get("data", {})
|
||
instruction = task_data.get("instruction", "")
|
||
priority = task_data.get("priority", 5)
|
||
self.ai_brain.add_task(instruction, source="server", priority=priority)
|
||
await self._send_response(command_id, {"code": 200, "message": "AI任务已入队"})
|
||
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", "")
|
||
self.ai_brain.add_standing_order(order)
|
||
await self._send_response(command_id, {"code": 200, "message": "常驻指令已添加"})
|
||
|
||
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:
|
||
self.heartbeat_interval = int(ack_data["heartbeat_interval"])
|
||
logger.info(f"已应用服务端配置: heartbeat_interval={self.heartbeat_interval}")
|
||
logger.debug(f"device_request_ack: request_id={data.get('request_id')} success={data.get('success')}")
|
||
|
||
elif msg_type == "agent_status":
|
||
status_info = {
|
||
"agent_version": self.version if hasattr(self, 'version') else "3.1.0",
|
||
"device_id": self.device_id,
|
||
"connected": self.connected,
|
||
"uptime": int(time.time() - self.start_time) if hasattr(self, 'start_time') else 0,
|
||
"ai_brain": bool(self.ai_brain),
|
||
"frida": bool(getattr(self, 'frida_mgr', None)),
|
||
"skills": list(self.skill_registry.keys()) if hasattr(self, 'skill_registry') else [],
|
||
}
|
||
await self._send_response(command_id, {"code": 200, "data": status_info})
|
||
|
||
else:
|
||
logger.warning(f"未知消息类型: {msg_type}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"处理消息错误: {e}")
|
||
if command_id:
|
||
await self._send_response(command_id, {
|
||
"code": 500,
|
||
"message": str(e)
|
||
})
|
||
|
||
async def _send_response(self, command_id: str, result: dict):
|
||
"""发送命令执行结果"""
|
||
if not self.ws or not self.connected:
|
||
return
|
||
|
||
try:
|
||
await self.ws.send(json.dumps({
|
||
"type": "response",
|
||
"command_id": command_id,
|
||
"device_id": self.device_id,
|
||
"code": result.get("code", 200),
|
||
"message": result.get("message", "success"),
|
||
"data": result.get("data", {}),
|
||
"timestamp": int(time.time()),
|
||
}))
|
||
except Exception as e:
|
||
logger.error(f"发送响应失败: {e}")
|
||
|
||
async def _send_event(self, event: str, data: dict = None):
|
||
"""设备端事件上报(技能执行完毕、异常等),由服务端处理/落库/转发"""
|
||
if not self.ws or not self.connected:
|
||
return
|
||
try:
|
||
await self.ws.send(json.dumps({
|
||
"type": "event",
|
||
"device_id": self.device_id,
|
||
"event": event,
|
||
"data": data or {},
|
||
"timestamp": int(time.time()),
|
||
}))
|
||
except Exception as e:
|
||
logger.error(f"发送事件失败: {e}")
|
||
|
||
async def _send_device_request(self, action: str, params: dict = None) -> dict:
|
||
"""设备端请求服务端执行操作(拉配置、落库等),等待 device_request_ack(可选)"""
|
||
if not self.ws or not self.connected:
|
||
return {"success": False, "error": "未连接"}
|
||
request_id = f"req_{int(time.time() * 1000)}_{random.randint(1000, 9999)}"
|
||
try:
|
||
await self.ws.send(json.dumps({
|
||
"type": "device_request",
|
||
"device_id": self.device_id,
|
||
"request_id": request_id,
|
||
"action": action,
|
||
"params": params or {},
|
||
"timestamp": int(time.time()),
|
||
}))
|
||
except Exception as e:
|
||
logger.error(f"发送 device_request 失败: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
return {"success": True, "request_id": request_id}
|
||
|
||
async def _handle_config_update(self, config: dict):
|
||
"""处理服务器推送的配置更新"""
|
||
if "heartbeat_interval" in config:
|
||
self.heartbeat_interval = config["heartbeat_interval"]
|
||
logger.info(f"心跳间隔更新为: {self.heartbeat_interval}秒")
|
||
|
||
# ====================================================================
|
||
# 四、命令执行(操作手机)
|
||
# ====================================================================
|
||
|
||
async def _execute_command(self, cmd: dict) -> dict:
|
||
"""执行命令(Frida优先 → u2兜底),前置风控检查"""
|
||
action = cmd.get("action")
|
||
params = cmd.get("params", {})
|
||
script = cmd.get("script")
|
||
channel = cmd.get("channel", "auto") # auto | frida | u2
|
||
hook_only = bool(cmd.get("hook_only"))
|
||
|
||
# 风控哨兵前置检查
|
||
if self.risk_sentinel and action:
|
||
check_result = self.risk_sentinel.check(action)
|
||
if not check_result.get("allowed", True):
|
||
return {
|
||
"code": 429,
|
||
"message": f"风控限制: {check_result.get('reason', '操作频率过高')}",
|
||
"data": {"risk_check": check_result},
|
||
}
|
||
|
||
try:
|
||
if script:
|
||
if channel == "u2":
|
||
return await self._execute_skill(script, action, params)
|
||
return await self._execute_with_frida_priority(
|
||
script, action, params, hook_only=hook_only
|
||
)
|
||
|
||
if not self.d:
|
||
return {"code": 503, "message": "uiautomator2未连接"}
|
||
|
||
def _run_basic():
|
||
if action == "screenshot":
|
||
return self._screenshot()
|
||
elif action == "click":
|
||
self.d.click(params["x"], params["y"])
|
||
return {"code": 200, "data": {"success": True}}
|
||
elif action == "click_text":
|
||
text = params["text"]
|
||
t = params.get("timeout", 10)
|
||
if self.d(text=text).wait(timeout=t):
|
||
self.d(text=text).click()
|
||
return {"code": 200, "data": {"success": True, "text": text}}
|
||
return {"code": 404, "message": f"未找到: {text}"}
|
||
elif action == "input":
|
||
if params.get("clear", True):
|
||
self.d.clear_text()
|
||
self.d.send_keys(params["text"])
|
||
return {"code": 200, "data": {"success": True}}
|
||
elif action == "swipe":
|
||
if "x1" in params and "y1" in params and "x2" in params and "y2" in params:
|
||
dur = params.get("duration", 300) / 1000.0
|
||
self.d.swipe(params["x1"], params["y1"], params["x2"], params["y2"], duration=dur)
|
||
else:
|
||
direction = params.get("direction", "up")
|
||
self.d.swipe_ext(direction, scale=params.get("scale", 0.8))
|
||
return {"code": 200, "data": {"success": True}}
|
||
elif action == "ui_tree":
|
||
xml = self.d.dump_hierarchy()
|
||
return {"code": 200, "data": {"xml": xml, "length": len(xml)}}
|
||
elif action == "app_start":
|
||
self.d.app_start(params["package"])
|
||
return {"code": 200, "data": {"success": True}}
|
||
elif action == "app_stop":
|
||
self.d.app_stop(params["package"])
|
||
return {"code": 200, "data": {"success": True}}
|
||
elif action == "press_key":
|
||
self.d.press(params.get("key", "home"))
|
||
return {"code": 200, "data": {"success": True}}
|
||
elif action == "device_info":
|
||
return {"code": 200, "data": self._get_device_info()}
|
||
elif action == "status":
|
||
return {"code": 200, "data": self._get_full_status()}
|
||
else:
|
||
return {"code": 400, "message": f"未知操作: {action}"}
|
||
|
||
return await self._run_u2_sync(_run_basic)
|
||
|
||
except Exception as e:
|
||
logger.error(f"执行命令错误: {e}")
|
||
if self.risk_sentinel and action:
|
||
self.risk_sentinel.record(action)
|
||
return {"code": 500, "message": str(e)}
|
||
|
||
async def _execute_skill(self, script: str, action: str, params: dict) -> dict:
|
||
"""执行APP技能"""
|
||
try:
|
||
if not self.d:
|
||
return {"code": 503, "message": "uiautomator2未连接"}
|
||
|
||
# 动态加载技能
|
||
# 通过技能注册表获取
|
||
from skills import get_skill
|
||
try:
|
||
skill_class = get_skill(script)
|
||
except ImportError as ie:
|
||
return {"code": 404, "message": str(ie)}
|
||
|
||
anti_ban_ctx = {
|
||
"risk_sentinel": self.risk_sentinel,
|
||
"touch_hardener": self.touch_hardener,
|
||
"sensor_sim": self.sensor_sim,
|
||
"nurture_scheduler": self.nurture_scheduler,
|
||
}
|
||
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, resolved_action, None)
|
||
if not method:
|
||
return {"code": 404, "message": f"技能{script}不支持操作: {action}"}
|
||
|
||
# 执行(同步方法放到线程池,避免阻塞事件循环导致 WS ping 超时断连)
|
||
import inspect
|
||
if inspect.iscoroutinefunction(method):
|
||
result = await method(**params)
|
||
else:
|
||
result = await self._run_u2_sync(lambda: method(**params))
|
||
|
||
return {"code": 200, "data": result}
|
||
|
||
except Exception as e:
|
||
logger.error(f"执行技能错误 [{script}.{action}]: {e}")
|
||
return {"code": 500, "message": str(e)}
|
||
|
||
async def _execute_agent_task(self, data: dict) -> dict:
|
||
"""执行AI Agent任务(自然语言控制);优先走 SkillExecutor 微信/抖音复合任务,无 LLM 也可执行"""
|
||
task = (data.get("task") or "").strip()
|
||
logger.info(f"🤖 AI任务: {task}")
|
||
if not task:
|
||
return {"code": 400, "message": "task 为空", "data": {"success": False, "error": "task 为空"}}
|
||
|
||
try:
|
||
if not self.d:
|
||
return {"code": 503, "message": "uiautomator2未连接", "data": {"success": False, "error": "设备未连接"}}
|
||
try:
|
||
from skill_executor import SkillExecutor
|
||
executor = SkillExecutor(self.d, anti_ban_ctx={
|
||
"risk_sentinel": self.risk_sentinel,
|
||
"touch_hardener": self.touch_hardener,
|
||
"sensor_sim": self.sensor_sim,
|
||
"nurture_scheduler": self.nurture_scheduler,
|
||
})
|
||
except ImportError:
|
||
executor = None
|
||
if executor:
|
||
result = None
|
||
if "微信" in task:
|
||
result = executor.execute_wechat_task(task)
|
||
elif "抖音" in task:
|
||
result = executor.execute_douyin_task(task)
|
||
elif "小红书" in task:
|
||
result = executor.execute_xhs_task(task)
|
||
elif "闲鱼" in task:
|
||
result = executor.execute_xianyu_task(task)
|
||
elif "Soul" in task or "soul" in task.lower() or "灵魂" in task:
|
||
result = executor.execute_soul_task(task)
|
||
elif any(k in task for k in ("连接网络", "恢复网络", "打开WiFi", "打开网络", "断网", "连不上网", "上网")):
|
||
result = executor.execute_network_reconnect()
|
||
else:
|
||
result = executor.execute_command(task)
|
||
if result is None:
|
||
result = {"success": False, "error": "未匹配到执行路径"}
|
||
return {"code": 200, "data": result}
|
||
return {"code": 200, "data": {"success": False, "error": "AI Agent任务引擎未加载", "task": task}}
|
||
except Exception as e:
|
||
logger.exception(f"AI任务执行异常: {e}")
|
||
return {"code": 500, "message": str(e), "data": {"success": False, "error": str(e), "task": task}}
|
||
|
||
# ====================================================================
|
||
# 五、设备信息
|
||
# ====================================================================
|
||
|
||
def _screenshot(self) -> dict:
|
||
"""截图(raw 不支持时回退 PNG bytes)"""
|
||
try:
|
||
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,
|
||
"data": {
|
||
"base64": b64,
|
||
"width": info.get("displayWidth", 0),
|
||
"height": info.get("displayHeight", 0),
|
||
"size": len(raw_bytes),
|
||
}
|
||
}
|
||
except Exception as e:
|
||
return {"code": 500, "message": str(e)}
|
||
|
||
def _get_device_info(self) -> dict:
|
||
"""获取设备信息"""
|
||
info = {}
|
||
|
||
if self.d:
|
||
try:
|
||
d_info = self.d.info
|
||
info = {
|
||
"device_id": self.device_id,
|
||
"model": d_info.get("productName", "Unknown"),
|
||
"brand": d_info.get("brand", "Unknown"),
|
||
"android_version": str(d_info.get("sdkVersion", "Unknown")),
|
||
"display": {
|
||
"width": d_info.get("displayWidth", 0),
|
||
"height": d_info.get("displayHeight", 0),
|
||
},
|
||
"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 = []
|
||
try:
|
||
output = self.d.shell("pm list packages -3").output
|
||
app_detect = {
|
||
'com.tencent.mm': 'wechat',
|
||
'com.ss.android.ugc.aweme': 'douyin',
|
||
'com.xingin.xhs': 'xhs',
|
||
'com.taobao.idlefish': 'xianyu',
|
||
'cn.soulapp.android': 'soul',
|
||
}
|
||
for line in output.strip().split('\n'):
|
||
pkg = line.replace('package:', '').strip()
|
||
for check_pkg, name in app_detect.items():
|
||
if check_pkg in pkg:
|
||
apps.append(name)
|
||
except:
|
||
pass
|
||
|
||
info["installed_apps"] = apps
|
||
capabilities = [
|
||
"u2", "screenshot", "click", "input", "swipe",
|
||
"ui_tree", "app_control", "skill_execute",
|
||
"skill_wechat", "skill_douyin", "skill_xhs", "skill_xianyu",
|
||
"skill_network_reconnect", "hawk",
|
||
"connection_guard", "popup_auto_dismiss",
|
||
"event", "device_request",
|
||
]
|
||
if self.frida_mgr and self.frida_mgr.connected:
|
||
capabilities.extend(["frida", "hook", "frida_rpc"])
|
||
if self.ai_brain:
|
||
capabilities.extend(["ai_brain", "autonomous_mode"])
|
||
info["capabilities"] = capabilities
|
||
|
||
if self.frida_mgr:
|
||
info["frida"] = self.frida_mgr.get_status()
|
||
if self.ai_brain:
|
||
info["ai_brain"] = self.ai_brain.get_status()
|
||
# 防护模块状态
|
||
info["anti_ban"] = {}
|
||
if self.device_guard:
|
||
info["anti_ban"]["guard"] = self.device_guard.report
|
||
if self.risk_sentinel:
|
||
info["anti_ban"]["sentinel"] = self.risk_sentinel.get_stats()
|
||
if self.nurture_scheduler:
|
||
info["anti_ban"]["nurture"] = self.nurture_scheduler.get_stats(self.device_id)
|
||
|
||
except Exception as e:
|
||
info["error"] = str(e)
|
||
else:
|
||
info = {
|
||
"device_id": self.device_id,
|
||
"model": "Unknown (u2未连接)",
|
||
"capabilities": [],
|
||
}
|
||
|
||
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
|
||
|
||
# ====================================================================
|
||
# 六、启动/停止
|
||
# ====================================================================
|
||
|
||
async def start(self):
|
||
"""启动Agent"""
|
||
self._loop = asyncio.get_running_loop()
|
||
self.running = True
|
||
self.start_time = time.time()
|
||
|
||
logger.info("=" * 60)
|
||
logger.info(f"🚀 AI数字员工 v{self.VERSION} 启动(Frida + AI Brain + 连接守护 增强版)")
|
||
logger.info(f" 设备ID: {self.device_id}")
|
||
logger.info(f" 服务器: {self.server_url}")
|
||
logger.info(f" 项目ID: {self.project_id}")
|
||
logger.info(f" 心跳间隔: {self.heartbeat_interval}秒 | 弹窗守护: 5秒")
|
||
logger.info(f" Frida: {'✅' if self.frida_mgr and self.frida_mgr.connected else '❌'}")
|
||
logger.info(f" AI Brain: {'✅' if self.ai_brain else '⬜'}")
|
||
guard = self._get_connection_guard()
|
||
logger.info(f" 连接守护: {'✅' if guard else '⬜ (无设备)'}")
|
||
logger.info("=" * 60)
|
||
|
||
await self.connect()
|
||
|
||
async def stop_async(self):
|
||
"""异步停止Agent(清理资源)"""
|
||
self.running = False
|
||
self.connected = False
|
||
self._stop_autonomous_mode()
|
||
if self.frida_mgr:
|
||
self.frida_mgr.stop()
|
||
if self.ai_brain:
|
||
await self.ai_brain.close()
|
||
logger.info("🛑 AI数字员工已停止")
|
||
|
||
def stop(self):
|
||
"""停止Agent"""
|
||
self.running = False
|
||
self.connected = False
|
||
self._stop_autonomous_mode()
|
||
if self.frida_mgr:
|
||
self.frida_mgr.stop()
|
||
logger.info("🛑 AI数字员工已停止")
|
||
|
||
|
||
def _detect_device_id() -> str:
|
||
"""自动检测设备ID(Termux / ADB / fallback)"""
|
||
import subprocess
|
||
# 1. Termux: getprop
|
||
try:
|
||
r = subprocess.run(['getprop', 'ro.serialno'], capture_output=True, text=True, timeout=5)
|
||
serial = r.stdout.strip()
|
||
if serial:
|
||
return serial
|
||
except Exception:
|
||
pass
|
||
# 2. ADB serial(从环境变量,模拟器常用)
|
||
serial = os.environ.get("ANDROID_SERIAL", "")
|
||
if serial:
|
||
return serial
|
||
# 3. 通过 uiautomator2 获取
|
||
if u2:
|
||
try:
|
||
d = u2.connect()
|
||
serial = d.serial
|
||
if serial:
|
||
return serial
|
||
except Exception:
|
||
pass
|
||
# 4. fallback: 基于时间戳
|
||
return f"agent-{int(time.time())}"
|
||
|
||
|
||
def _resolve_config(args) -> dict:
|
||
"""
|
||
配置级联解析(优先级: 环境变量 > 命令行 > config.json > 默认值)
|
||
返回 {device_id, server_url, heartbeat_interval, project_id, ai_config}
|
||
"""
|
||
config = {}
|
||
config_path = args.config or os.path.join(_AGENT_DIR, 'config.json')
|
||
if os.path.exists(config_path):
|
||
try:
|
||
with open(config_path) as f:
|
||
config = json.load(f)
|
||
logger.info(f"📄 已加载配置: {config_path}")
|
||
except Exception as e:
|
||
logger.warning(f"读取配置文件失败: {e}")
|
||
|
||
device_id = (
|
||
os.environ.get("WP_DEVICE_ID")
|
||
or args.device_id
|
||
or config.get("device_id")
|
||
or _detect_device_id()
|
||
)
|
||
server_base = (
|
||
os.environ.get("WP_SERVER_URL")
|
||
or args.server
|
||
or config.get("server_url")
|
||
)
|
||
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)
|
||
or config.get("heartbeat_interval")
|
||
or 10
|
||
)
|
||
project_id = (
|
||
os.environ.get("WP_PROJECT_ID")
|
||
or args.project
|
||
or config.get("project_id")
|
||
or "cunkebao"
|
||
)
|
||
|
||
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": _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"),
|
||
"brain_interval": int(os.environ.get("WP_AI_INTERVAL", "0")
|
||
or ai_cfg.get("brain_interval", 60)),
|
||
"standing_orders": ai_cfg.get("standing_orders", []),
|
||
}
|
||
|
||
return {
|
||
"device_id": device_id,
|
||
"server_url": server_url,
|
||
"heartbeat_interval": heartbeat,
|
||
"project_id": project_id,
|
||
"ai_config": ai_config,
|
||
"public_servers": public_servers,
|
||
}
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(
|
||
description='AI数字员工 - 工作手机Agent v3.1(Frida + AI Brain 增强版)',
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""
|
||
配置优先级: 环境变量 > 命令行参数 > config.json > 默认值
|
||
|
||
环境变量:
|
||
WP_DEVICE_ID 设备ID(默认自动检测)
|
||
WP_SERVER_URL WebSocket基础地址
|
||
WP_PROJECT_ID 项目ID
|
||
WP_HEARTBEAT 心跳间隔(秒)
|
||
WP_AI_ENABLED 启用AI Brain(1/true)
|
||
WP_AI_API_URL 卡若AI API地址
|
||
WP_AI_API_KEY 卡若AI API密钥
|
||
WP_AI_INTERVAL AI思考间隔(秒)
|
||
|
||
示例:
|
||
# 基本启动(读 config.json)
|
||
python agent.py
|
||
|
||
# 启用 AI Brain
|
||
WP_AI_ENABLED=1 WP_AI_API_KEY=xxx python agent.py
|
||
|
||
# 指定服务器 + AI
|
||
python agent.py -s ws://192.168.1.100:8899/ws/device
|
||
"""
|
||
)
|
||
parser.add_argument('--device-id', '-d', default=None, help='设备ID(默认自动检测)')
|
||
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)
|
||
|
||
agent = WorkPhoneAgent(
|
||
device_id=cfg["device_id"],
|
||
server_url=cfg["server_url"],
|
||
heartbeat_interval=cfg["heartbeat_interval"],
|
||
project_id=cfg["project_id"],
|
||
ai_config=cfg.get("ai_config"),
|
||
public_servers=cfg.get("public_servers"),
|
||
)
|
||
|
||
# 信号处理:优雅关闭
|
||
def _signal_handler(sig, frame):
|
||
logger.info(f"收到信号 {sig},正在停止...")
|
||
agent.stop()
|
||
|
||
signal.signal(signal.SIGINT, _signal_handler)
|
||
signal.signal(signal.SIGTERM, _signal_handler)
|
||
|
||
try:
|
||
asyncio.run(agent.start())
|
||
except KeyboardInterrupt:
|
||
pass
|
||
finally:
|
||
agent.stop()
|
||
logger.info("👋 Agent已退出")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|