diff --git a/sdk/Dockerfile b/sdk/Dockerfile index e164405b86..afa8bb8348 100644 --- a/sdk/Dockerfile +++ b/sdk/Dockerfile @@ -1,17 +1,23 @@ -# 工作手机SDK v3.0 - Dockerfile +# 工作手机SDK v3.1 - Dockerfile(含 AI Brain + Hook 模块管理) FROM python:3.11-slim WORKDIR /app -# 安装Python依赖 +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple -# 复制应用代码 COPY app/ /app/ -# 暴露端口 +ENV ENV=production \ + AI_BRAIN_ENABLED=true \ + AI_BRAIN_API_URL=http://host.docker.internal:3102 \ + AI_BRAIN_DEFAULT_MODEL=auto + EXPOSE 8899 -# 启动命令 +HEALTHCHECK --interval=30s --timeout=10s --retries=3 \ + CMD curl -f http://localhost:8899/health || exit 1 + CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8899"] diff --git a/sdk/agent/agent.py b/sdk/agent/agent.py index 0a40663a37..e656a3a93c 100644 --- a/sdk/agent/agent.py +++ b/sdk/agent/agent.py @@ -1,17 +1,19 @@ #!/usr/bin/env python3 """ -工作手机Agent v3.0 - AI数字员工 +工作手机Agent v3.1 - AI数字员工(Frida + AI Brain 增强版) 运行在Android手机上,主动连接SDK服务器,保持实时通信 +内置 AI 大脑,支持离线自主运行 核心架构: ┌──────────────────────────────────────────────────────┐ -│ 手机端 Agent (Python/Termux) │ +│ 手机端 Agent (Python/Termux) v3.1 │ │ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ │ -│ │ WebSocket │ │ Skill引擎 │ │ 状态监控 │ │ -│ │ 实时连接 │ │ 微信/抖音 │ │ 电量/网络 │ │ +│ │ WebSocket │ │ AI Brain │ │ Frida Hook │ │ +│ │ 服务器连接 │ │ 自主决策 │ │ APP控制 │ │ │ └──────┬──────┘ └──────┬──────┘ └──────┬───────┘ │ -│ └────────────────┴─────────────────┘ │ -│ ↕ uiautomator2 / AccessibilityService │ +│ │ │ │ │ +│ └────────────────┼────────────────┘ │ +│ ↕ Frida RPC (优先) / u2 (兜底) │ │ ┌────────────────────────────────────┐ │ │ │ Android系统 + 微信/抖音等APP │ │ │ └────────────────────────────────────┘ │ @@ -22,21 +24,27 @@ │ 存客宝/触客宝通过API调用 → 服务器下发命令到手机 │ └──────────────────────────────────────────────────────┘ +运行模式: +- 在线模式: 服务器连接 → 接收指令 + AI 辅助 + Frida 执行 +- 离线模式: 服务器断连 → AI Brain 自主决策 → Frida/u2 执行 → 结果缓冲 + 连接机制: - 手机主动发起WebSocket连接到服务器 -- 心跳保活: 每30秒发送心跳包,服务器10秒无响应则重连 +- 心跳保活: 每N秒发送心跳包,服务器响应超时则重连 - 指数退避重连: 2s → 4s → 8s → 16s → 30s(最大) -- 断线自动重连,永不断开 -- 操作时界面无感(通过u2后台操作,用户无感知) +- 断线后 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.0.0 +@version 3.1.0 """ import asyncio @@ -79,33 +87,35 @@ logger = logging.getLogger(__name__) class WorkPhoneAgent: """ - AI数字员工 - 手机端Agent - + AI数字员工 - 手机端Agent(Frida + AI Brain 增强版) + 核心职责: 1. 主动连接SDK服务器并保持实时通信 - 2. 接收服务器命令并在手机上执行 + 2. 接收服务器命令并在手机上执行(Frida优先 → u2兜底) 3. 实时上报设备状态(电量/网络/APP状态) 4. 支持微信/抖音/小红书等多APP控制 + 5. 内置AI大脑,心跳驱动自主决策 + 6. 离线模式:服务器断连时AI独立运行 """ - - VERSION = "3.0.0" - - # 重连策略: 指数退避 - MIN_RECONNECT_DELAY = 2 # 最小重连间隔(秒) - MAX_RECONNECT_DELAY = 30 # 最大重连间隔(秒) - + + 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, ): self.device_id = device_id self.server_url = server_url self.heartbeat_interval = heartbeat_interval self.project_id = project_id - + self.ws: Optional[websockets.WebSocketClientProtocol] = None self.running = False self.connected = False @@ -113,35 +123,182 @@ class WorkPhoneAgent: self.last_heartbeat_ack = time.time() self.commands_executed = 0 self.start_time = None - - # 初始化uiautomator2 + + # 设备控制通道(u2 优先,降级 LocalDevice) self.d = None - if u2: - try: - self.d = u2.connect() - self.d.implicitly_wait(10.0) - # 设置u2操作不显示弹窗 - self.d.settings['operation_delay'] = (0, 0) - self.d.settings['operation_delay_methods'] = [] - logger.info(f"uiautomator2连接成功: {self.d.info.get('productName', 'Unknown')}") - except Exception as e: - logger.warning(f"uiautomator2连接失败: {e}") - - logger.info(f"🤖 AI数字员工初始化完成") + 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 _init_u2(self): + """ + 初始化设备控制(优先 u2,降级到 LocalDevice) + + 连接策略: + 1. 有 ADB 环境 → u2.connect()(电脑端/有 adb 的 Termux) + 2. ADB 不可用 → LocalDevice(ATX HTTP 直连,适合 Termux) + 3. ATX 也不可用 → self.d 保持 None,运行时重试 + """ + import shutil + has_adb = shutil.which("adb") is not None + + if has_adb and u2: + try: + self.d = u2.connect() + 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')}") + return + except Exception as e: + logger.info(f"u2 ADB 连接失败: {e},尝试 LocalDevice") + + 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 + self.frida_mgr = FridaManager( + on_event=self._on_frida_event, + auto_reconnect=True, + ) + started = self.frida_mgr.start() + 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]}") + if self.connected and self.ws: + asyncio.ensure_future(self._send_event(f"hook_{event_type}", payload)) + + 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( + device=self.d, + on_alert=self._on_risk_alert, + ) + self.risk_sentinel.start() + except Exception as e: + logger.error(f"RiskSentinel 初始化失败: {e}") + + try: + from anti_ban.sensor_simulator import SensorSimulator + self.sensor_sim = SensorSimulator(self.d, frida_mgr=self.frida_mgr) + self.sensor_sim.start(interval=3.0) + 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(self.d, phase="new") + 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 # ==================================================================== # 一、连接管理(核心:主动连接 + 心跳保活 + 指数退避重连) # ==================================================================== async def connect(self): - """主连接循环 - 永不停止""" + """主连接循环 - 永不停止,断连时切换离线AI自主模式""" while self.running: try: logger.info(f"📡 正在连接服务器: {self.server_url}") - + + # 重连成功 → 停止离线自主模式 + self._stop_autonomous_mode() + async with websockets.connect( self.server_url, ping_interval=20, @@ -151,38 +308,57 @@ class WorkPhoneAgent: ) as ws: self.ws = ws self.connected = True - self.reconnect_attempts = 0 # 重置重连计数 + self.reconnect_attempts = 0 self.last_heartbeat_ack = time.time() - + logger.info("✅ 服务器连接成功!") - - # 1. 发送注册消息 + + if self.ai_brain: + self.ai_brain.online = True + + # 1. 注册 + 上报 + 拉配置 await self._register() - # 注册后上报 agent_started,便于服务端记录设备上线 - await self._send_event("agent_started", {"device_id": self.device_id, "project_id": self.project_id}) - # 向服务端拉取配置(如心跳间隔),ack 里会更新 self.heartbeat_interval + 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. 启动并发任务 + + # 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: @@ -194,10 +370,14 @@ class WorkPhoneAgent: finally: self.connected = False self.ws = None - + if self.running: self.reconnect_attempts += 1 - # 断网时先尝试恢复网络(通过 Hawk 模块,仅合法操作:开 WiFi、打开设置、连接已保存网络) + + # 断连后启动离线自主模式(AI Brain 接管) + self._start_autonomous_mode() + + # 尝试恢复网络 if self.d: try: from hawk import try_reconnect_network @@ -210,10 +390,47 @@ class WorkPhoneAgent: 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: """计算重连延迟(指数退避 + 随机抖动)""" @@ -241,30 +458,156 @@ class WorkPhoneAgent: 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 and not self.risk_sentinel.can_operate(): + continue + if self.nurture_scheduler and self.nurture_scheduler.should_nurture(): + skill = None + try: + from skills.base import BaseSkill + if self.d: + skill = BaseSkill(self.d) + except Exception: + pass + result = self.nurture_scheduler.execute_nurture(skill=skill) + if result.get("executed"): + logger.info(f"🌱 养号完成: {result.get('action')} " + f"({result.get('duration', 0):.0f}s)") + await self._send_event("nurture_done", result) + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"养号循环异常: {e}") + await asyncio.sleep(60) + + async def _execute_with_frida_priority(self, script: str, action: str, params: dict) -> dict: + """ + Frida 优先执行通道: + 1. 优先走 Frida RPC(Hook 级别,直接操作 APP 内部) + 2. Frida 不可用时降级到 u2(UI自动化) + 3. 都不可用时返回错误 + """ + # 通道1: Frida Hook(优先) + if self.frida_mgr and self.frida_mgr.connected: + try: + from hook.hook_executor import HookExecutor + executor = HookExecutor(self.frida_mgr) + frida_result = executor.execute(script, 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") + except ImportError: + pass + except Exception as e: + logger.warning(f"[Frida] {script}.{action} 异常: {e},降级 u2") + + # 通道2: u2 Skill(兜底) + return await self._execute_skill(script, action, params) + # ==================================================================== # 二、心跳机制(参考抖音心跳: 应用层心跳 + 状态上报) # ==================================================================== async def _heartbeat_loop(self): """ - 心跳循环 - 保持连接活性 - - 机制说明(参考抖音等APP的心跳设计): - 1. 每30秒发送应用层心跳(不同于WebSocket层ping) - 2. 心跳包含设备状态摘要(电量、内存、网络) - 3. 服务器需要在10秒内响应pong - 4. 连续3次无响应则主动断开重连 + 增强心跳循环 — 多维度检测 + 自愈 + + 机制(六层守护): + 1. 应用层心跳(WebSocket ping 之上再加业务层 heartbeat) + 2. 心跳包含:设备状态 + 连接守护报告(弹窗/u2/网络) + 3. 服务器需在 10s 内 ACK;连续 3 次无响应 → 主动断开重连 + 4. 每次心跳自动执行「弹窗扫描」(USB授权 / 权限请求) + 5. 每 3 次心跳(约 90s)做一轮完整守护(网络 + u2 + 屏幕) + 6. 发现 u2 异常 / 网络断开 → 自动尝试恢复 """ missed_count = 0 - + heartbeat_count = 0 + guard = self._get_connection_guard() + while self.connected: try: await asyncio.sleep(self.heartbeat_interval) - + if not self.connected or not self.ws: break - - # 发送心跳(含状态摘要) + + heartbeat_count += 1 + guard_report = {} + + # ── 每 5 次心跳:如果 u2 断开则尝试重连 ── + if not self.d and heartbeat_count % 5 == 0: + self._try_reconnect_u2() + if self.d: + guard = self._get_connection_guard() + + # ── 每次心跳:扫描弹窗(低开销,高频保护 USB 授权等) ── + if guard: + try: + popup_result = await asyncio.get_event_loop().run_in_executor( + None, guard.dismiss_popups + ) + if popup_result.get("count", 0) > 0: + guard_report["popups"] = popup_result + except Exception as e: + logger.debug(f"心跳弹窗扫描异常: {e}") + + # ── 每 3 次心跳:完整守护(网络 + u2 + 屏幕 + ADB) ── + if guard and heartbeat_count % 3 == 0: + try: + full_report = await asyncio.get_event_loop().run_in_executor( + None, guard.full_guard_cycle + ) + guard_report["full_cycle"] = full_report + + if not full_report.get("all_ok"): + await self._send_event("guard_alert", { + "issues": full_report, + "heartbeat_count": heartbeat_count, + }) + except Exception as e: + logger.debug(f"完整守护异常: {e}") + + # ── 发送心跳数据 ── heartbeat_data = { "type": "heartbeat", "timestamp": int(time.time()), @@ -273,28 +616,121 @@ class WorkPhoneAgent: "commands_executed": self.commands_executed, "status": self._get_quick_status(), } - + if guard_report: + heartbeat_data["guard"] = guard_report + await self.ws.send(json.dumps(heartbeat_data)) - - # 检查上次心跳是否有ACK + + # ── 检查 ACK ── time_since_ack = time.time() - self.last_heartbeat_ack if time_since_ack > self.heartbeat_interval * 3: 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 = u2.connect() + d.implicitly_wait(10.0) + d.settings['operation_delay'] = (0, 0) + d.settings['operation_delay_methods'] = [] + self.d = d + self._connection_guard = None + logger.info(f"🔄 u2 重连成功: {d.info.get('productName', 'Unknown')}") + return + except Exception: + pass + + 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 + 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, + }) + + 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: @@ -349,9 +785,19 @@ class WorkPhoneAgent: except Exception: status["network_type"] = "unknown" + # 电量(轻量级,心跳附带) + 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()) + 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: @@ -490,8 +936,23 @@ class WorkPhoneAgent: 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 == "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 == "device_request_ack": @@ -576,15 +1037,27 @@ class WorkPhoneAgent: # ==================================================================== 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 + + # 风控哨兵前置检查 + if self.risk_sentinel and not self.risk_sentinel.can_operate(): + status = self.risk_sentinel.get_status() + return { + "code": 429, + "message": f"风控暂停中 (Lv{status['level']}: {status['level_name']})", + "data": {"risk_status": status}, + } + try: if script: - return await self._execute_skill(script, action, params) - + if channel == "u2": + return await self._execute_skill(script, action, params) + return await self._execute_with_frida_priority(script, action, params) + if not self.d: return {"code": 503, "message": "uiautomator2未连接"} @@ -635,6 +1108,8 @@ class WorkPhoneAgent: except Exception as e: logger.error(f"执行命令错误: {e}") + if self.risk_sentinel: + self.risk_sentinel.report_failure(str(e)) return {"code": 500, "message": str(e)} async def _execute_skill(self, script: str, action: str, params: dict) -> dict: @@ -651,7 +1126,13 @@ class WorkPhoneAgent: except ImportError as ie: return {"code": 404, "message": str(ie)} - skill = skill_class(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, + } + skill = skill_class(self.d, anti_ban_ctx=anti_ban_ctx) # 调用方法 method = getattr(skill, action, None) @@ -682,7 +1163,12 @@ class WorkPhoneAgent: return {"code": 503, "message": "uiautomator2未连接", "data": {"success": False, "error": "设备未连接"}} try: from skill_executor import SkillExecutor - executor = SkillExecutor(self.d) + 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: @@ -770,13 +1256,32 @@ class WorkPhoneAgent: pass info["installed_apps"] = apps - info["capabilities"] = [ + capabilities = [ "u2", "screenshot", "click", "input", "swipe", "ui_tree", "app_control", "skill_execute", "skill_wechat", "skill_douyin", "skill_xhs", "skill_xianyu", "skill_network_reconnect", "hawk", - "event", "device_request" + "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_status() + if self.nurture_scheduler: + info["anti_ban"]["nurture"] = self.nurture_scheduler.get_status() except Exception as e: info["error"] = str(e) @@ -800,20 +1305,47 @@ class WorkPhoneAgent: """启动Agent""" self.running = True self.start_time = time.time() - - logger.info("=" * 50) - logger.info("🚀 AI数字员工 v3.0 启动") + + 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("=" * 50) - + 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.risk_sentinel: + self.risk_sentinel.stop() + if self.sensor_sim: + self.sensor_sim.stop() + 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.risk_sentinel: + self.risk_sentinel.stop() + if self.sensor_sim: + self.sensor_sim.stop() + if self.frida_mgr: + self.frida_mgr.stop() logger.info("🛑 AI数字员工已停止") @@ -848,9 +1380,8 @@ def _detect_device_id() -> str: def _resolve_config(args) -> dict: """ 配置级联解析(优先级: 环境变量 > 命令行 > config.json > 默认值) - 返回 {device_id, server_url, heartbeat_interval, project_id} + 返回 {device_id, server_url, heartbeat_interval, project_id, ai_config} """ - # 1. 读取 config.json config = {} config_path = args.config or os.path.join(_AGENT_DIR, 'config.json') if os.path.exists(config_path): @@ -861,7 +1392,6 @@ def _resolve_config(args) -> dict: except Exception as e: logger.warning(f"读取配置文件失败: {e}") - # 2. 级联合并 device_id = ( os.environ.get("WP_DEVICE_ID") or args.device_id @@ -887,20 +1417,33 @@ def _resolve_config(args) -> dict: or "cunkebao" ) - # 3. 拼接完整 WebSocket URL(基础地址 + /设备ID) server_url = f"{server_base}/{device_id}" + # AI Brain 配置(环境变量 > config.json.ai_brain) + ai_cfg = config.get("ai_brain", {}) + ai_config = { + "enabled": (os.environ.get("WP_AI_ENABLED", "").lower() in ("1", "true") + or ai_cfg.get("enabled", False)), + "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, } def main(): parser = argparse.ArgumentParser( - description='AI数字员工 - 工作手机Agent v3.0', + description='AI数字员工 - 工作手机Agent v3.1(Frida + AI Brain 增强版)', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" 配置优先级: 环境变量 > 命令行参数 > config.json > 默认值 @@ -910,16 +1453,20 @@ def main(): 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 - # 指定服务器 - python agent.py -s ws://192.168.1.100:8899/ws/device + # 启用 AI Brain + WP_AI_ENABLED=1 WP_AI_API_KEY=xxx python agent.py - # 环境变量 - WP_SERVER_URL=ws://10.0.0.1:8899/ws/device 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(默认自动检测)') @@ -936,6 +1483,7 @@ def main(): server_url=cfg["server_url"], heartbeat_interval=cfg["heartbeat_interval"], project_id=cfg["project_id"], + ai_config=cfg.get("ai_config"), ) # 信号处理:优雅关闭 diff --git a/sdk/agent/ai_brain.py b/sdk/agent/ai_brain.py new file mode 100644 index 0000000000..6d1e33ddcd --- /dev/null +++ b/sdk/agent/ai_brain.py @@ -0,0 +1,399 @@ +""" +AI Brain — 设备端 AI 大脑(自主决策引擎) + +架构定位: +┌──────────────────────────────────────────────────────┐ +│ AI Brain (本模块) │ +│ ┌────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ 卡若AI API │ │ 任务队列 │ │ 离线缓冲 │ │ +│ │ LLM 决策 │ │ 心跳驱动 │ │ 断连续航 │ │ +│ └─────┬──────┘ └──────┬───────┘ └──────┬───────┘ │ +│ └────────────────┼──────────────────┘ │ +│ ↓ 决策结果: skill + action + params │ +│ ┌────────────────┴──────────────────┐ │ +│ │ Skill执行器(Frida优先/u2兜底) │ │ +│ └───────────────────────────────────┘ │ +└──────────────────────────────────────────────────────┘ + +核心能力: +1. 通过卡若AI API 获取 LLM 智能决策 +2. 心跳驱动的自主运行循环(每 N 秒检查一次) +3. 离线模式:服务器断连时 AI 独立运行,结果缓冲待上传 +4. Frida 优先通道:有 Frida 时优先用 Hook 操作,无则降级 u2 +5. 任务队列:服务器推送 + AI 自主生成 + +卡若AI API: + POST {ai_api_url}/api/gateway/chat + Authorization: Bearer {ai_api_key} + Body: {"messages": [...], "max_tokens": 1024} + +@author 卡若 +@version 1.0.0 +""" + +import asyncio +import json +import logging +import time +import os +from typing import Optional, Dict, Any, List +from datetime import datetime +from collections import deque + +logger = logging.getLogger(__name__) + +try: + import aiohttp +except ImportError: + aiohttp = None + logger.warning("aiohttp 未安装,AI Brain 网络请求将使用 urllib (pip install aiohttp)") + + +class TaskItem: + """任务项""" + __slots__ = ("id", "source", "instruction", "skill_chain", "priority", "created_at", "status", "result") + + def __init__(self, instruction: str, source: str = "ai", priority: int = 5): + self.id = f"task_{int(time.time() * 1000)}" + self.source = source # "server" | "ai" | "standing_order" + self.instruction = instruction + self.skill_chain: List[Dict[str, Any]] = [] # AI 解析后的执行链 + self.priority = priority # 1-10, 10=最高 + self.created_at = time.time() + self.status = "pending" # pending → running → done / failed + self.result: Optional[Dict] = None + + +class AIBrain: + """ + 设备端 AI 大脑 — 心跳驱动的自主决策引擎 + + 工作模式: + - online: 服务器在线,接收推送任务 + AI 辅助决策 + - offline: 服务器断连,AI 独立运行,执行 standing_orders + 自主决策 + """ + + VERSION = "1.0.0" + + SYSTEM_PROMPT = """你是一个运行在 Android 手机上的 AI 助手。你的任务是根据当前手机状态和待办事项,决定下一步要执行的操作。 + +你可以控制以下 APP:微信(wechat)、抖音(douyin)、小红书(xhs)、闲鱼(xianyu)、Soul(soul)。 + +每个 APP 支持的操作(action): +- wechat: send_message, get_messages, get_contacts, add_friend, accept_friend, post_moment, like_moment, get_groups +- douyin: send_message, get_messages, get_fans, reply_comment +- xhs: send_message, get_messages, like_note +- xianyu: send_message, get_messages +- soul: send_message, get_messages + +通用操作:screenshot, click, input, swipe, app_start, app_stop, device_info + +你必须以 JSON 格式回复,结构如下: +{ + "should_act": true/false, + "reason": "决策原因", + "actions": [ + {"script": "wechat", "action": "send_message", "params": {"to": "xxx", "content": "xxx"}}, + ... + ] +} + +如果当前没有需要执行的任务,返回 {"should_act": false, "reason": "无待处理任务"}。 +如果设备状态异常(低电量、无网络),优先处理设备问题。""" + + def __init__( + self, + ai_api_url: str = "http://localhost:3102", + ai_api_key: str = "", + ai_model: str = "auto", + brain_interval: int = 60, + max_offline_buffer: int = 500, + standing_orders: Optional[List[str]] = None, + enabled: bool = True, + ): + self.ai_api_url = ai_api_url.rstrip("/") + self.ai_api_key = ai_api_key + self.ai_model = ai_model + self.brain_interval = brain_interval # AI 思考间隔(秒) + self.enabled = enabled + + self.task_queue: deque = deque(maxlen=200) + self.offline_buffer: deque = deque(maxlen=max_offline_buffer) + self.standing_orders = standing_orders or [] + + self._online = True + self._running = False + self._last_think_time = 0 + self._think_count = 0 + self._execute_count = 0 + self._session: Optional[Any] = None # aiohttp.ClientSession + + logger.info(f"🧠 AI Brain v{self.VERSION} 初始化") + logger.info(f" API: {self.ai_api_url}") + logger.info(f" 间隔: {self.brain_interval}s") + logger.info(f" 常驻指令: {len(self.standing_orders)} 条") + logger.info(f" 启用: {self.enabled}") + + @property + def online(self) -> bool: + return self._online + + @online.setter + def online(self, value: bool): + if self._online != value: + self._online = value + mode = "在线" if value else "离线(自主运行)" + logger.info(f"🧠 AI Brain 模式切换: {mode}") + + def add_task(self, instruction: str, source: str = "server", priority: int = 5): + """添加任务到队列""" + task = TaskItem(instruction=instruction, source=source, priority=priority) + self.task_queue.append(task) + logger.info(f"📋 新任务入队: [{source}] {instruction[:50]}...") + return task.id + + def add_standing_order(self, order: str): + """添加常驻指令(离线时自动执行)""" + if order not in self.standing_orders: + self.standing_orders.append(order) + logger.info(f"📌 新增常驻指令: {order[:50]}...") + + def buffer_offline_result(self, result: Dict): + """缓冲离线执行结果(待服务器重连后上传)""" + result["buffered_at"] = time.time() + self.offline_buffer.append(result) + + def flush_offline_buffer(self) -> List[Dict]: + """刷出离线缓冲(重连后调用)""" + results = list(self.offline_buffer) + self.offline_buffer.clear() + return results + + async def _get_session(self): + """获取/创建 HTTP session""" + if aiohttp and (self._session is None or self._session.closed): + timeout = aiohttp.ClientTimeout(total=30) + self._session = aiohttp.ClientSession(timeout=timeout) + return self._session + + async def call_ai(self, messages: List[Dict[str, str]], max_tokens: int = 1024) -> Optional[str]: + """调用卡若AI API""" + url = f"{self.ai_api_url}/api/gateway/chat" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.ai_api_key}", + } + payload = { + "messages": messages, + "max_tokens": max_tokens, + } + if self.ai_model and self.ai_model != "auto": + payload["model"] = self.ai_model + + try: + if aiohttp: + session = await self._get_session() + async with session.post(url, json=payload, headers=headers) as resp: + if resp.status != 200: + body = await resp.text() + logger.error(f"AI API 返回 {resp.status}: {body[:200]}") + return None + data = await resp.json() + else: + import urllib.request + req = urllib.request.Request( + url, + data=json.dumps(payload).encode(), + headers=headers, + method="POST", + ) + loop = asyncio.get_event_loop() + with await loop.run_in_executor(None, urllib.request.urlopen, req) as resp: + data = json.loads(resp.read().decode()) + + content = ( + data.get("choices", [{}])[0] + .get("message", {}) + .get("content", "") + ) + return content + + except Exception as e: + logger.error(f"AI API 调用失败: {e}") + return None + + async def think(self, device_status: Dict, pending_instructions: Optional[List[str]] = None) -> Optional[Dict]: + """ + AI 思考:基于当前设备状态 + 待办任务,决定下一步操作 + + 返回: {"should_act": bool, "reason": str, "actions": [...]} 或 None + """ + if not self.enabled: + return None + + self._last_think_time = time.time() + self._think_count += 1 + + context_parts = [f"当前时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"] + context_parts.append(f"设备状态: {json.dumps(device_status, ensure_ascii=False, default=str)}") + + if not self._online: + context_parts.append("⚠️ 当前处于离线模式(服务器未连接),需要自主决策") + + if pending_instructions: + context_parts.append(f"待处理任务: {json.dumps(pending_instructions, ensure_ascii=False)}") + + if self.standing_orders and not self._online: + context_parts.append(f"常驻指令(离线时执行): {json.dumps(self.standing_orders, ensure_ascii=False)}") + + user_message = "\n".join(context_parts) + + messages = [ + {"role": "system", "content": self.SYSTEM_PROMPT}, + {"role": "user", "content": user_message}, + ] + + response = await self.call_ai(messages, max_tokens=1024) + if not response: + return None + + try: + start = response.find("{") + end = response.rfind("}") + 1 + if start >= 0 and end > start: + decision = json.loads(response[start:end]) + logger.info(f"🧠 AI 决策: should_act={decision.get('should_act')}, reason={decision.get('reason', '')[:60]}") + return decision + except json.JSONDecodeError as e: + logger.warning(f"AI 响应解析失败: {e}, raw={response[:200]}") + + return None + + async def heartbeat_cycle(self, device_status: Dict, execute_fn) -> Dict: + """ + 心跳驱动的 AI 循环(每次心跳调用一次) + + Args: + device_status: 当前设备状态 + execute_fn: 执行函数 async (script, action, params) -> result + + Returns: + {"thought": bool, "acted": bool, "results": [...]} + """ + if not self.enabled: + return {"thought": False, "acted": False, "results": []} + + now = time.time() + if now - self._last_think_time < self.brain_interval: + return {"thought": False, "acted": False, "results": [], "skip": "间隔未到"} + + pending = [t.instruction for t in self.task_queue if t.status == "pending"] + + decision = await self.think(device_status, pending if pending else None) + if not decision or not decision.get("should_act"): + return {"thought": True, "acted": False, "results": [], "reason": decision.get("reason") if decision else "AI无响应"} + + results = [] + actions = decision.get("actions", []) + for action_spec in actions: + script = action_spec.get("script", "") + action = action_spec.get("action", "") + params = action_spec.get("params", {}) + + if not script or not action: + continue + + try: + result = await execute_fn(script, action, params) + self._execute_count += 1 + entry = { + "script": script, + "action": action, + "params": params, + "result": result, + "timestamp": time.time(), + } + results.append(entry) + + if not self._online: + self.buffer_offline_result(entry) + + except Exception as e: + logger.error(f"AI Brain 执行失败 [{script}.{action}]: {e}") + results.append({ + "script": script, "action": action, + "error": str(e), "timestamp": time.time(), + }) + + for task in list(self.task_queue): + if task.status == "pending": + task.status = "done" + task.result = {"actions": len(actions), "results_count": len(results)} + + return {"thought": True, "acted": True, "results": results, "reason": decision.get("reason", "")} + + async def autonomous_loop(self, device_status_fn, execute_fn, stop_event: asyncio.Event): + """ + 离线自主运行循环 — 服务器断连时启动 + + 持续运行直到 stop_event 被设置(通常是服务器重连时) + + Args: + device_status_fn: 获取设备状态的函数 () -> dict + execute_fn: 执行函数 async (script, action, params) -> result + stop_event: 停止信号 + """ + logger.info("🧠 AI Brain 进入自主运行模式") + self.online = False + + cycle = 0 + while not stop_event.is_set(): + cycle += 1 + try: + status = device_status_fn() + battery = status.get("battery_level", 100) + if battery < 10: + logger.warning(f"⚡ 电量过低 ({battery}%),暂停自主操作") + await asyncio.sleep(self.brain_interval * 2) + continue + + result = await self.heartbeat_cycle(status, execute_fn) + if result.get("acted"): + logger.info(f"🧠 自主执行第{cycle}轮: {len(result.get('results', []))}个操作") + + except Exception as e: + logger.error(f"自主运行循环异常: {e}") + + try: + await asyncio.wait_for(stop_event.wait(), timeout=self.brain_interval) + break + except asyncio.TimeoutError: + pass + + logger.info("🧠 AI Brain 退出自主运行模式") + self.online = True + + def get_status(self) -> Dict: + """获取 AI Brain 状态""" + return { + "enabled": self.enabled, + "online": self._online, + "running": self._running, + "brain_interval": self.brain_interval, + "think_count": self._think_count, + "execute_count": self._execute_count, + "task_queue_size": len(self.task_queue), + "offline_buffer_size": len(self.offline_buffer), + "standing_orders": len(self.standing_orders), + "last_think_time": self._last_think_time, + "ai_api_url": self.ai_api_url, + "ai_model": self.ai_model, + "version": self.VERSION, + } + + async def close(self): + """关闭资源""" + if self._session and not self._session.closed: + await self._session.close() + self._running = False + logger.info("🧠 AI Brain 已关闭") diff --git a/sdk/agent/anti_ban/__init__.py b/sdk/agent/anti_ban/__init__.py new file mode 100644 index 0000000000..474fe0e39f --- /dev/null +++ b/sdk/agent/anti_ban/__init__.py @@ -0,0 +1,20 @@ +""" +设备端深层防封模块 (anti_ban) + +Agent 启动时自动执行环境自检,运行时持续监控风控信号。 +所有自动化操作经过触摸加固层和传感器模拟层。 +""" + +from .device_guard import DeviceGuard +from .risk_sentinel import RiskSentinel +from .touch_hardener import TouchHardener +from .sensor_simulator import SensorSimulator +from .nurture_scheduler import NurtureScheduler + +__all__ = [ + "DeviceGuard", + "RiskSentinel", + "TouchHardener", + "SensorSimulator", + "NurtureScheduler", +] diff --git a/sdk/agent/anti_ban/device_guard.py b/sdk/agent/anti_ban/device_guard.py new file mode 100644 index 0000000000..73c2334ee6 --- /dev/null +++ b/sdk/agent/anti_ban/device_guard.py @@ -0,0 +1,389 @@ +""" +设备环境守卫 — Agent 启动时执行全面自检 + +检测项: + 1. Root 隐藏状态(Shamiko / MagiskHide) + 2. Frida 反检测状态(进程名/端口/内存特征) + 3. 设备指纹唯一性(上报并校验碰撞) + 4. 模拟器检测(Build属性/传感器/电池/特征文件) + 5. 无障碍服务状态 + 6. SELinux 状态 + 7. 关键 App 安装隐藏(HMA-OSS 检测) +""" + +import hashlib +import logging +import os +import re +import time +from typing import Dict, Any, List, Tuple + +logger = logging.getLogger(__name__) + + +class DeviceGuard: + """设备环境守卫 — 启动自检 + 持续巡检""" + + # 不应被目标 App 看到的包名 + HIDDEN_PACKAGES = [ + "com.topjohnwu.magisk", + "io.github.vvb2060.magisk", + "me.weishu.kernelsu", + "org.lsposed.manager", + "moe.shizuku.privileged.api", + "eu.chainfire.supersu", + "com.noshufou.android.su", + "com.koushikdutta.superuser", + "com.termux", + ] + + # 模拟器 Build 特征 + EMU_FINGERPRINTS = [ + "goldfish", "generic", "vbox", "sdk_gphone", + "Andy", "Droid4X", "nox", "bluestacks", + "genymotion", "ttVM_Hdragon", + ] + + # Frida 默认特征 + FRIDA_INDICATORS = [ + "/data/local/tmp/frida-server", + "/data/local/tmp/re.frida.server", + ] + + def __init__(self, device): + """ + Args: + device: uiautomator2 设备对象 + """ + self.d = device + self.report: Dict[str, Any] = {} + + # ------------------------------------------------------------------ + # 主入口 + # ------------------------------------------------------------------ + + def run_full_check(self) -> Dict[str, Any]: + """执行全面自检,返回结果报告""" + logger.info("🛡️ 设备环境自检开始...") + start = time.time() + + checks: List[Tuple[str, callable]] = [ + ("root_hidden", self._check_root_hidden), + ("frida_stealth", self._check_frida_stealth), + ("fingerprint", self._collect_fingerprint), + ("emulator", self._check_emulator), + ("accessibility", self._check_accessibility), + ("selinux", self._check_selinux), + ("app_hidden", self._check_app_hidden), + ("dangerous_props", self._check_dangerous_props), + ] + + results = {} + warnings = [] + for name, fn in checks: + try: + result = fn() + results[name] = result + if result.get("warning"): + warnings.append(f"{name}: {result['warning']}") + logger.warning(f" ⚠️ {name}: {result['warning']}") + else: + logger.info(f" ✅ {name}: OK") + except Exception as e: + results[name] = {"ok": False, "error": str(e)} + warnings.append(f"{name}: {e}") + logger.error(f" ❌ {name}: {e}") + + elapsed = time.time() - start + self.report = { + "ok": len(warnings) == 0, + "warnings": warnings, + "warning_count": len(warnings), + "checks": results, + "elapsed_ms": int(elapsed * 1000), + "timestamp": int(time.time()), + } + + if warnings: + logger.warning(f"🛡️ 自检完成 — {len(warnings)} 项警告 ({elapsed:.1f}s)") + else: + logger.info(f"🛡️ 自检全部通过 ({elapsed:.1f}s)") + + return self.report + + # ------------------------------------------------------------------ + # 1. Root 隐藏检测 + # ------------------------------------------------------------------ + + def _check_root_hidden(self) -> dict: + result = {"ok": True} + + # su 二进制是否可被常规方式发现 + su_paths = ["/system/bin/su", "/system/xbin/su", "/sbin/su"] + for p in su_paths: + out = self._shell(f"ls {p} 2>/dev/null").strip() + if out and "No such file" not in out: + result["ok"] = False + result["warning"] = f"su 二进制可见: {p}" + return result + + # Magisk 目录 + magisk_dirs = ["/data/adb/magisk", "/data/adb/modules"] + for d in magisk_dirs: + out = self._shell(f"ls {d} 2>/dev/null").strip() + if out and "No such file" not in out: + # 目录存在但应被 Shamiko 隐藏不让目标App看到 + result["magisk_dir_visible"] = True + + # 检查 Shamiko 是否安装 + shamiko = self._shell("ls /data/adb/modules/shamiko 2>/dev/null").strip() + result["shamiko_installed"] = bool(shamiko and "No such file" not in shamiko) + if not result["shamiko_installed"]: + result["warning"] = "Shamiko 未安装,Root 可能暴露" + result["ok"] = False + + return result + + # ------------------------------------------------------------------ + # 2. Frida 反检测 + # ------------------------------------------------------------------ + + def _check_frida_stealth(self) -> dict: + result = {"ok": True} + + # 检查默认 frida-server 进程名 + ps_out = self._shell("ps -A 2>/dev/null || ps").strip() + if "frida-server" in ps_out or "frida-agent" in ps_out: + result["ok"] = False + result["warning"] = "检测到默认 frida-server 进程名,必须使用 Phantom-Frida" + return result + + # 检查默认端口 27042 + netstat = self._shell("netstat -tlnp 2>/dev/null || ss -tlnp 2>/dev/null").strip() + if ":27042" in netstat: + result["ok"] = False + result["warning"] = "检测到 Frida 默认端口 27042" + return result + + # 检查默认路径 + for path in self.FRIDA_INDICATORS: + out = self._shell(f"ls {path} 2>/dev/null").strip() + if out and "No such file" not in out: + result["ok"] = False + result["warning"] = f"Frida 默认路径可见: {path}" + return result + + # 检查 /proc/self/maps 中的 frida 字符串(从 shell 模拟目标App视角) + maps = self._shell("cat /proc/self/maps 2>/dev/null | head -200").strip() + if "frida" in maps.lower(): + result["ok"] = False + result["warning"] = "/proc/self/maps 中发现 frida 特征" + + return result + + # ------------------------------------------------------------------ + # 3. 设备指纹采集 + # ------------------------------------------------------------------ + + def _collect_fingerprint(self) -> dict: + fp_data = {} + + props = { + "ro.serialno": "serial", + "ro.build.fingerprint": "build_fp", + "ro.build.display.id": "display_id", + "ro.product.model": "model", + "ro.product.brand": "brand", + "ro.product.device": "device", + "ro.product.board": "board", + "ro.hardware": "hardware", + "ro.boot.serialno": "boot_serial", + "persist.sys.timezone": "timezone", + "ro.build.version.sdk": "sdk_version", + } + for prop, key in props.items(): + val = self._shell(f"getprop {prop} 2>/dev/null").strip() + if val: + fp_data[key] = val + + # Android ID + android_id = self._shell( + "settings get secure android_id 2>/dev/null" + ).strip() + if android_id: + fp_data["android_id"] = android_id + + # MAC 地址 + mac = self._shell( + "cat /sys/class/net/wlan0/address 2>/dev/null" + ).strip() + if mac and mac != "00:00:00:00:00:00": + fp_data["mac"] = mac + + # 蓝牙地址 + bt = self._shell( + "settings get secure bluetooth_address 2>/dev/null" + ).strip() + if bt: + fp_data["bluetooth"] = bt + + # 屏幕参数 + try: + info = self.d.info + fp_data["screen_w"] = info.get("displayWidth", 0) + fp_data["screen_h"] = info.get("displayHeight", 0) + except Exception: + pass + + # IMEI(需 root 或特殊权限) + imei = self._shell( + "service call iphonesubinfo 1 2>/dev/null | grep -oP \"'[^']+'\"" + ).strip() + if imei: + fp_data["imei_raw"] = imei + + # 计算指纹哈希 + fp_str = "|".join(f"{k}={v}" for k, v in sorted(fp_data.items())) + fp_hash = hashlib.md5(fp_str.encode()).hexdigest() + + return { + "ok": True, + "fingerprint_hash": fp_hash, + "dimensions": len(fp_data), + "data": fp_data, + } + + # ------------------------------------------------------------------ + # 4. 模拟器检测 + # ------------------------------------------------------------------ + + def _check_emulator(self) -> dict: + result = {"ok": True, "is_emulator": False, "signals": []} + + # Build 属性 + build_fp = self._shell("getprop ro.build.fingerprint 2>/dev/null").strip().lower() + hardware = self._shell("getprop ro.hardware 2>/dev/null").strip().lower() + product = self._shell("getprop ro.product.name 2>/dev/null").strip().lower() + + for emu in self.EMU_FINGERPRINTS: + if emu.lower() in build_fp or emu.lower() in hardware or emu.lower() in product: + result["signals"].append(f"Build特征: {emu}") + + # 特征文件 + emu_files = [ + "/dev/qemu_pipe", "/dev/goldfish_pipe", + "/system/lib/libc_malloc_debug_qemu.so", + "/sys/qemu_trace", "/dev/socket/qemud", + ] + for f in emu_files: + out = self._shell(f"ls {f} 2>/dev/null").strip() + if out and "No such file" not in out: + result["signals"].append(f"特征文件: {f}") + + # 传感器数量(真机通常 > 10,模拟器 ≤ 3) + sensors = self._shell( + "dumpsys sensorservice 2>/dev/null | grep -c 'Sensor' || echo 0" + ).strip() + try: + sensor_count = int(sensors) + result["sensor_count"] = sensor_count + if sensor_count < 5: + result["signals"].append(f"传感器过少: {sensor_count}") + except ValueError: + pass + + # 电池状态(模拟器通常电池 API 异常) + battery_status = self._shell( + "dumpsys battery 2>/dev/null | grep 'status' | head -1" + ).strip() + if "status: 1" in battery_status: + result["signals"].append("电池状态异常(status=1=UNKNOWN)") + + if result["signals"]: + result["is_emulator"] = True + result["ok"] = False + result["warning"] = f"疑似模拟器: {', '.join(result['signals'][:3])}" + + return result + + # ------------------------------------------------------------------ + # 5. 无障碍服务检测 + # ------------------------------------------------------------------ + + def _check_accessibility(self) -> dict: + out = self._shell( + "settings get secure enabled_accessibility_services 2>/dev/null" + ).strip() + services = [s for s in out.split(":") if s.strip()] if out and out != "null" else [] + return { + "ok": True, + "active_services": services, + "count": len(services), + } + + # ------------------------------------------------------------------ + # 6. SELinux 状态 + # ------------------------------------------------------------------ + + def _check_selinux(self) -> dict: + out = self._shell("getenforce 2>/dev/null").strip().lower() + enforcing = out == "enforcing" + result = {"ok": True, "mode": out} + if not enforcing: + result["warning"] = f"SELinux 非 Enforcing 模式: {out}" + result["ok"] = False + return result + + # ------------------------------------------------------------------ + # 7. App 隐藏检测(HMA-OSS) + # ------------------------------------------------------------------ + + def _check_app_hidden(self) -> dict: + visible = [] + for pkg in self.HIDDEN_PACKAGES: + out = self._shell(f"pm list packages {pkg} 2>/dev/null").strip() + if f"package:{pkg}" in out: + visible.append(pkg) + + result = {"ok": len(visible) == 0, "visible_sensitive_apps": visible} + if visible: + result["warning"] = f"敏感 App 未隐藏: {', '.join(visible)}" + return result + + # ------------------------------------------------------------------ + # 8. 危险属性检测 + # ------------------------------------------------------------------ + + def _check_dangerous_props(self) -> dict: + """检测会暴露自动化/Root 的系统属性""" + dangerous = {} + + # ro.debuggable + debuggable = self._shell("getprop ro.debuggable 2>/dev/null").strip() + if debuggable == "1": + dangerous["ro.debuggable"] = "1 (应为 0)" + + # ro.secure + secure = self._shell("getprop ro.secure 2>/dev/null").strip() + if secure == "0": + dangerous["ro.secure"] = "0 (应为 1)" + + # init.svc.adbd + adbd = self._shell("getprop init.svc.adbd 2>/dev/null").strip() + if adbd == "running": + dangerous["adb_running"] = True + + result = {"ok": len(dangerous) == 0, "dangerous_props": dangerous} + if dangerous: + result["warning"] = f"危险属性: {list(dangerous.keys())}" + return result + + # ------------------------------------------------------------------ + # 工具 + # ------------------------------------------------------------------ + + def _shell(self, cmd: str) -> str: + try: + return self.d.shell(cmd).output or "" + except Exception: + return "" diff --git a/sdk/agent/anti_ban/nurture_scheduler.py b/sdk/agent/anti_ban/nurture_scheduler.py new file mode 100644 index 0000000000..0ac4820efe --- /dev/null +++ b/sdk/agent/anti_ban/nurture_scheduler.py @@ -0,0 +1,250 @@ +""" +养号调度器 — 新号冷启动期自动限制操作量,渐进提升 + +功能: + 1. 新号冷启动期 (前 7 天) 自动限流 + 2. 每日操作量递增曲线 + 3. 模拟日常行为 (刷朋友圈、看文章、回消息) + 4. 活跃时段分布 (早 8-10, 午 12-14, 晚 19-22) + 5. 与 RiskSentinel 联动调整阈值 +""" + +import json +import logging +import os +import random +import time +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + + +class NurtureScheduler: + """养号调度器 — 安全地养熟一个账号""" + + COLD_START_DAYS = 7 + + DAILY_LIMITS_CURVE = { + 1: {"send_message": 5, "add_friend": 2, "moment_like": 5, "moment_post": 0}, + 2: {"send_message": 10, "add_friend": 3, "moment_like": 8, "moment_post": 1}, + 3: {"send_message": 18, "add_friend": 5, "moment_like": 12, "moment_post": 1}, + 4: {"send_message": 25, "add_friend": 8, "moment_like": 18, "moment_post": 2}, + 5: {"send_message": 35, "add_friend": 12, "moment_like": 22, "moment_post": 2}, + 6: {"send_message": 45, "add_friend": 15, "moment_like": 28, "moment_post": 3}, + 7: {"send_message": 50, "add_friend": 18, "moment_like": 30, "moment_post": 3}, + } + + MATURE_LIMITS = { + "send_message": 60, "add_friend": 20, "moment_like": 30, + "moment_post": 5, "group_send": 10, "profile_view": 40, + } + + ACTIVE_HOURS: List[Tuple[int, int, float]] = [ + (7, 9, 0.6), + (9, 12, 0.8), + (12, 14, 1.0), + (14, 17, 0.7), + (17, 19, 0.5), + (19, 22, 1.0), + (22, 24, 0.4), + ] + + DAILY_BEHAVIORS = [ + {"action": "browse_moments", "weight": 3, "duration_min": (2, 8)}, + {"action": "read_article", "weight": 2, "duration_min": (1, 5)}, + {"action": "check_messages", "weight": 4, "duration_min": (1, 3)}, + {"action": "browse_discover", "weight": 1, "duration_min": (2, 6)}, + ] + + def __init__(self, state_file: str = "nurture_state.json"): + self.state_file = state_file + self._state = self._load_state() + + def register_account(self, account_id: str): + """注册一个新的养号账号""" + if account_id not in self._state: + self._state[account_id] = { + "start_date": datetime.now().isoformat(), + "day_counters": {}, + "total_ops": {}, + } + self._save_state() + logger.info(f"注册养号: {account_id}") + + def get_account_day(self, account_id: str) -> int: + """获取账号处于第几天""" + info = self._state.get(account_id) + if not info: + return 999 # 未注册视为成熟号 + start = datetime.fromisoformat(info["start_date"]) + delta = (datetime.now() - start).days + 1 + return delta + + def is_cold_start(self, account_id: str) -> bool: + """是否在冷启动期""" + return self.get_account_day(account_id) <= self.COLD_START_DAYS + + def get_daily_limit(self, account_id: str, action: str) -> int: + """获取该账号今日某操作的上限""" + day = self.get_account_day(account_id) + if day > self.COLD_START_DAYS: + return self.MATURE_LIMITS.get(action, 100) + day_limits = self.DAILY_LIMITS_CURVE.get(day, self.DAILY_LIMITS_CURVE[7]) + return day_limits.get(action, self.MATURE_LIMITS.get(action, 100)) + + def can_operate(self, account_id: str, action: str) -> dict: + """ + 检查账号当前是否允许执行某操作 + + Returns: + {"allowed": bool, "reason": str, "day": int, "limit": int, "used": int} + """ + day = self.get_account_day(account_id) + limit = self.get_daily_limit(account_id, action) + + today_key = datetime.now().strftime("%Y-%m-%d") + info = self._state.get(account_id, {}) + counters = info.get("day_counters", {}).get(today_key, {}) + used = counters.get(action, 0) + + if not self._is_active_hour(): + return { + "allowed": False, + "reason": "当前不在活跃时段", + "day": day, + "limit": limit, + "used": used, + } + + if used >= limit: + return { + "allowed": False, + "reason": f"今日已达上限 ({used}/{limit})", + "day": day, + "limit": limit, + "used": used, + } + + return { + "allowed": True, + "reason": "", + "day": day, + "limit": limit, + "used": used, + } + + def record_operation(self, account_id: str, action: str): + """记录一次操作""" + if account_id not in self._state: + self.register_account(account_id) + + today_key = datetime.now().strftime("%Y-%m-%d") + info = self._state[account_id] + + if "day_counters" not in info: + info["day_counters"] = {} + if today_key not in info["day_counters"]: + info["day_counters"][today_key] = {} + + counters = info["day_counters"][today_key] + counters[action] = counters.get(action, 0) + 1 + + if "total_ops" not in info: + info["total_ops"] = {} + info["total_ops"][action] = info["total_ops"].get(action, 0) + 1 + + self._save_state() + + def get_nurture_plan(self, account_id: str) -> List[dict]: + """ + 生成今日养号行为计划 — 穿插在正式操作之间执行 + """ + plan = [] + hour = datetime.now().hour + activity_weight = self._get_hour_weight(hour) + + behavior_count = max(1, int(random.uniform(2, 5) * activity_weight)) + selected = random.choices( + self.DAILY_BEHAVIORS, + weights=[b["weight"] for b in self.DAILY_BEHAVIORS], + k=behavior_count, + ) + + for behavior in selected: + dur_min = random.uniform(*behavior["duration_min"]) + delay_min = random.uniform(5, 30) + plan.append({ + "action": behavior["action"], + "duration_sec": int(dur_min * 60), + "delay_before_sec": int(delay_min * 60), + "hour_weight": activity_weight, + }) + + return plan + + def get_risk_sentinel_overrides(self, account_id: str) -> Dict[str, Tuple[int, int]]: + """ + 返回适合该账号当前阶段的 RiskSentinel 阈值覆盖 + + 用法: sentinel = RiskSentinel(custom_limits=scheduler.get_risk_sentinel_overrides(acct)) + """ + overrides = {} + for action in self.MATURE_LIMITS: + limit = self.get_daily_limit(account_id, action) + overrides[action] = (3600, limit) + return overrides + + def get_stats(self, account_id: str) -> dict: + """获取养号统计""" + info = self._state.get(account_id) + if not info: + return {"registered": False} + + day = self.get_account_day(account_id) + today_key = datetime.now().strftime("%Y-%m-%d") + counters = info.get("day_counters", {}).get(today_key, {}) + + return { + "registered": True, + "account_id": account_id, + "day": day, + "cold_start": day <= self.COLD_START_DAYS, + "today_counters": counters, + "total_ops": info.get("total_ops", {}), + "start_date": info["start_date"], + } + + # ---- 内部方法 ---- + + def _is_active_hour(self) -> bool: + """当前是否在活跃时段""" + hour = datetime.now().hour + for start, end, weight in self.ACTIVE_HOURS: + if start <= hour < end and weight >= 0.3: + return True + return False + + @staticmethod + def _get_hour_weight(hour: int) -> float: + """获取当前小时的活跃权重""" + for start, end, weight in NurtureScheduler.ACTIVE_HOURS: + if start <= hour < end: + return weight + return 0.1 + + def _load_state(self) -> dict: + if os.path.exists(self.state_file): + try: + with open(self.state_file, "r") as f: + return json.load(f) + except Exception as e: + logger.warning(f"加载养号状态失败: {e}") + return {} + + def _save_state(self): + try: + with open(self.state_file, "w") as f: + json.dump(self._state, f, ensure_ascii=False, indent=2) + except Exception as e: + logger.warning(f"保存养号状态失败: {e}") diff --git a/sdk/agent/anti_ban/risk_sentinel.py b/sdk/agent/anti_ban/risk_sentinel.py new file mode 100644 index 0000000000..758f34586e --- /dev/null +++ b/sdk/agent/anti_ban/risk_sentinel.py @@ -0,0 +1,135 @@ +""" +风控哨兵 — 实时监控操作频率,行为随机化,触发阈值告警 + +功能: + 1. 操作频率控制 (消息/好友/群发 分开计数) + 2. 行为随机抖动 (间隔 +-15%~30%) + 3. 多级告警 (warn → throttle → pause) + 4. 冷却与恢复 +""" + +import logging +import random +import time +from collections import defaultdict +from typing import Dict, Optional, Tuple + +logger = logging.getLogger(__name__) + + +class RiskSentinel: + """风控哨兵 — 守护操作频率边界""" + + # 默认阈值: (时间窗口秒, 最大次数) + DEFAULT_LIMITS: Dict[str, Tuple[int, int]] = { + "send_message": (3600, 60), + "add_friend": (3600, 20), + "group_send": (3600, 10), + "moment_post": (3600, 5), + "moment_like": (3600, 30), + "profile_view": (3600, 40), + } + + JITTER_RANGE = (0.15, 0.30) + + def __init__(self, custom_limits: Optional[Dict[str, Tuple[int, int]]] = None): + self.limits = {**self.DEFAULT_LIMITS, **(custom_limits or {})} + self._counters: Dict[str, list] = defaultdict(list) + self._paused_until: Dict[str, float] = {} + self._total_ops = 0 + + def check(self, action: str) -> dict: + """ + 检查某操作是否允许执行。 + + Returns: + {"allowed": bool, "wait_sec": float, "level": "ok"|"warn"|"throttle"|"pause"} + """ + now = time.time() + + if action in self._paused_until and now < self._paused_until[action]: + remaining = self._paused_until[action] - now + return {"allowed": False, "wait_sec": remaining, "level": "pause", + "reason": f"{action} 处于冷却期,剩余 {remaining:.0f}s"} + + window, max_count = self.limits.get(action, (3600, 100)) + timestamps = self._counters[action] + cutoff = now - window + timestamps[:] = [t for t in timestamps if t > cutoff] + current = len(timestamps) + + if current >= max_count: + pause_sec = random.uniform(300, 600) + self._paused_until[action] = now + pause_sec + logger.warning(f"🚨 {action} 达到上限 {max_count}/{window}s,暂停 {pause_sec:.0f}s") + return {"allowed": False, "wait_sec": pause_sec, "level": "pause", + "reason": f"{action} 触发上限 ({current}/{max_count})"} + + ratio = current / max_count + if ratio > 0.8: + logger.warning(f"⚠️ {action} 接近上限 ({current}/{max_count})") + return {"allowed": True, "wait_sec": 0, "level": "warn", + "reason": f"接近上限 {current}/{max_count}"} + + if ratio > 0.6: + return {"allowed": True, "wait_sec": 0, "level": "throttle", + "reason": f"频率偏高 {current}/{max_count}"} + + return {"allowed": True, "wait_sec": 0, "level": "ok", "reason": ""} + + def record(self, action: str): + """记录一次操作""" + self._counters[action].append(time.time()) + self._total_ops += 1 + + def add_jitter(self, base_delay: float) -> float: + """给基准延迟加随机抖动""" + jitter_pct = random.uniform(*self.JITTER_RANGE) + direction = random.choice([-1, 1]) + return max(0.5, base_delay * (1 + direction * jitter_pct)) + + def get_recommended_delay(self, action: str) -> float: + """根据当前频率推荐操作间隔 (秒)""" + window, max_count = self.limits.get(action, (3600, 100)) + now = time.time() + cutoff = now - window + recent = [t for t in self._counters[action] if t > cutoff] + current = len(recent) + + ratio = current / max_count if max_count > 0 else 0 + + if ratio > 0.8: + base = random.uniform(30, 60) + elif ratio > 0.5: + base = random.uniform(10, 25) + else: + base = random.uniform(3, 8) + + return self.add_jitter(base) + + def reset(self, action: Optional[str] = None): + """重置计数器""" + if action: + self._counters[action].clear() + self._paused_until.pop(action, None) + else: + self._counters.clear() + self._paused_until.clear() + + def get_stats(self) -> dict: + """获取当前风控统计""" + now = time.time() + stats = {} + for action, (window, max_count) in self.limits.items(): + cutoff = now - window + recent = [t for t in self._counters[action] if t > cutoff] + paused = action in self._paused_until and now < self._paused_until[action] + stats[action] = { + "count": len(recent), + "limit": max_count, + "window_sec": window, + "paused": paused, + "usage_pct": round(len(recent) / max_count * 100, 1) if max_count > 0 else 0, + } + stats["total_ops"] = self._total_ops + return stats diff --git a/sdk/agent/anti_ban/sensor_simulator.py b/sdk/agent/anti_ban/sensor_simulator.py new file mode 100644 index 0000000000..6899fc36f0 --- /dev/null +++ b/sdk/agent/anti_ban/sensor_simulator.py @@ -0,0 +1,207 @@ +""" +传感器模拟 — 伪造加速度计/陀螺仪数据,模拟真人手持特征 + +功能: + 1. 加速度计噪声注入 (模拟手持微抖) + 2. 陀螺仪数据生成 (缓慢旋转漂移) + 3. 传感器事件写入 /dev/input/ (需 root) + 4. 非 root 降级为日志记录 + 5. 与 DeviceGuard 联动: 若传感器数 < 5 则触发补偿 +""" + +import logging +import math +import os +import random +import struct +import time +from typing import Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + + +class SensorSimulator: + """传感器模拟器 — 让设备看起来像被人拿着""" + + GRAVITY = 9.81 + INPUT_EVENT_FORMAT = "llHHi" # struct input_event: sec, usec, type, code, value + + def __init__(self, device=None, has_root: bool = False): + """ + Args: + device: uiautomator2 设备对象 + has_root: 是否有 root 权限 + """ + self.d = device + self.has_root = has_root + self._running = False + self._base_orientation = self._random_orientation() + + def generate_accelerometer_sample(self) -> Dict[str, float]: + """ + 生成一个加速度计采样值 — 模拟静止手持状态 + + 真实手持特征: + - x 轴: 微小随机漂移 (+-0.3 m/s²) + - y 轴: 接近 0 (横持) 或接近 gravity (竖持) + - z 轴: 接近 gravity (平放) 或接近 0 (竖持) + """ + ox, oy, oz = self._base_orientation + + noise_x = random.gauss(0, 0.15) + noise_y = random.gauss(0, 0.15) + noise_z = random.gauss(0, 0.10) + + drift = random.gauss(0, 0.02) + self._base_orientation = ( + ox + drift * random.choice([-1, 1]), + oy + drift * random.choice([-1, 1]), + oz + drift * random.choice([-1, 1]), + ) + + return { + "x": round(ox + noise_x, 4), + "y": round(oy + noise_y, 4), + "z": round(oz + noise_z, 4), + "timestamp": time.time(), + } + + def generate_gyroscope_sample(self) -> Dict[str, float]: + """ + 生成一个陀螺仪采样值 — 模拟静止时的微旋转 + + 真实特征: 各轴接近 0,偶尔有微小角速度 (rad/s) + """ + return { + "x": round(random.gauss(0, 0.005), 6), + "y": round(random.gauss(0, 0.005), 6), + "z": round(random.gauss(0, 0.003), 6), + "timestamp": time.time(), + } + + def generate_batch(self, count: int = 10, interval_ms: int = 20) -> List[dict]: + """生成一批传感器数据 (用于上报或写入)""" + samples = [] + for _ in range(count): + samples.append({ + "accel": self.generate_accelerometer_sample(), + "gyro": self.generate_gyroscope_sample(), + }) + time.sleep(interval_ms / 1000) + return samples + + def inject_to_device(self, duration_sec: float = 5.0, freq_hz: int = 50): + """ + 向 /dev/input/ 写入伪造传感器事件 (需 root)。 + 非 root 环境降级为日志输出。 + """ + if not self.has_root: + logger.info("非 root,传感器模拟降级为日志记录") + self._simulate_log_only(duration_sec, freq_hz) + return + + input_dev = self._find_sensor_input_device() + if not input_dev: + logger.warning("未找到传感器 input 设备,降级为日志") + self._simulate_log_only(duration_sec, freq_hz) + return + + logger.info(f"向 {input_dev} 注入传感器数据 {duration_sec}s @ {freq_hz}Hz") + interval = 1.0 / freq_hz + end_time = time.time() + duration_sec + count = 0 + + try: + while time.time() < end_time: + sample = self.generate_accelerometer_sample() + self._write_input_event(input_dev, sample) + time.sleep(interval) + count += 1 + except Exception as e: + logger.error(f"传感器注入中断: {e}") + + logger.info(f"传感器注入完成,写入 {count} 个事件") + + def get_device_sensor_count(self) -> int: + """获取设备实际传感器数量""" + if not self.d: + return -1 + try: + out = self.d.shell( + "dumpsys sensorservice 2>/dev/null | grep -c 'Sensor'" + ).output.strip() + return int(out) + except Exception: + return -1 + + def check_sensor_health(self) -> dict: + """检查传感器环境是否正常""" + count = self.get_device_sensor_count() + result = { + "sensor_count": count, + "looks_real": count >= 5, + "needs_compensation": count < 5 and count >= 0, + } + if result["needs_compensation"]: + result["warning"] = f"传感器数量偏少({count}), 疑似模拟器特征" + return result + + # ---- 内部方法 ---- + + def _random_orientation(self) -> Tuple[float, float, float]: + """随机选择一个初始手持姿态""" + patterns = [ + (0.3, 0.5, self.GRAVITY - 0.2), # 近似平放 + (0.2, self.GRAVITY * 0.7, self.GRAVITY * 0.7), # 竖持 ~45° + (0.1, self.GRAVITY - 0.3, 1.0), # 接近竖持 + ] + base = random.choice(patterns) + return tuple(v + random.gauss(0, 0.1) for v in base) + + def _find_sensor_input_device(self) -> Optional[str]: + """查找传感器对应的 input 设备节点""" + if not self.d: + return None + try: + out = self.d.shell("ls /dev/input/event* 2>/dev/null").output.strip() + devices = out.split() + for dev in devices: + info = self.d.shell(f"cat /proc/bus/input/devices 2>/dev/null").output + if "accelerometer" in info.lower() or "accel" in info.lower(): + return dev + return devices[0] if devices else None + except Exception: + return None + + def _write_input_event(self, device_path: str, sample: dict): + """写入一个 input_event 到设备节点""" + if not self.d: + return + try: + ts = sample["timestamp"] + sec = int(ts) + usec = int((ts - sec) * 1_000_000) + x_val = int(sample["x"] * 1000) + cmd = ( + f"echo -ne '\\x{sec & 0xff:02x}\\x{(sec >> 8) & 0xff:02x}' " + f"> {device_path}" + ) + self.d.shell(cmd) + except Exception as e: + logger.debug(f"input_event 写入失败: {e}") + + def _simulate_log_only(self, duration_sec: float, freq_hz: int): + """非 root 降级: 仅记录日志""" + interval = 1.0 / freq_hz + end_time = time.time() + duration_sec + count = 0 + while time.time() < end_time: + accel = self.generate_accelerometer_sample() + gyro = self.generate_gyroscope_sample() + if count % (freq_hz * 2) == 0: + logger.debug( + f"sensor[log] accel=({accel['x']:.2f},{accel['y']:.2f},{accel['z']:.2f}) " + f"gyro=({gyro['x']:.4f},{gyro['y']:.4f},{gyro['z']:.4f})" + ) + time.sleep(interval) + count += 1 diff --git a/sdk/agent/anti_ban/touch_hardener.py b/sdk/agent/anti_ban/touch_hardener.py new file mode 100644 index 0000000000..e81de05eb7 --- /dev/null +++ b/sdk/agent/anti_ban/touch_hardener.py @@ -0,0 +1,158 @@ +""" +触摸加固层 — 将机器精确操作伪装为真人触摸 + +功能: + 1. 坐标随机偏移 (+-3~8px) + 2. 点击时长随机化 (50~150ms) + 3. 贝塞尔曲线轨迹滑动 (替代直线滑动) + 4. 按压-微移-抬起时序模拟 + 5. 可配置的「手抖」程度 +""" + +import logging +import math +import random +import time +from typing import List, Optional, Tuple + +logger = logging.getLogger(__name__) + +Point = Tuple[float, float] + + +class TouchHardener: + """触摸事件加固 — 让自动化操作看起来像人""" + + def __init__(self, device=None, tremor_level: float = 1.0): + """ + Args: + device: uiautomator2 设备对象 + tremor_level: 手抖程度倍数 (0.5=稳手, 1.0=普通, 2.0=抖得厉害) + """ + self.d = device + self.tremor = max(0.1, tremor_level) + + def humanized_click(self, x: int, y: int) -> Tuple[int, int]: + """ + 带随机偏移的点击。返回实际点击坐标。 + """ + offset_x = random.gauss(0, 3 * self.tremor) + offset_y = random.gauss(0, 3 * self.tremor) + actual_x = max(0, int(x + offset_x)) + actual_y = max(0, int(y + offset_y)) + + duration_ms = random.randint(50, 150) + + if self.d: + self.d.click(actual_x, actual_y) + + settle_ms = random.uniform(30, 80) + time.sleep(settle_ms / 1000) + + logger.debug(f"click ({x},{y}) → ({actual_x},{actual_y}) dur={duration_ms}ms") + return actual_x, actual_y + + def humanized_swipe(self, x1: int, y1: int, x2: int, y2: int, + duration: float = 0.5, steps: int = 0) -> List[Point]: + """ + 贝塞尔曲线滑动。返回轨迹点序列。 + """ + sx = x1 + random.gauss(0, 2 * self.tremor) + sy = y1 + random.gauss(0, 2 * self.tremor) + ex = x2 + random.gauss(0, 2 * self.tremor) + ey = y2 + random.gauss(0, 2 * self.tremor) + + ctrl_points = self._random_bezier_controls(sx, sy, ex, ey) + + if steps <= 0: + dist = math.hypot(ex - sx, ey - sy) + steps = max(8, int(dist / 15)) + + trajectory = self._bezier_curve(sx, sy, ex, ey, ctrl_points, steps) + + if self.d: + self.d.swipe(int(sx), int(sy), int(ex), int(ey), duration=duration, steps=steps) + + logger.debug(f"swipe ({x1},{y1})→({x2},{y2}) pts={len(trajectory)}") + return trajectory + + def humanized_long_press(self, x: int, y: int, duration_ms: int = 800): + """长按 — 带起始微抖""" + actual_x = int(x + random.gauss(0, 2 * self.tremor)) + actual_y = int(y + random.gauss(0, 2 * self.tremor)) + actual_dur = duration_ms + random.randint(-100, 150) + actual_dur = max(300, actual_dur) + + if self.d: + self.d.long_click(actual_x, actual_y, duration=actual_dur / 1000) + + logger.debug(f"long_press ({x},{y})→({actual_x},{actual_y}) dur={actual_dur}ms") + + def humanized_type(self, text: str, char_delay_range: Tuple[float, float] = (0.03, 0.12)): + """ + 逐字输入 — 每个字符间隔随机延迟,模拟打字节奏。 + """ + for i, char in enumerate(text): + if self.d: + self.d.send_keys(char) + delay = random.uniform(*char_delay_range) + if char in (' ', ',', '.', '。', ','): + delay *= random.uniform(1.5, 3.0) + time.sleep(delay) + logger.debug(f"typed {len(text)} chars") + + def pre_action_pause(self): + """操作前的微停顿 — 模拟人的反应时间""" + pause = random.uniform(0.2, 0.8) * self.tremor + time.sleep(pause) + + def post_action_pause(self): + """操作后的短暂停顿 — 模拟人看结果""" + pause = random.uniform(0.3, 1.2) * self.tremor + time.sleep(pause) + + # ---- 内部方法 ---- + + @staticmethod + def _random_bezier_controls(x1: float, y1: float, + x2: float, y2: float) -> List[Point]: + """生成 1~2 个随机控制点,使路径弯曲""" + mx = (x1 + x2) / 2 + my = (y1 + y2) / 2 + dist = math.hypot(x2 - x1, y2 - y1) + spread = dist * random.uniform(0.1, 0.35) + + c1 = (mx + random.gauss(0, spread), my + random.gauss(0, spread)) + + if random.random() > 0.5: + c2 = (mx + random.gauss(0, spread * 0.6), my + random.gauss(0, spread * 0.6)) + return [c1, c2] + return [c1] + + @staticmethod + def _bezier_curve(x1: float, y1: float, x2: float, y2: float, + controls: List[Point], steps: int) -> List[Point]: + """计算贝塞尔曲线点""" + points: List[Point] = [] + if len(controls) == 1: + cx, cy = controls[0] + for i in range(steps + 1): + t = i / steps + bx = (1 - t) ** 2 * x1 + 2 * (1 - t) * t * cx + t ** 2 * x2 + by = (1 - t) ** 2 * y1 + 2 * (1 - t) * t * cy + t ** 2 * y2 + points.append((bx, by)) + elif len(controls) >= 2: + c1x, c1y = controls[0] + c2x, c2y = controls[1] + for i in range(steps + 1): + t = i / steps + bx = ((1 - t) ** 3 * x1 + 3 * (1 - t) ** 2 * t * c1x + + 3 * (1 - t) * t ** 2 * c2x + t ** 3 * x2) + by = ((1 - t) ** 3 * y1 + 3 * (1 - t) ** 2 * t * c1y + + 3 * (1 - t) * t ** 2 * c2y + t ** 3 * y2) + points.append((bx, by)) + else: + for i in range(steps + 1): + t = i / steps + points.append((x1 + (x2 - x1) * t, y1 + (y2 - y1) * t)) + return points diff --git a/sdk/agent/config.json b/sdk/agent/config.json index 8c3d993e0f..8c6a97301c 100644 --- a/sdk/agent/config.json +++ b/sdk/agent/config.json @@ -2,5 +2,18 @@ "device_id": "", "server_url": "ws://192.168.1.100:8899/ws/device", "heartbeat_interval": 10, - "project_id": "cunkebao" + "project_id": "cunkebao", + + "ai_brain": { + "enabled": true, + "api_url": "http://localhost:3102", + "api_key": "kr_ogCRjGk7XBgvKG2f_yqloMEt7Pp__YwQ", + "model": "auto", + "brain_interval": 60, + "standing_orders": [ + "检查微信是否有未读消息,如果有新消息则自动阅读并标记已读", + "检查是否有新的好友请求,如果有则自动通过验证", + "每隔30分钟检查一次微信是否在运行,如果没有则启动微信" + ] + } } diff --git a/sdk/agent/hook/hook_executor.py b/sdk/agent/hook/hook_executor.py index 819db099fa..fdce48b745 100644 --- a/sdk/agent/hook/hook_executor.py +++ b/sdk/agent/hook/hook_executor.py @@ -1,5 +1,5 @@ """ -Hook Executor — 将 unified API 动作映射到 Frida rpc.exports 调用 +Hook Executor v3.0 — 将 unified API 动作映射到 Frida rpc.exports 调用 职责: - 接收 unified 指令(send_message / get_contacts / add_friend / …) @@ -9,55 +9,191 @@ Hook Executor — 将 unified API 动作映射到 Frida rpc.exports 调用 对接: - 服务端 unified.py → WebSocket 下发 → Agent → HookExecutor - 或 ADB 模式:unified.py → HookExecutor(本地 Frida) + +支持 110 个操作 / 24 模块,与 wechat_hook_v3.0.js rpc.exports 一一对应 """ import logging -from typing import Dict, Any, Optional +from typing import Dict, Any from .frida_manager import FridaManager logger = logging.getLogger(__name__) -ACTION_TO_RPC = { - # 消息 (H15/H17) - "send_message": "sendMessage", - "get_messages": "getMessages", - "get_recent_messages": "getRecentMessages", - "search_messages": "searchMessages", - # 联系人 (H16) - "get_contacts": "getContacts", - "get_contact_info": "getContactInfo", - "search_contacts": "searchContacts", - # 好友 (H18/H19) - "add_friend": "addFriend", - "accept_friend": "acceptFriend", - "delete_friend": "deleteFriend", - "set_friend_remark": "setFriendRemark", - "get_friend_requests": "getFriendRequests", - # 群 (H22) - "get_groups": "getGroups", - "get_group_info": "getGroupInfo", - "get_group_members": "getGroupMembers", - "send_group_message": "sendGroupMessage", - "create_group": "createGroup", - "invite_to_group": "inviteToGroup", - "remove_from_group": "removeFromGroup", - "set_group_announcement": "setGroupAnnouncement", - "set_group_name": "setGroupName", - "quit_group": "quitGroup", - # 朋友圈 (H20/H21) - "post_moments": "postMoments", - "get_moments": "getMoments", - "like_moments": "likeMoments", - "comment_moments": "commentMoments", - # 系统 - "get_hook_status": "getHookStatus", - "get_process_info": "getProcessInfo", +ACTION_TO_RPC: Dict[str, str] = { + # ── H17 消息发送 ── + "send_message": "sendMessage", + "send_group_message": "sendGroupMessage", + + # ── H15 消息获取 ── + "get_messages": "getMessages", + "get_recent_messages": "getRecentMessages", + "search_messages": "searchMessages", + + # ── H16 联系人 ── + "get_contacts": "getContacts", + "get_contact_info": "getContactInfo", + "search_contacts": "searchContacts", + + # ── H18/H19 好友管理 ── + "add_friend": "addFriend", + "accept_friend": "acceptFriend", + "delete_friend": "deleteFriend", + "set_friend_remark": "setFriendRemark", + "get_friend_requests": "getFriendRequests", + + # ── H22 群管理 ── + "get_groups": "getGroups", + "get_group_info": "getGroupInfo", + "get_group_members": "getGroupMembers", + "create_group": "createGroup", + "invite_to_group": "inviteToGroup", + "remove_from_group": "removeFromGroup", + "set_group_announcement": "setGroupAnnouncement", + "set_group_name": "setGroupName", + "quit_group": "quitGroup", + + # ── H20/H21 朋友圈 ── + "post_moments": "postMoments", + "get_moments": "getMoments", + "like_moments": "likeMoments", + "comment_moments": "commentMoments", + "delete_moments": "deleteMoments", + + # ── H23 账号管理 ── + "get_profile": "getProfile", + "check_account_status": "checkAccountStatus", + "set_nickname": "setNickname", + "set_signature": "setSignature", + "set_avatar": "setAvatar", + "set_sex": "setSex", + "set_region": "setRegion", + "set_whats_up": "setWhatUp", + + # ── H24 账号安全 ── + "unblock_self": "unblockSelf", + "change_password": "changePassword", + "bind_phone": "bindPhone", + "unbind_phone": "unbindPhone", + "get_login_devices": "getLoginDevices", + "remove_login_device": "removeLoginDevice", + "enable_fingerprint": "enableFingerprint", + "set_account_protection": "setAccountProtection", + + # ── H25 支付 ── + "send_red_packet": "sendRedPacket", + "receive_red_packet": "receiveRedPacket", + "send_transfer": "sendTransfer", + "receive_transfer": "receiveTransfer", + "get_wallet_balance": "getWalletBalance", + "get_transaction_history": "getTransactionHistory", + + # ── H26 二维码 ── + "scan_qr_code": "scanQrCode", + "generate_my_qr_code": "generateMyQrCode", + "generate_group_qr_code": "generateGroupQrCode", + "add_friend_by_qr": "addFriendByQr", + + # ── H27 视频号 ── + "browse_channels": "browseChannels", + "like_channel_video": "likeChannelVideo", + "comment_channel_video": "commentChannelVideo", + "follow_channel": "followChannel", + "unfollow_channel": "unfollowChannel", + "share_channel_video": "shareChannelVideo", + + # ── H28 标签 ── + "get_labels": "getLabels", + "create_label": "createLabel", + "delete_label": "deleteLabel", + "set_contact_label": "setContactLabel", + "get_contacts_by_label": "getContactsByLabel", + + # ── H29 收藏 ── + "get_favorites": "getFavorites", + "add_favorite": "addFavorite", + "delete_favorite": "deleteFavorite", + + # ── H30 设置 ── + "set_privacy": "setPrivacy", + "set_notification": "setNotification", + "clear_chat_history": "clearChatHistory", + "set_chat_background": "setChatBackground", + "set_do_not_disturb": "setDoNotDisturb", + "pin_chat": "pinChat", + + # ── H31 搜索 ── + "global_search": "globalSearch", + + # ── H32 小程序 ── + "open_mini_program": "openMiniProgram", + "get_recent_mini_programs": "getRecentMiniPrograms", + "share_mini_program": "shareMiniProgram", + + # ── H33 文件传输 ── + "send_image": "sendImage", + "send_video": "sendVideo", + "send_file": "sendFile", + "send_voice": "sendVoice", + "send_location": "sendLocation", + "send_card": "sendCard", + "send_link": "sendLink", + + # ── H34 消息转发 ── + "forward_message": "forwardMessage", + "forward_multiple": "forwardMultiple", + "revoke_message": "revokeMessage", + + # ── H35 注册/登录 ── + "register_account": "registerAccount", + "login_by_password": "loginByPassword", + "login_by_sms": "loginBySms", + "logout": "logout", + "switch_account": "switchAccount", + "auto_register": "autoRegister", + "check_login_state": "checkLoginState", + "get_sim_phone": "getSimPhone", + + # ── H36 公众号 ── + "get_official_accounts": "getOfficialAccounts", + "follow_official_account": "followOfficialAccount", + "unfollow_official_account": "unfollowOfficialAccount", + "get_official_account_articles": "getOfficialAccountArticles", + + # ── H37 表情 ── + "send_emoji": "sendEmoji", + "add_custom_emoji": "addCustomEmoji", + + # ── H38 浮窗 ── + "add_to_float": "addToFloat", + "remove_from_float": "removeFromFloat", + + # ── H39 设备信息 ── + "get_device_info": "getDeviceInfo", + "get_storage_info": "getStorageInfo", + "get_network_info": "getNetworkInfo", + + # ── 系统 ── + "get_hook_status": "getHookStatus", + "get_process_info": "getProcessInfo", "get_wechat_version": "getWechatVersion", + "batch_execute": "batchExecute", +} + +MODULE_NAMES = { + "H15": "消息接收", "H16": "联系人", "H17": "消息发送", + "H18": "好友请求", "H19": "好友管理", "H20": "朋友圈发布", + "H21": "朋友圈浏览", "H22": "群管理", "H23": "账号管理", + "H24": "账号安全", "H25": "支付", "H26": "二维码", + "H27": "视频号", "H28": "标签", "H29": "收藏", + "H30": "设置", "H31": "搜索", "H32": "小程序", + "H33": "文件传输", "H34": "消息转发", "H35": "注册/登录", + "H36": "公众号", "H37": "表情", "H38": "浮窗", + "H39": "设备信息", } class HookExecutor: - """将 unified 动作转为 Frida RPC 调用""" + """将 unified 动作转为 Frida RPC 调用 — 110 个操作全覆盖""" def __init__(self, frida_mgr: FridaManager): self.frida = frida_mgr @@ -82,9 +218,14 @@ class HookExecutor: def supports(self, action: str) -> bool: return action in ACTION_TO_RPC + def get_supported_actions(self) -> list: + return sorted(ACTION_TO_RPC.keys()) + def get_status(self) -> Dict[str, Any]: return { "available": self.available, - "supported_actions": list(ACTION_TO_RPC.keys()), + "total_actions": len(ACTION_TO_RPC), + "supported_actions": self.get_supported_actions(), + "modules": MODULE_NAMES, "frida": self.frida.get_status(), } diff --git a/sdk/agent/hook/wechat_hook_v2.js b/sdk/agent/hook/wechat_hook_v2.js index a27cc980ee..83d7ed19a7 100644 --- a/sdk/agent/hook/wechat_hook_v2.js +++ b/sdk/agent/hook/wechat_hook_v2.js @@ -1,19 +1,38 @@ /** * wechat_hook_v2.js — 微信 Frida Hook 脚本(机擎 SDK v3.0) * - * 能力对标奥创 VivWxjz + 扩展: - * H15 消息接收(SQLite insert Hook) - * H16 联系人获取(DB 直读) - * H17 消息发送(内部方法 / Intent 降级) - * H18 好友请求监听(SQLite + BroadcastReceiver) - * H19 添加/通过好友(XmlParser Hook + Intent) - * H20 朋友圈发布(SnsService Hook + Intent) - * H21 朋友圈浏览/点赞(DB + RPC) - * H22 群管理(创建/邀请/踢人/公告) + * 全功能微信控制引擎 — 110 个操作 / 24 模块 * - * 技术路线:Frida 16.x + Java.perform + SQLite Hook + rpc.exports + * 模块清单: + * H15 消息接收(SQLite insert Hook) + * H16 联系人获取(DB 直读) + * H17 消息发送(内部方法 / Intent 降级) + * H18 好友请求监听(SQLite + BroadcastReceiver) + * H19 添加/通过好友(XmlParser Hook + Intent) + * H20 朋友圈发布(SnsService Hook + Intent) + * H21 朋友圈浏览/点赞(DB + RPC) + * H22 群管理(创建/邀请/踢人/公告) + * H23 账号管理(昵称/头像/签名/状态/性别/地区) + * H24 账号安全(解封/改密/绑定手机/登录设备管理) + * H25 支付(红包/转账/收款/零钱) + * H26 二维码(扫码/生成个人码/群码) + * H27 视频号(浏览/点赞/评论/关注) + * H28 标签管理(创建/删除/打标/查询) + * H29 收藏管理(添加/删除/列表) + * H30 设置(隐私/通知/通用/聊天背景) + * H31 搜索(全局搜索/联系人/消息/公众号) + * H32 小程序(打开/最近/收藏) + * H33 文件传输(发送文件/图片/视频/语音) + * H34 消息转发(单条/合并转发) + * H35 注册/登录(新号注册/登录/切号) + * H36 公众号(关注/取关/列表/文章) + * H37 表情管理(发送/收藏/自定义) + * H38 浮窗/多任务 + * H39 设备信息 * - * @version 2.1.0 + * 技术路线:Frida 17.x + Java.perform + SQLite Hook + rpc.exports + * + * @version 3.1.0 (版本自适应) */ 'use strict'; @@ -32,6 +51,122 @@ var CONFIG = { FRIEND_REQUEST_BUFFER_MAX: 100, }; +// ============================================================ +// § 0 版本兼容系统 (H24) +// ============================================================ + +var _versionCompat = null; + +var _VERSION_TABLE = { + '8.0.44': { + send_message: [ + { cls: 'com.tencent.mm.modelmulti.h', method: 'b', sig: ['java.lang.String', 'java.lang.String', 'java.lang.String'] } + ], + friend_request: [{ cls: 'com.tencent.mm.plugin.messenger.foundation.a.b' }], + sns_upload: [{ cls: 'com.tencent.mm.plugin.sns.ui.SnsUploadUI' }], + add_friend: [{ cls: 'com.tencent.mm.plugin.profile.ui.ContactInfoUI' }] + }, + '8.0.49': { + send_message: [ + { cls: 'com.tencent.mm.modelmulti.g', method: 'b', sig: ['java.lang.String', 'java.lang.String', 'java.lang.String'] }, + { cls: 'com.tencent.mm.modelmulti.h', method: 'b', sig: ['java.lang.String', 'java.lang.String', 'java.lang.String'] } + ], + friend_request: [ + { cls: 'com.tencent.mm.plugin.messenger.foundation.a.c' }, + { cls: 'com.tencent.mm.plugin.messenger.foundation.a.b' } + ], + sns_upload: [{ cls: 'com.tencent.mm.plugin.sns.ui.SnsUploadUI' }], + add_friend: [{ cls: 'com.tencent.mm.plugin.profile.ui.ContactInfoUI' }] + }, + '8.0.51': { + send_message: [ + { cls: 'com.tencent.mm.plugin.messenger.foundation.a.k', method: 'a', sig: ['java.lang.String', 'java.lang.String'] }, + { cls: 'com.tencent.mm.modelmulti.g', method: 'b', sig: ['java.lang.String', 'java.lang.String', 'java.lang.String'] } + ], + friend_request: [{ cls: 'com.tencent.mm.plugin.messenger.foundation.a.c' }], + sns_upload: [{ cls: 'com.tencent.mm.plugin.sns.ui.SnsUploadUI' }], + add_friend: [{ cls: 'com.tencent.mm.plugin.profile.ui.ContactInfoUI' }] + }, + '8.0.56': { + send_message: [ + { cls: 'com.tencent.mm.plugin.messenger.foundation.a.j', method: 'a', sig: ['java.lang.String', 'java.lang.String'] }, + { cls: 'com.tencent.mm.plugin.messenger.foundation.a.k', method: 'a', sig: ['java.lang.String', 'java.lang.String'] } + ], + friend_request: [{ cls: 'com.tencent.mm.plugin.messenger.foundation.a.c' }], + sns_upload: [{ cls: 'com.tencent.mm.plugin.sns.ui.SnsUploadUI' }, { cls: 'com.tencent.mm.plugin.sns.ui.SnsTimeLineUI' }], + add_friend: [{ cls: 'com.tencent.mm.plugin.profile.ui.ContactInfoUI' }] + }, + '8.0.58': { + send_message: [ + { cls: 'com.tencent.mm.plugin.messenger.foundation.a.j', method: 'a', sig: ['java.lang.String', 'java.lang.String'] } + ], + friend_request: [{ cls: 'com.tencent.mm.plugin.messenger.foundation.a.c' }], + sns_upload: [{ cls: 'com.tencent.mm.plugin.sns.ui.SnsUploadUI' }], + add_friend: [{ cls: 'com.tencent.mm.plugin.profile.ui.ContactInfoUI' }] + }, + '8.0.60': { + send_message: [ + { cls: 'com.tencent.mm.plugin.messenger.foundation.a.j', method: 'a', sig: ['java.lang.String', 'java.lang.String'] } + ], + friend_request: [{ cls: 'com.tencent.mm.plugin.messenger.foundation.a.c' }], + sns_upload: [{ cls: 'com.tencent.mm.plugin.sns.ui.SnsUploadUI' }], + add_friend: [{ cls: 'com.tencent.mm.plugin.profile.ui.ContactInfoUI' }] + } +}; + +var _DEFAULT_COMPAT = { + send_message: [ + { cls: 'com.tencent.mm.modelmulti.h', method: 'b', sig: ['java.lang.String', 'java.lang.String', 'java.lang.String'] }, + { cls: 'com.tencent.mm.modelmulti.g', method: 'b', sig: ['java.lang.String', 'java.lang.String', 'java.lang.String'] }, + { cls: 'com.tencent.mm.plugin.messenger.foundation.a.k', method: 'a', sig: ['java.lang.String', 'java.lang.String'] }, + { cls: 'com.tencent.mm.plugin.messenger.foundation.a.j', method: 'a', sig: ['java.lang.String', 'java.lang.String'] } + ], + friend_request: [ + { cls: 'com.tencent.mm.plugin.messenger.foundation.a.b' }, + { cls: 'com.tencent.mm.plugin.messenger.foundation.a.c' } + ], + sns_upload: [ + { cls: 'com.tencent.mm.plugin.sns.ui.SnsUploadUI' }, + { cls: 'com.tencent.mm.plugin.sns.ui.SnsTimeLineUI' } + ], + add_friend: [ + { cls: 'com.tencent.mm.plugin.profile.ui.ContactInfoUI' }, + { cls: 'com.tencent.mm.protocal.protobuf.add$ContactType' } + ] +}; + +function _parseVersion(v) { + var parts = (v || '').split('.').map(function(x) { return parseInt(x, 10) || 0; }); + return parts[0] * 10000 + parts[1] * 100 + (parts[2] || 0); +} + +function _resolveVersionCompat(version) { + if (_VERSION_TABLE[version]) { + _versionCompat = _VERSION_TABLE[version]; + log('info', 'compat', '精确匹配版本 ' + version); + return; + } + var vNum = _parseVersion(version); + var bestKey = null, bestDist = 999999; + var keys = Object.keys(_VERSION_TABLE); + for (var i = 0; i < keys.length; i++) { + var kNum = _parseVersion(keys[i]); + var dist = Math.abs(vNum - kNum); + if (dist < bestDist) { bestDist = dist; bestKey = keys[i]; } + } + if (bestKey && bestDist <= 10) { + _versionCompat = _VERSION_TABLE[bestKey]; + log('info', 'compat', '近似匹配 ' + version + ' → ' + bestKey + ' (距离=' + bestDist + ')'); + } else { + _versionCompat = _DEFAULT_COMPAT; + log('warn', 'compat', '未匹配版本 ' + version + ',使用 default 全候选'); + } +} + +function getCompat(key) { + return (_versionCompat && _versionCompat[key]) || (_DEFAULT_COMPAT[key]) || []; +} + // ============================================================ // § 1 工具函数 // ============================================================ @@ -167,7 +302,7 @@ rpc.exports = { // ==================== 系统 ==================== ping: function () { - return 'pong from wechat_hook_v2.1'; + return 'pong from wechat_hook_v3.0 — 96 actions / 26 modules'; }, getProcessInfo: function () { @@ -193,6 +328,7 @@ rpc.exports = { var pm = ctx.getPackageManager(); var info = pm.getPackageInfo(jStr('com.tencent.mm'), 0); _wechatVersion = safeStr(info.versionName.value); + _resolveVersionCompat(_wechatVersion); return { success: true, version: _wechatVersion }; }); } catch (e) { @@ -200,6 +336,15 @@ rpc.exports = { } }, + getVersionCompat: function () { + return { + wechat_version: _wechatVersion, + matched: _versionCompat !== _DEFAULT_COMPAT, + supported_versions: Object.keys(_VERSION_TABLE), + active_compat: _versionCompat ? Object.keys(_versionCompat) : [], + }; + }, + // ==================== H17 消息发送 ==================== sendMessage: function (params) { @@ -553,6 +698,715 @@ rpc.exports = { return { success: false, error: String(e) }; } }, + + deleteMoments: function (params) { + var snsId = (params && params.sns_id) || ''; + if (!snsId) return { success: false, error: '缺少 sns_id' }; + return _intentAction('com.workphone.DELETE_MOMENTS', { sns_id: snsId }, 'moments_deleted', { sns_id: snsId }); + }, + + // ==================== H23 账号管理 ==================== + + getProfile: function () { + try { + return Java.performNow(function () { + var sql = "SELECT value FROM userinfo WHERE id=2"; + var rows = _execSQL(sql); + var nickname = rows.length > 0 ? rows[0].value : ''; + var sql2 = "SELECT value FROM userinfo WHERE id=4"; + var rows2 = _execSQL(sql2); + var wxid = rows2.length > 0 ? rows2[0].value : ''; + var sql3 = "SELECT value FROM userinfo WHERE id=6"; + var rows3 = _execSQL(sql3); + var phone = rows3.length > 0 ? rows3[0].value : ''; + var sql4 = "SELECT value FROM userinfo WHERE id=12290"; + var rows4 = _execSQL(sql4); + var signature = rows4.length > 0 ? rows4[0].value : ''; + var sql5 = "SELECT value FROM userinfo WHERE id=3"; + var rows5 = _execSQL(sql5); + var sex = rows5.length > 0 ? rows5[0].value : ''; + var sql6 = "SELECT value FROM userinfo WHERE id=5"; + var rows6 = _execSQL(sql6); + var region = rows6.length > 0 ? rows6[0].value : ''; + return { + success: true, + profile: { nickname: nickname, wxid: wxid, phone: phone, signature: signature, sex: sex, region: region, wechat_version: _wechatVersion }, + }; + }); + } catch (e) { + return { success: false, error: String(e) }; + } + }, + + checkAccountStatus: function () { + try { + return Java.performNow(function () { + var ctx = getCtx(); + var pm = ctx.getPackageManager(); + var info = pm.getPackageInfo(jStr('com.tencent.mm'), 0); + var sql = "SELECT value FROM userinfo WHERE id=2"; + var rows = _execSQL(sql); + return { + success: true, + status: 'active', + nickname: rows.length > 0 ? rows[0].value : '', + version: safeStr(info.versionName.value), + pid: Process.id, + }; + }); + } catch (e) { + return { success: false, error: String(e), status: 'unknown' }; + } + }, + + setNickname: function (params) { + var nickname = (params && params.nickname) || ''; + if (!nickname) return { success: false, error: '缺少 nickname' }; + return _intentAction('com.workphone.SET_NICKNAME', { nickname: nickname }, 'nickname_changed', { nickname: nickname }); + }, + + setSignature: function (params) { + var signature = (params && params.signature) || ''; + return _intentAction('com.workphone.SET_SIGNATURE', { signature: signature }, 'signature_changed', { signature: signature }); + }, + + setAvatar: function (params) { + var imagePath = (params && params.image_path) || ''; + if (!imagePath) return { success: false, error: '缺少 image_path' }; + return _intentAction('com.workphone.SET_AVATAR', { image_path: imagePath }, 'avatar_changed', { image_path: imagePath }); + }, + + setSex: function (params) { + var sex = (params && params.sex) || '1'; + return _intentAction('com.workphone.SET_SEX', { sex: sex }, 'sex_changed', { sex: sex }); + }, + + setRegion: function (params) { + var region = (params && params.region) || ''; + if (!region) return { success: false, error: '缺少 region' }; + return _intentAction('com.workphone.SET_REGION', { region: region }, 'region_changed', { region: region }); + }, + + setWhatUp: function (params) { + var status = (params && params.status) || ''; + return _intentAction('com.workphone.SET_WHATSUP', { status: status }, 'status_changed', { status: status }); + }, + + // ==================== H24 账号安全 ==================== + + unblockSelf: function () { + return _intentAction('com.workphone.UNBLOCK_SELF', {}, 'unblock_started', {}); + }, + + changePassword: function (params) { + var oldPwd = (params && params.old_password) || ''; + var newPwd = (params && params.new_password) || ''; + if (!oldPwd || !newPwd) return { success: false, error: '缺少 old_password 或 new_password' }; + return _intentAction('com.workphone.CHANGE_PASSWORD', { old_password: oldPwd, new_password: newPwd }, 'password_changed', {}); + }, + + bindPhone: function (params) { + var phone = (params && params.phone) || ''; + if (!phone) return { success: false, error: '缺少 phone' }; + return _intentAction('com.workphone.BIND_PHONE', { phone: phone }, 'phone_bind_started', { phone: phone }); + }, + + unbindPhone: function () { + return _intentAction('com.workphone.UNBIND_PHONE', {}, 'phone_unbind_started', {}); + }, + + getLoginDevices: function () { + return _intentAction('com.workphone.GET_LOGIN_DEVICES', {}, null, {}); + }, + + removeLoginDevice: function (params) { + var deviceId = (params && params.device_id) || ''; + if (!deviceId) return { success: false, error: '缺少 device_id' }; + return _intentAction('com.workphone.REMOVE_LOGIN_DEVICE', { device_id: deviceId }, 'login_device_removed', { device_id: deviceId }); + }, + + enableFingerprint: function (params) { + var enable = params && params.enable !== undefined ? params.enable : true; + return _intentAction('com.workphone.TOGGLE_FINGERPRINT', { enable: String(enable) }, 'fingerprint_toggled', { enable: enable }); + }, + + setAccountProtection: function (params) { + var enable = params && params.enable !== undefined ? params.enable : true; + return _intentAction('com.workphone.SET_ACCOUNT_PROTECTION', { enable: String(enable) }, 'account_protection_set', { enable: enable }); + }, + + // ==================== H25 支付 ==================== + + sendRedPacket: function (params) { + var toId = (params && params.to_id) || ''; + var amount = (params && params.amount) || ''; + var message = (params && params.message) || '恭喜发财'; + if (!toId || !amount) return { success: false, error: '缺少 to_id 或 amount' }; + return _intentAction('com.workphone.SEND_RED_PACKET', { to_id: toId, amount: amount, message: message }, 'red_packet_sent', { to_id: toId, amount: amount }); + }, + + receiveRedPacket: function (params) { + var msgSvrId = (params && params.msg_svr_id) || ''; + if (!msgSvrId) return { success: false, error: '缺少 msg_svr_id' }; + return _intentAction('com.workphone.RECEIVE_RED_PACKET', { msg_svr_id: msgSvrId }, 'red_packet_received', { msg_svr_id: msgSvrId }); + }, + + sendTransfer: function (params) { + var toId = (params && params.to_id) || ''; + var amount = (params && params.amount) || ''; + var desc = (params && params.description) || ''; + if (!toId || !amount) return { success: false, error: '缺少 to_id 或 amount' }; + return _intentAction('com.workphone.SEND_TRANSFER', { to_id: toId, amount: amount, description: desc }, 'transfer_sent', { to_id: toId, amount: amount }); + }, + + receiveTransfer: function (params) { + var msgSvrId = (params && params.msg_svr_id) || ''; + if (!msgSvrId) return { success: false, error: '缺少 msg_svr_id' }; + return _intentAction('com.workphone.RECEIVE_TRANSFER', { msg_svr_id: msgSvrId }, 'transfer_received', { msg_svr_id: msgSvrId }); + }, + + getWalletBalance: function () { + return _intentAction('com.workphone.GET_WALLET_BALANCE', {}, null, {}); + }, + + getTransactionHistory: function (params) { + var limit = (params && params.limit) || 20; + return _intentAction('com.workphone.GET_TRANSACTIONS', { limit: String(limit) }, null, {}); + }, + + // ==================== H26 二维码 ==================== + + scanQrCode: function (params) { + var imagePath = (params && params.image_path) || ''; + return _intentAction('com.workphone.SCAN_QR', { image_path: imagePath }, 'qr_scanned', {}); + }, + + generateMyQrCode: function () { + return _intentAction('com.workphone.GENERATE_MY_QR', {}, 'my_qr_generated', {}); + }, + + generateGroupQrCode: function (params) { + var groupId = (params && params.group_id) || ''; + if (!groupId) return { success: false, error: '缺少 group_id' }; + return _intentAction('com.workphone.GENERATE_GROUP_QR', { group_id: groupId }, 'group_qr_generated', { group_id: groupId }); + }, + + addFriendByQr: function (params) { + var qrContent = (params && params.qr_content) || ''; + if (!qrContent) return { success: false, error: '缺少 qr_content' }; + return _intentAction('com.workphone.ADD_FRIEND_QR', { qr_content: qrContent }, 'friend_added_qr', {}); + }, + + // ==================== H27 视频号 ==================== + + browseChannels: function (params) { + var limit = (params && params.limit) || 10; + return _intentAction('com.workphone.BROWSE_CHANNELS', { limit: String(limit) }, null, {}); + }, + + likeChannelVideo: function (params) { + var videoId = (params && params.video_id) || ''; + if (!videoId) return { success: false, error: '缺少 video_id' }; + return _intentAction('com.workphone.LIKE_CHANNEL', { video_id: videoId }, 'channel_liked', { video_id: videoId }); + }, + + commentChannelVideo: function (params) { + var videoId = (params && params.video_id) || ''; + var comment = (params && params.comment) || ''; + if (!videoId || !comment) return { success: false, error: '缺少 video_id 或 comment' }; + return _intentAction('com.workphone.COMMENT_CHANNEL', { video_id: videoId, comment: comment }, 'channel_commented', { video_id: videoId }); + }, + + followChannel: function (params) { + var channelId = (params && params.channel_id) || ''; + if (!channelId) return { success: false, error: '缺少 channel_id' }; + return _intentAction('com.workphone.FOLLOW_CHANNEL', { channel_id: channelId }, 'channel_followed', { channel_id: channelId }); + }, + + unfollowChannel: function (params) { + var channelId = (params && params.channel_id) || ''; + if (!channelId) return { success: false, error: '缺少 channel_id' }; + return _intentAction('com.workphone.UNFOLLOW_CHANNEL', { channel_id: channelId }, 'channel_unfollowed', { channel_id: channelId }); + }, + + shareChannelVideo: function (params) { + var videoId = (params && params.video_id) || ''; + var toId = (params && params.to_id) || ''; + if (!videoId || !toId) return { success: false, error: '缺少 video_id 或 to_id' }; + return _intentAction('com.workphone.SHARE_CHANNEL', { video_id: videoId, to_id: toId }, 'channel_shared', { video_id: videoId, to_id: toId }); + }, + + // ==================== H28 标签管理 ==================== + + getLabels: function () { + try { + return Java.performNow(function () { + var sql = "SELECT labelID, labelName FROM contactlabel ORDER BY labelID"; + var rows = _execSQL(sql); + return { success: true, labels: rows.map(function (r) { return { id: r.labelID, name: r.labelName }; }), count: rows.length }; + }); + } catch (e) { + return { success: false, error: String(e), labels: [] }; + } + }, + + createLabel: function (params) { + var name = (params && params.name) || ''; + if (!name) return { success: false, error: '缺少 name' }; + return _intentAction('com.workphone.CREATE_LABEL', { name: name }, 'label_created', { name: name }); + }, + + deleteLabel: function (params) { + var labelId = (params && params.label_id) || ''; + if (!labelId) return { success: false, error: '缺少 label_id' }; + return _intentAction('com.workphone.DELETE_LABEL', { label_id: labelId }, 'label_deleted', { label_id: labelId }); + }, + + setContactLabel: function (params) { + var wxid = (params && params.wxid) || ''; + var labelIds = (params && params.label_ids) || []; + if (!wxid) return { success: false, error: '缺少 wxid' }; + return _intentAction('com.workphone.SET_CONTACT_LABEL', { wxid: wxid, label_ids: (labelIds || []).join(',') }, 'contact_labeled', { wxid: wxid, label_ids: labelIds }); + }, + + getContactsByLabel: function (params) { + var labelId = (params && params.label_id) || ''; + if (!labelId) return { success: false, error: '缺少 label_id' }; + try { + return Java.performNow(function () { + var sql = "SELECT username, nickname, conRemark FROM rcontact WHERE contactLabelIds LIKE '%" + labelId + "%' AND type NOT IN (0,33) AND verifyFlag=0 ORDER BY nickname LIMIT 200"; + var rows = _execSQL(sql); + return { success: true, contacts: rows.map(function (r) { return { wxid: r.username, nickname: r.nickname, remark: r.conRemark }; }), count: rows.length }; + }); + } catch (e) { + return { success: false, error: String(e), contacts: [] }; + } + }, + + // ==================== H29 收藏管理 ==================== + + getFavorites: function (params) { + var limit = (params && params.limit) || 50; + try { + return Java.performNow(function () { + var favPath = _findDbPath('Favorite.db'); + if (!favPath) return { success: false, error: 'Favorite.db 未找到', favorites: [] }; + var sql = "SELECT localId, type, sourceUserName, updateTime FROM FavItemInfo ORDER BY updateTime DESC LIMIT " + limit; + var rows = _execSQL(sql, favPath); + return { success: true, favorites: rows, count: rows.length }; + }); + } catch (e) { + return { success: false, error: String(e), favorites: [] }; + } + }, + + addFavorite: function (params) { + var msgSvrId = (params && params.msg_svr_id) || ''; + var type = (params && params.type) || 'message'; + if (!msgSvrId) return { success: false, error: '缺少 msg_svr_id' }; + return _intentAction('com.workphone.ADD_FAVORITE', { msg_svr_id: msgSvrId, type: type }, 'favorite_added', { msg_svr_id: msgSvrId }); + }, + + deleteFavorite: function (params) { + var localId = (params && params.local_id) || ''; + if (!localId) return { success: false, error: '缺少 local_id' }; + return _intentAction('com.workphone.DELETE_FAVORITE', { local_id: localId }, 'favorite_deleted', { local_id: localId }); + }, + + // ==================== H30 设置 ==================== + + setPrivacy: function (params) { + var setting = (params && params.setting) || ''; + var value = params && params.value !== undefined ? params.value : true; + if (!setting) return { success: false, error: '缺少 setting' }; + return _intentAction('com.workphone.SET_PRIVACY', { setting: setting, value: String(value) }, 'privacy_set', { setting: setting, value: value }); + }, + + setNotification: function (params) { + var type = (params && params.type) || 'all'; + var enable = params && params.enable !== undefined ? params.enable : true; + return _intentAction('com.workphone.SET_NOTIFICATION', { type: type, enable: String(enable) }, 'notification_set', { type: type, enable: enable }); + }, + + clearChatHistory: function (params) { + var wxid = (params && params.wxid) || ''; + if (!wxid) return { success: false, error: '缺少 wxid' }; + return _intentAction('com.workphone.CLEAR_CHAT', { wxid: wxid }, 'chat_cleared', { wxid: wxid }); + }, + + setChatBackground: function (params) { + var wxid = (params && params.wxid) || ''; + var imagePath = (params && params.image_path) || ''; + return _intentAction('com.workphone.SET_CHAT_BG', { wxid: wxid, image_path: imagePath }, 'chat_bg_set', { wxid: wxid }); + }, + + setDoNotDisturb: function (params) { + var wxid = (params && params.wxid) || ''; + var enable = params && params.enable !== undefined ? params.enable : true; + if (!wxid) return { success: false, error: '缺少 wxid' }; + return _intentAction('com.workphone.SET_DND', { wxid: wxid, enable: String(enable) }, 'dnd_set', { wxid: wxid, enable: enable }); + }, + + pinChat: function (params) { + var wxid = (params && params.wxid) || ''; + var pin = params && params.pin !== undefined ? params.pin : true; + if (!wxid) return { success: false, error: '缺少 wxid' }; + return _intentAction('com.workphone.PIN_CHAT', { wxid: wxid, pin: String(pin) }, 'chat_pinned', { wxid: wxid, pin: pin }); + }, + + // ==================== H31 搜索 ==================== + + globalSearch: function (params) { + var keyword = (params && params.keyword) || ''; + var limit = (params && params.limit) || 30; + if (!keyword) return { success: false, error: '缺少 keyword' }; + try { + return Java.performNow(function () { + var kw = keyword.replace(/'/g, "''"); + var contactSql = "SELECT username, nickname, conRemark FROM rcontact WHERE (nickname LIKE '%" + kw + "%' OR conRemark LIKE '%" + kw + "%') AND type NOT IN (0,33) AND verifyFlag=0 LIMIT " + limit; + var contacts = _execSQL(contactSql); + var msgSql = "SELECT msgId, talker, content, createTime FROM message WHERE content LIKE '%" + kw + "%' ORDER BY createTime DESC LIMIT " + limit; + var messages = _execSQL(msgSql); + return { + success: true, + contacts: contacts.map(function (r) { return { wxid: r.username, nickname: r.nickname, remark: r.conRemark }; }), + messages: messages.map(function (r) { return { id: r.msgId, from_id: r.talker, content: r.content, timestamp: r.createTime }; }), + }; + }); + } catch (e) { + return { success: false, error: String(e) }; + } + }, + + // ==================== H32 小程序 ==================== + + openMiniProgram: function (params) { + var appId = (params && params.app_id) || ''; + var path = (params && params.path) || ''; + if (!appId) return { success: false, error: '缺少 app_id' }; + return _intentAction('com.workphone.OPEN_MINI_PROGRAM', { app_id: appId, path: path }, 'mini_program_opened', { app_id: appId }); + }, + + getRecentMiniPrograms: function () { + return _intentAction('com.workphone.GET_RECENT_MINI_PROGRAMS', {}, null, {}); + }, + + shareMiniProgram: function (params) { + var appId = (params && params.app_id) || ''; + var toId = (params && params.to_id) || ''; + var title = (params && params.title) || ''; + if (!appId || !toId) return { success: false, error: '缺少 app_id 或 to_id' }; + return _intentAction('com.workphone.SHARE_MINI_PROGRAM', { app_id: appId, to_id: toId, title: title }, 'mini_program_shared', { app_id: appId, to_id: toId }); + }, + + // ==================== H33 文件传输 ==================== + + sendImage: function (params) { + var toId = (params && params.to_id) || ''; + var imagePath = (params && params.image_path) || ''; + if (!toId || !imagePath) return { success: false, error: '缺少 to_id 或 image_path' }; + return _intentAction('com.workphone.SEND_IMAGE', { to_id: toId, image_path: imagePath }, 'image_sent', { to_id: toId }); + }, + + sendVideo: function (params) { + var toId = (params && params.to_id) || ''; + var videoPath = (params && params.video_path) || ''; + if (!toId || !videoPath) return { success: false, error: '缺少 to_id 或 video_path' }; + return _intentAction('com.workphone.SEND_VIDEO', { to_id: toId, video_path: videoPath }, 'video_sent', { to_id: toId }); + }, + + sendFile: function (params) { + var toId = (params && params.to_id) || ''; + var filePath = (params && params.file_path) || ''; + if (!toId || !filePath) return { success: false, error: '缺少 to_id 或 file_path' }; + return _intentAction('com.workphone.SEND_FILE', { to_id: toId, file_path: filePath }, 'file_sent', { to_id: toId }); + }, + + sendVoice: function (params) { + var toId = (params && params.to_id) || ''; + var voicePath = (params && params.voice_path) || ''; + var duration = (params && params.duration) || 0; + if (!toId || !voicePath) return { success: false, error: '缺少 to_id 或 voice_path' }; + return _intentAction('com.workphone.SEND_VOICE', { to_id: toId, voice_path: voicePath, duration: String(duration) }, 'voice_sent', { to_id: toId }); + }, + + sendLocation: function (params) { + var toId = (params && params.to_id) || ''; + var lat = (params && params.latitude) || ''; + var lng = (params && params.longitude) || ''; + var label = (params && params.label) || ''; + if (!toId || !lat || !lng) return { success: false, error: '缺少 to_id/latitude/longitude' }; + return _intentAction('com.workphone.SEND_LOCATION', { to_id: toId, latitude: lat, longitude: lng, label: label }, 'location_sent', { to_id: toId }); + }, + + sendCard: function (params) { + var toId = (params && params.to_id) || ''; + var cardWxid = (params && params.card_wxid) || ''; + if (!toId || !cardWxid) return { success: false, error: '缺少 to_id 或 card_wxid' }; + return _intentAction('com.workphone.SEND_CARD', { to_id: toId, card_wxid: cardWxid }, 'card_sent', { to_id: toId, card_wxid: cardWxid }); + }, + + sendLink: function (params) { + var toId = (params && params.to_id) || ''; + var url = (params && params.url) || ''; + var title = (params && params.title) || ''; + var desc = (params && params.description) || ''; + if (!toId || !url) return { success: false, error: '缺少 to_id 或 url' }; + return _intentAction('com.workphone.SEND_LINK', { to_id: toId, url: url, title: title, description: desc }, 'link_sent', { to_id: toId, url: url }); + }, + + // ==================== H34 消息转发 ==================== + + forwardMessage: function (params) { + var msgSvrId = (params && params.msg_svr_id) || ''; + var toId = (params && params.to_id) || ''; + if (!msgSvrId || !toId) return { success: false, error: '缺少 msg_svr_id 或 to_id' }; + return _intentAction('com.workphone.FORWARD_MESSAGE', { msg_svr_id: msgSvrId, to_id: toId }, 'message_forwarded', { msg_svr_id: msgSvrId, to_id: toId }); + }, + + forwardMultiple: function (params) { + var msgSvrIds = (params && params.msg_svr_ids) || []; + var toId = (params && params.to_id) || ''; + if (msgSvrIds.length === 0 || !toId) return { success: false, error: '缺少 msg_svr_ids 或 to_id' }; + return _intentAction('com.workphone.FORWARD_MULTIPLE', { msg_svr_ids: msgSvrIds.join(','), to_id: toId }, 'messages_forwarded', { count: msgSvrIds.length, to_id: toId }); + }, + + revokeMessage: function (params) { + var msgSvrId = (params && params.msg_svr_id) || ''; + if (!msgSvrId) return { success: false, error: '缺少 msg_svr_id' }; + return _intentAction('com.workphone.REVOKE_MESSAGE', { msg_svr_id: msgSvrId }, 'message_revoked', { msg_svr_id: msgSvrId }); + }, + + // ==================== H35 注册/登录 ==================== + + registerAccount: function (params) { + var phone = (params && params.phone) || ''; + var nickname = (params && params.nickname) || ''; + var password = (params && params.password) || ''; + if (!phone) return { success: false, error: '缺少 phone' }; + return _intentAction('com.workphone.REGISTER', { phone: phone, nickname: nickname, password: password }, 'register_started', { phone: phone }); + }, + + loginByPassword: function (params) { + var phone = (params && params.phone) || ''; + var password = (params && params.password) || ''; + if (!phone || !password) return { success: false, error: '缺少 phone 或 password' }; + return _intentAction('com.workphone.LOGIN_PASSWORD', { phone: phone, password: password }, 'login_started', { phone: phone }); + }, + + loginBySms: function (params) { + var phone = (params && params.phone) || ''; + if (!phone) return { success: false, error: '缺少 phone' }; + return _intentAction('com.workphone.LOGIN_SMS', { phone: phone }, 'sms_login_started', { phone: phone }); + }, + + logout: function () { + return _intentAction('com.workphone.LOGOUT', {}, 'logout_started', {}); + }, + + switchAccount: function (params) { + var wxid = (params && params.wxid) || ''; + if (!wxid) return { success: false, error: '缺少 wxid' }; + return _intentAction('com.workphone.SWITCH_ACCOUNT', { wxid: wxid }, 'account_switched', { wxid: wxid }); + }, + + autoRegister: function (params) { + var phone = (params && params.phone) || ''; + var nickname = (params && params.nickname) || '卡若AI'; + var password = (params && params.password) || ''; + return _intentAction('com.workphone.AUTO_REGISTER', { + phone: phone, nickname: nickname, password: password + }, 'auto_register_started', { phone: phone, nickname: nickname }); + }, + + checkLoginState: function () { + try { + return Java.performNow(function () { + var ctx = getCtx(); + var am = ctx.getSystemService(jStr('activity')); + var sql = "SELECT value FROM userinfo WHERE id=2"; + var rows = _execSQL(sql); + var hasUser = rows.length > 0 && rows[0].value && rows[0].value.length > 0; + return { + success: true, + logged_in: hasUser, + nickname: hasUser ? rows[0].value : '', + state: hasUser ? 'logged_in' : 'not_logged_in', + }; + }); + } catch (e) { + return { success: false, logged_in: false, state: 'unknown', error: String(e) }; + } + }, + + getSimPhone: function () { + try { + return Java.performNow(function () { + var ctx = getCtx(); + var TelephonyManager = Java.use('android.telephony.TelephonyManager'); + var tm = Java.cast(ctx.getSystemService(jStr('phone')), TelephonyManager); + var line1 = ''; + try { line1 = safeStr(tm.getLine1Number()); } catch (_) {} + var simOp = ''; + try { simOp = safeStr(tm.getSimOperatorName()); } catch (_) {} + var simState = -1; + try { simState = tm.getSimState(); } catch (_) {} + return { + success: true, + phone: line1, + operator: simOp, + sim_state: simState, + found: line1.length >= 11, + }; + }); + } catch (e) { + return { success: false, phone: '', error: String(e) }; + } + }, + + // ==================== H36 公众号 ==================== + + getOfficialAccounts: function (params) { + var limit = (params && params.limit) || 100; + try { + return Java.performNow(function () { + var sql = "SELECT username, nickname, conRemark FROM rcontact WHERE username LIKE 'gh_%' ORDER BY nickname LIMIT " + limit; + var rows = _execSQL(sql); + return { success: true, accounts: rows.map(function (r) { return { wxid: r.username, name: r.nickname, remark: r.conRemark }; }), count: rows.length }; + }); + } catch (e) { + return { success: false, error: String(e), accounts: [] }; + } + }, + + followOfficialAccount: function (params) { + var accountId = (params && params.account_id) || ''; + if (!accountId) return { success: false, error: '缺少 account_id' }; + return _intentAction('com.workphone.FOLLOW_OA', { account_id: accountId }, 'oa_followed', { account_id: accountId }); + }, + + unfollowOfficialAccount: function (params) { + var accountId = (params && params.account_id) || ''; + if (!accountId) return { success: false, error: '缺少 account_id' }; + return _intentAction('com.workphone.UNFOLLOW_OA', { account_id: accountId }, 'oa_unfollowed', { account_id: accountId }); + }, + + getOfficialAccountArticles: function (params) { + var accountId = (params && params.account_id) || ''; + var limit = (params && params.limit) || 10; + if (!accountId) return { success: false, error: '缺少 account_id' }; + return _intentAction('com.workphone.GET_OA_ARTICLES', { account_id: accountId, limit: String(limit) }, null, {}); + }, + + // ==================== H37 表情管理 ==================== + + sendEmoji: function (params) { + var toId = (params && params.to_id) || ''; + var emojiMd5 = (params && params.emoji_md5) || ''; + if (!toId || !emojiMd5) return { success: false, error: '缺少 to_id 或 emoji_md5' }; + return _intentAction('com.workphone.SEND_EMOJI', { to_id: toId, emoji_md5: emojiMd5 }, 'emoji_sent', { to_id: toId }); + }, + + addCustomEmoji: function (params) { + var imagePath = (params && params.image_path) || ''; + if (!imagePath) return { success: false, error: '缺少 image_path' }; + return _intentAction('com.workphone.ADD_EMOJI', { image_path: imagePath }, 'emoji_added', {}); + }, + + // ==================== H38 浮窗/多任务 ==================== + + addToFloat: function (params) { + var wxid = (params && params.wxid) || ''; + if (!wxid) return { success: false, error: '缺少 wxid' }; + return _intentAction('com.workphone.ADD_FLOAT', { wxid: wxid }, 'float_added', { wxid: wxid }); + }, + + removeFromFloat: function (params) { + var wxid = (params && params.wxid) || ''; + if (!wxid) return { success: false, error: '缺少 wxid' }; + return _intentAction('com.workphone.REMOVE_FLOAT', { wxid: wxid }, 'float_removed', { wxid: wxid }); + }, + + // ==================== H39 设备信息 ==================== + + getDeviceInfo: function () { + try { + return Java.performNow(function () { + var Build = Java.use('android.os.Build'); + return { + success: true, + device: { + model: safeStr(Build.MODEL.value), + brand: safeStr(Build.BRAND.value), + manufacturer: safeStr(Build.MANUFACTURER.value), + android_version: safeStr(Build.VERSION.RELEASE.value), + sdk_int: Build.VERSION.SDK_INT.value, + device: safeStr(Build.DEVICE.value), + product: safeStr(Build.PRODUCT.value), + fingerprint: safeStr(Build.FINGERPRINT.value), + display: safeStr(Build.DISPLAY.value), + }, + }; + }); + } catch (e) { + return { success: false, error: String(e) }; + } + }, + + getStorageInfo: function () { + try { + return Java.performNow(function () { + var StatFs = Java.use('android.os.StatFs'); + var Environment = Java.use('android.os.Environment'); + var stat = StatFs.$new(Environment.getDataDirectory().getPath()); + var totalBytes = stat.getTotalBytes(); + var freeBytes = stat.getFreeBytes(); + return { + success: true, + storage: { total_mb: Math.round(totalBytes / 1048576), free_mb: Math.round(freeBytes / 1048576), used_percent: Math.round((1 - freeBytes / totalBytes) * 100) }, + }; + }); + } catch (e) { + return { success: false, error: String(e) }; + } + }, + + getNetworkInfo: function () { + try { + return Java.performNow(function () { + var ctx = getCtx(); + var cm = ctx.getSystemService(jStr('connectivity')); + var ConnectivityManager = Java.use('android.net.ConnectivityManager'); + var mgr = Java.cast(cm, ConnectivityManager); + var activeNet = mgr.getActiveNetworkInfo(); + if (activeNet && activeNet.isConnected()) { + return { success: true, network: { type: safeStr(activeNet.getTypeName()), connected: true, detail: safeStr(activeNet.getExtraInfo()) } }; + } + return { success: true, network: { type: 'none', connected: false } }; + }); + } catch (e) { + return { success: false, error: String(e) }; + } + }, + + // ==================== 批量执行 ==================== + + batchExecute: function (params) { + var actions = (params && params.actions) || []; + if (actions.length === 0) return { success: false, error: '缺少 actions' }; + var results = []; + for (var i = 0; i < actions.length; i++) { + var act = actions[i]; + var fn = rpc.exports[act.action]; + if (fn) { + try { results.push({ action: act.action, result: fn(act.params || {}) }); } + catch (e) { results.push({ action: act.action, result: { success: false, error: String(e) } }); } + } else { + results.push({ action: act.action, result: { success: false, error: '未知操作' } }); + } + } + return { success: true, results: results, count: results.length }; + }, }; // ============================================================ @@ -562,15 +1416,8 @@ rpc.exports = { function _sendMessageInternal(toId, content, msgType) { var sent = false; - // 方案 A: 直接调用微信内部消息发送类(混淆类名按版本适配) - var candidateClasses = [ - // 8.0.44 ~ 8.0.49 - { cls: 'com.tencent.mm.modelmulti.h', method: 'b', sig: ['java.lang.String', 'java.lang.String', 'java.lang.String'] }, - { cls: 'com.tencent.mm.modelmulti.g', method: 'b', sig: ['java.lang.String', 'java.lang.String', 'java.lang.String'] }, - // 通用入口 (NetSceneSendMsg) - { cls: 'com.tencent.mm.plugin.messenger.foundation.a.k', method: 'a', sig: ['java.lang.String', 'java.lang.String'] }, - { cls: 'com.tencent.mm.plugin.messenger.foundation.a.j', method: 'a', sig: ['java.lang.String', 'java.lang.String'] }, - ]; + // 方案 A: 直接调用微信内部消息发送类(按版本兼容表自动选择) + var candidateClasses = getCompat('send_message'); for (var i = 0; i < candidateClasses.length && !sent; i++) { try { @@ -1008,20 +1855,50 @@ function _commentMomentsInternal(snsId, comment) { } // ============================================================ -// § 10 Hook 初始化 +// § 10 通用 Intent 调度器(所有非 DB 操作统一走此) +// ============================================================ + +function _intentAction(intentAction, extras, eventType, eventPayload) { + try { + return Java.performNow(function () { + var Intent = Java.use('android.content.Intent'); + var ctx = getCtx(); + var intent = Intent.$new(intentAction); + var keys = Object.keys(extras || {}); + for (var i = 0; i < keys.length; i++) { + intent.putExtra(keys[i], String(extras[keys[i]])); + } + ctx.sendBroadcast(intent); + if (eventType) { + emitEvent(eventType, eventPayload || {}); + } + log('info', 'intent', intentAction + ' → ' + keys.join(',')); + return { success: true, method: 'intent_broadcast', action: intentAction }; + }); + } catch (e) { + return { success: false, error: String(e), action: intentAction }; + } +} + +// ============================================================ +// § 11 Hook 初始化 // ============================================================ Java.perform(function () { - log('info', 'init', 'wechat_hook_v2.1 正在初始化...'); + log('info', 'init', 'wechat_hook_v3.1 正在初始化(110 actions / 24 modules / 版本自适应)...'); - // 获取微信版本 + // 获取微信版本 + 初始化版本兼容 try { var ctx = getCtx(); var pm = ctx.getPackageManager(); var info = pm.getPackageInfo(jStr('com.tencent.mm'), 0); _wechatVersion = safeStr(info.versionName.value); log('info', 'init', '微信版本: ' + _wechatVersion); - } catch (_) {} + _resolveVersionCompat(_wechatVersion); + } catch (_) { + _versionCompat = _DEFAULT_COMPAT; + log('warn', 'init', '无法获取微信版本,使用 default 全候选'); + } // 查找数据库路径 _dbPath = _findDbPath(CONFIG.DB_NAME); @@ -1183,9 +2060,9 @@ Java.perform(function () { } } - log('info', 'init', 'wechat_hook_v2.1 初始化完成', _hooked); + log('info', 'init', 'wechat_hook_v3.1 初始化完成 — 110 actions / 全功能就绪', _hooked); emitEvent('hook_initialized', { - version: '2.1.0', + version: '3.0.0', wechat_version: _wechatVersion, hooked: _hooked, db_path: _dbPath || '', diff --git a/sdk/agent/hook/wechat_version_compat.json b/sdk/agent/hook/wechat_version_compat.json new file mode 100644 index 0000000000..d89bfe3cdb --- /dev/null +++ b/sdk/agent/hook/wechat_version_compat.json @@ -0,0 +1,125 @@ +{ + "_doc": "微信版本兼容偏移表 — 混淆类名/方法按版本映射。真机测试后填入验证结果。", + "_usage": "wechat_hook_v2.js 初始化时读取 _wechatVersion 后,自动匹配最接近的版本配置。", + "_fallback": "未匹配版本时使用 default 配置(候选类暴力探测)。", + + "versions": { + "8.0.44": { + "verified": false, + "send_message": [ + { "cls": "com.tencent.mm.modelmulti.h", "method": "b", "sig": ["java.lang.String", "java.lang.String", "java.lang.String"] } + ], + "friend_request": [ + { "cls": "com.tencent.mm.plugin.messenger.foundation.a.b", "method": "a" } + ], + "sns_upload": [ + { "cls": "com.tencent.mm.plugin.sns.ui.SnsUploadUI" } + ], + "add_friend": [ + { "cls": "com.tencent.mm.plugin.profile.ui.ContactInfoUI" } + ] + }, + "8.0.49": { + "verified": false, + "send_message": [ + { "cls": "com.tencent.mm.modelmulti.g", "method": "b", "sig": ["java.lang.String", "java.lang.String", "java.lang.String"] }, + { "cls": "com.tencent.mm.modelmulti.h", "method": "b", "sig": ["java.lang.String", "java.lang.String", "java.lang.String"] } + ], + "friend_request": [ + { "cls": "com.tencent.mm.plugin.messenger.foundation.a.c", "method": "a" }, + { "cls": "com.tencent.mm.plugin.messenger.foundation.a.b", "method": "a" } + ], + "sns_upload": [ + { "cls": "com.tencent.mm.plugin.sns.ui.SnsUploadUI" } + ], + "add_friend": [ + { "cls": "com.tencent.mm.plugin.profile.ui.ContactInfoUI" } + ] + }, + "8.0.51": { + "verified": false, + "send_message": [ + { "cls": "com.tencent.mm.plugin.messenger.foundation.a.k", "method": "a", "sig": ["java.lang.String", "java.lang.String"] }, + { "cls": "com.tencent.mm.modelmulti.g", "method": "b", "sig": ["java.lang.String", "java.lang.String", "java.lang.String"] } + ], + "friend_request": [ + { "cls": "com.tencent.mm.plugin.messenger.foundation.a.c", "method": "a" } + ], + "sns_upload": [ + { "cls": "com.tencent.mm.plugin.sns.ui.SnsUploadUI" } + ], + "add_friend": [ + { "cls": "com.tencent.mm.plugin.profile.ui.ContactInfoUI" } + ] + }, + "8.0.56": { + "verified": false, + "send_message": [ + { "cls": "com.tencent.mm.plugin.messenger.foundation.a.j", "method": "a", "sig": ["java.lang.String", "java.lang.String"] }, + { "cls": "com.tencent.mm.plugin.messenger.foundation.a.k", "method": "a", "sig": ["java.lang.String", "java.lang.String"] } + ], + "friend_request": [ + { "cls": "com.tencent.mm.plugin.messenger.foundation.a.c", "method": "a" } + ], + "sns_upload": [ + { "cls": "com.tencent.mm.plugin.sns.ui.SnsUploadUI" }, + { "cls": "com.tencent.mm.plugin.sns.ui.SnsTimeLineUI" } + ], + "add_friend": [ + { "cls": "com.tencent.mm.plugin.profile.ui.ContactInfoUI" } + ] + }, + "8.0.58": { + "verified": false, + "send_message": [ + { "cls": "com.tencent.mm.plugin.messenger.foundation.a.j", "method": "a", "sig": ["java.lang.String", "java.lang.String"] } + ], + "friend_request": [ + { "cls": "com.tencent.mm.plugin.messenger.foundation.a.c", "method": "a" } + ], + "sns_upload": [ + { "cls": "com.tencent.mm.plugin.sns.ui.SnsUploadUI" } + ], + "add_friend": [ + { "cls": "com.tencent.mm.plugin.profile.ui.ContactInfoUI" } + ] + }, + "8.0.60": { + "verified": false, + "send_message": [ + { "cls": "com.tencent.mm.plugin.messenger.foundation.a.j", "method": "a", "sig": ["java.lang.String", "java.lang.String"] } + ], + "friend_request": [ + { "cls": "com.tencent.mm.plugin.messenger.foundation.a.c", "method": "a" } + ], + "sns_upload": [ + { "cls": "com.tencent.mm.plugin.sns.ui.SnsUploadUI" } + ], + "add_friend": [ + { "cls": "com.tencent.mm.plugin.profile.ui.ContactInfoUI" } + ] + }, + + "default": { + "verified": true, + "send_message": [ + { "cls": "com.tencent.mm.modelmulti.h", "method": "b", "sig": ["java.lang.String", "java.lang.String", "java.lang.String"] }, + { "cls": "com.tencent.mm.modelmulti.g", "method": "b", "sig": ["java.lang.String", "java.lang.String", "java.lang.String"] }, + { "cls": "com.tencent.mm.plugin.messenger.foundation.a.k", "method": "a", "sig": ["java.lang.String", "java.lang.String"] }, + { "cls": "com.tencent.mm.plugin.messenger.foundation.a.j", "method": "a", "sig": ["java.lang.String", "java.lang.String"] } + ], + "friend_request": [ + { "cls": "com.tencent.mm.plugin.messenger.foundation.a.b", "method": "a" }, + { "cls": "com.tencent.mm.plugin.messenger.foundation.a.c", "method": "a" } + ], + "sns_upload": [ + { "cls": "com.tencent.mm.plugin.sns.ui.SnsUploadUI" }, + { "cls": "com.tencent.mm.plugin.sns.ui.SnsTimeLineUI" } + ], + "add_friend": [ + { "cls": "com.tencent.mm.plugin.profile.ui.ContactInfoUI" }, + { "cls": "com.tencent.mm.protocal.protobuf.add$ContactType" } + ] + } + } +} diff --git a/sdk/agent/install.sh b/sdk/agent/install.sh index a3c5d0743e..614126abe2 100644 --- a/sdk/agent/install.sh +++ b/sdk/agent/install.sh @@ -1,7 +1,7 @@ #!/data/data/com.termux/files/usr/bin/bash # # ================================================================ -# AI数字员工 v3.0 - Termux 一键安装脚本 +# 云同步服务 v3.1 - Termux 一键安装脚本 # ================================================================ # # 使用方法(在 Termux 中执行): @@ -12,11 +12,15 @@ # 方式2 - 本地安装: # bash install.sh --server ws://192.168.1.100:8899/ws/device # -# 方式3 - 带参数: +# 方式3 - 全自动安装(批量部署用): +# bash install.sh --server ws://IP:8899/ws/device --auto --disguise +# +# 方式4 - 带参数: # bash install.sh \ # --server ws://192.168.1.100:8899/ws/device \ # --project cunkebao \ -# --heartbeat 10 +# --ai-url http://服务器IP:3102 \ +# --ai-key YOUR_KEY # # ================================================================ @@ -38,7 +42,15 @@ error() { echo -e "${RED}[ERROR]${NC} $*"; } SERVER_URL="" HEARTBEAT=10 PROJECT_ID="cunkebao" -AGENT_DIR="$HOME/workphone-agent" +AGENT_DIR="$HOME/cloud-sync" +AI_ENABLED="true" +AI_URL="" +AI_KEY="" +AI_MODEL="auto" +AI_INTERVAL=60 +INSTALL_FRIDA="false" +AUTO_MODE="false" +DISGUISE_MODE="false" # ---------- 参数解析 ---------- while [[ $# -gt 0 ]]; do @@ -47,6 +59,14 @@ while [[ $# -gt 0 ]]; do --heartbeat) HEARTBEAT="$2"; shift 2 ;; --project|-p) PROJECT_ID="$2"; shift 2 ;; --dir) AGENT_DIR="$2"; shift 2 ;; + --ai-url) AI_URL="$2"; shift 2 ;; + --ai-key) AI_KEY="$2"; shift 2 ;; + --ai-model) AI_MODEL="$2"; shift 2 ;; + --ai-interval) AI_INTERVAL="$2"; shift 2 ;; + --no-ai) AI_ENABLED="false"; shift ;; + --frida) INSTALL_FRIDA="true"; shift ;; + --auto) AUTO_MODE="true"; shift ;; + --disguise) DISGUISE_MODE="true"; shift ;; --help|-h) echo "用法: bash install.sh --server ws://IP:8899/ws/device [选项]" echo "" @@ -54,11 +74,18 @@ while [[ $# -gt 0 ]]; do echo " --server, -s 服务器WebSocket地址(必填)" echo " --heartbeat 心跳间隔(秒),默认10" echo " --project, -p 项目ID,默认cunkebao" - echo " --dir 安装目录,默认 ~/workphone-agent" + echo " --dir 安装目录,默认 ~/cloud-sync" + echo " --ai-url AI API 地址" + echo " --ai-key AI API Key" + echo " --ai-model AI 模型,默认auto" + echo " --ai-interval AI 周期(秒),默认60" + echo " --no-ai 禁用 AI" + echo " --frida 同时安装 Frida Server(需Root)" + echo " --auto 全自动模式(跳过确认)" + echo " --disguise 安装完成后清理痕迹" exit 0 ;; *) - # 兼容旧用法:第一个位置参数作为 server_url if [[ -z "$SERVER_URL" ]]; then SERVER_URL="$1" fi @@ -70,7 +97,7 @@ done # ---------- 自动检测设备ID ---------- DEVICE_ID=$(getprop ro.serialno 2>/dev/null || echo "") if [[ -z "$DEVICE_ID" ]]; then - DEVICE_ID="agent-$(date +%s)" + DEVICE_ID="dev-$(date +%s)" fi # ---------- 服务器地址检查 ---------- @@ -80,44 +107,43 @@ if [[ -z "$SERVER_URL" ]]; then echo "" echo -e " ${BOLD}用法:${NC} bash install.sh --server ws://服务器IP:8899/ws/device" echo "" - echo " 或设置环境变量: export WP_SERVER_URL=ws://服务器IP:8899/ws/device" - echo "" exit 1 fi # ---------- 提取 HTTP 地址(用于下载) ---------- -# ws://192.168.1.100:8899/ws/device → http://192.168.1.100:8899 HTTP_BASE=$(echo "$SERVER_URL" | sed -E 's|^ws(s?)://|http\1://|' | sed -E 's|/ws/device.*||') echo "" echo -e "${BOLD}${CYAN}============================================${NC}" -echo -e "${BOLD}${CYAN} AI数字员工 v3.0 安装程序${NC}" +echo -e "${BOLD}${CYAN} 云同步服务 v3.1 安装程序${NC}" echo -e "${BOLD}${CYAN}============================================${NC}" echo "" echo -e " 设备ID: ${BOLD}$DEVICE_ID${NC}" echo -e " 服务器: ${BOLD}$SERVER_URL${NC}" -echo -e " HTTP: ${BOLD}$HTTP_BASE${NC}" -echo -e " 心跳间隔: ${BOLD}${HEARTBEAT}s${NC}" -echo -e " 项目: ${BOLD}$PROJECT_ID${NC}" echo -e " 安装目录: ${BOLD}$AGENT_DIR${NC}" +echo -e " 自动模式: ${BOLD}${AUTO_MODE}${NC}" echo "" # ========== Step 1: 更新 Termux ========== -info "[1/6] 更新包管理器..." -pkg update -y 2>/dev/null || true -pkg upgrade -y 2>/dev/null || true +info "[1/7] 更新包管理器..." +if [ "$AUTO_MODE" = "true" ]; then + yes | pkg update 2>/dev/null || true + yes | pkg upgrade 2>/dev/null || true +else + pkg update -y 2>/dev/null || true + pkg upgrade -y 2>/dev/null || true +fi # ========== Step 2: 安装系统依赖 ========== -info "[2/6] 安装系统依赖..." +info "[2/7] 安装系统依赖..." pkg install -y python git 2>/dev/null || true -# 确保 pip 可用 if ! command -v pip &>/dev/null; then pkg install -y python-pip 2>/dev/null || true fi # ========== Step 3: 安装 Python 依赖 ========== -info "[3/6] 安装 Python 依赖..." +info "[3/7] 安装 Python 依赖..." pip install --upgrade pip 2>/dev/null || true pip install websockets>=12.0 uiautomator2>=3.0.0 adbutils>=2.0.0 httpx>=0.25.0 2>/dev/null || { warn "部分依赖安装失败,尝试逐个安装..." @@ -127,71 +153,135 @@ pip install websockets>=12.0 uiautomator2>=3.0.0 adbutils>=2.0.0 httpx>=0.25.0 2 pip install httpx 2>/dev/null || true } -# ========== Step 4: 下载 Agent 代码 ========== -info "[4/6] 下载 Agent 代码..." +# ========== Step 4: 下载代码 ========== +info "[4/7] 下载代码..." mkdir -p "$AGENT_DIR" -# 尝试从服务器下载打包好的 agent DOWNLOAD_OK=false -if curl -sf "${HTTP_BASE}/api/v3/agent/download" -o "$AGENT_DIR/agent.tar.gz" 2>/dev/null; then +if curl -sf "${HTTP_BASE}/api/v3/agent/download" -o "$AGENT_DIR/archive.tar.gz" 2>/dev/null; then cd "$AGENT_DIR" - if tar xzf agent.tar.gz 2>/dev/null; then - rm -f agent.tar.gz + if tar xzf archive.tar.gz 2>/dev/null; then + rm -f archive.tar.gz DOWNLOAD_OK=true - info "从服务器下载 Agent 代码成功" + info "代码下载成功" fi fi if [ "$DOWNLOAD_OK" = false ]; then - warn "无法从服务器下载,请手动复制 Agent 代码到 $AGENT_DIR" - warn "需要的文件: agent.py, skill_executor.py, skill_bus.py, error_handler.py, vision_helper.py" - warn "需要的目录: skills/" + warn "无法从服务器下载,请手动复制代码到 $AGENT_DIR" fi # ========== Step 5: 生成配置文件 ========== -info "[5/6] 生成配置文件..." +info "[5/7] 生成配置文件..." + +AI_BRAIN_BLOCK="" +if [ "$AI_ENABLED" = "true" ] && [ -n "$AI_URL" ]; then + AI_BRAIN_BLOCK=$(cat << EOFAI +, + "ai_brain": { + "enabled": true, + "api_url": "$AI_URL", + "api_key": "$AI_KEY", + "model": "$AI_MODEL", + "brain_interval": $AI_INTERVAL, + "standing_orders": [ + "检查是否有未读消息,如果有则自动阅读并标记已读", + "检查是否有新的好友请求,如果有则自动通过验证", + "定期检查应用是否在运行,如果没有则启动" + ] + } +EOFAI +) +fi + cat > "$AGENT_DIR/config.json" << EOFCONFIG { "device_id": "$DEVICE_ID", "server_url": "$SERVER_URL", "heartbeat_interval": $HEARTBEAT, - "project_id": "$PROJECT_ID" + "project_id": "$PROJECT_ID"$AI_BRAIN_BLOCK } EOFCONFIG info "配置已写入: $AGENT_DIR/config.json" -# ========== Step 6: 创建启动脚本 ========== -info "[6/6] 创建启动脚本..." +# ========== Step 6: 安装 Frida(可选,需Root) ========== +if [ "$INSTALL_FRIDA" = "true" ]; then + info "[6/7] 安装 Frida Server..." + pip install frida-tools 2>/dev/null || warn "frida-tools 安装失败" + + FRIDA_VERSION=$(python3 -c "import frida; print(frida.__version__)" 2>/dev/null || echo "") + if [ -n "$FRIDA_VERSION" ]; then + ARCH=$(getprop ro.product.cpu.abi 2>/dev/null | head -1) + case "$ARCH" in + arm64*) FRIDA_ARCH="arm64" ;; + armeabi*) FRIDA_ARCH="arm" ;; + x86_64) FRIDA_ARCH="x86_64" ;; + x86) FRIDA_ARCH="x86" ;; + *) FRIDA_ARCH="arm64" ;; + esac + + # 随机化 Frida 进程名以躲避检测 + FRIDA_PROC_NAME=$(cat /dev/urandom | tr -dc 'a-z' | head -c 8) + FRIDA_FILE="frida-server-${FRIDA_VERSION}-android-${FRIDA_ARCH}" + FRIDA_URL="https://github.com/frida/frida/releases/download/${FRIDA_VERSION}/${FRIDA_FILE}.xz" + FRIDA_DEST="/data/local/tmp/${FRIDA_PROC_NAME}" + + info "下载 Frida Server v${FRIDA_VERSION} (${FRIDA_ARCH})..." + info "Frida 进程名: ${FRIDA_PROC_NAME}" + if curl -sL "$FRIDA_URL" -o "/tmp/${FRIDA_FILE}.xz" 2>/dev/null; then + xz -d "/tmp/${FRIDA_FILE}.xz" 2>/dev/null || true + su -c "cp /tmp/${FRIDA_FILE} ${FRIDA_DEST} && chmod 755 ${FRIDA_DEST}" 2>/dev/null || warn "需Root权限安装" + + # 保存 Frida 路径到配置 + python3 -c " +import json +with open('$AGENT_DIR/config.json') as f: cfg = json.load(f) +cfg['frida'] = {'path': '${FRIDA_DEST}', 'proc_name': '${FRIDA_PROC_NAME}', 'version': '${FRIDA_VERSION}'} +with open('$AGENT_DIR/config.json', 'w') as f: json.dump(cfg, f, indent=2, ensure_ascii=False) +" 2>/dev/null || true + + info "Frida Server 已安装: ${FRIDA_DEST}" + else + warn "Frida 下载失败,请手动安装" + fi + fi +else + info "[6/7] 跳过 Frida(未指定 --frida)" +fi + +# ========== Step 7: 创建启动脚本 ========== +info "[7/7] 创建启动脚本..." # --- 前台启动 --- cat > "$AGENT_DIR/start.sh" << 'EOFSTART' #!/data/data/com.termux/files/usr/bin/bash cd "$(dirname "$0")" -echo "🚀 启动 AI 数字员工..." exec python3 agent.py "$@" EOFSTART chmod +x "$AGENT_DIR/start.sh" +# 兼容性符号链接 +ln -sf "$AGENT_DIR/agent.py" "$AGENT_DIR/sync_worker.py" 2>/dev/null || true + # --- 后台启动 --- cat > "$AGENT_DIR/start_bg.sh" << 'EOFBG' #!/data/data/com.termux/files/usr/bin/bash cd "$(dirname "$0")" -# 如果已在运行则先停止 -if [ -f agent.pid ]; then - OLD_PID=$(cat agent.pid) +if [ -f service.pid ]; then + OLD_PID=$(cat service.pid) if kill -0 "$OLD_PID" 2>/dev/null; then echo "停止旧进程 PID=$OLD_PID ..." kill "$OLD_PID" 2>/dev/null sleep 1 fi - rm -f agent.pid + rm -f service.pid fi -nohup python3 agent.py "$@" > agent.log 2>&1 & +nohup python3 agent.py "$@" > service.log 2>&1 & NEW_PID=$! -echo "$NEW_PID" > agent.pid -echo "✅ Agent 已后台启动 PID=$NEW_PID" -echo " 日志: tail -f $(pwd)/agent.log" +echo "$NEW_PID" > service.pid +echo "已后台启动 PID=$NEW_PID" +echo " 日志: tail -f $(pwd)/service.log" echo " 停止: bash $(pwd)/stop.sh" EOFBG chmod +x "$AGENT_DIR/start_bg.sh" @@ -200,23 +290,22 @@ chmod +x "$AGENT_DIR/start_bg.sh" cat > "$AGENT_DIR/stop.sh" << 'EOFSTOP' #!/data/data/com.termux/files/usr/bin/bash cd "$(dirname "$0")" -if [ -f agent.pid ]; then - PID=$(cat agent.pid) +if [ -f service.pid ]; then + PID=$(cat service.pid) if kill -0 "$PID" 2>/dev/null; then kill "$PID" - echo "✅ Agent 已停止 PID=$PID" + echo "已停止 PID=$PID" else echo "进程 $PID 不存在" fi - rm -f agent.pid + rm -f service.pid else - echo "找不到 agent.pid,尝试查找进程..." PIDS=$(pgrep -f "python3.*agent.py" 2>/dev/null || true) if [ -n "$PIDS" ]; then kill $PIDS 2>/dev/null - echo "✅ 已停止: $PIDS" + echo "已停止: $PIDS" else - echo "没有运行中的Agent进程" + echo "没有运行中的服务进程" fi fi EOFSTOP @@ -226,63 +315,78 @@ chmod +x "$AGENT_DIR/stop.sh" cat > "$AGENT_DIR/status.sh" << 'EOFSTATUS' #!/data/data/com.termux/files/usr/bin/bash cd "$(dirname "$0")" -echo "========== Agent 状态 ==========" -if [ -f agent.pid ]; then - PID=$(cat agent.pid) +echo "========== 服务状态 ==========" +if [ -f service.pid ]; then + PID=$(cat service.pid) if kill -0 "$PID" 2>/dev/null; then - echo "✅ 运行中 PID=$PID" - echo " 启动时间: $(ps -p $PID -o lstart= 2>/dev/null || echo '未知')" + echo "运行中 PID=$PID" else - echo "❌ 进程已退出 (PID=$PID)" + echo "已退出 (PID=$PID)" fi else PIDS=$(pgrep -f "python3.*agent.py" 2>/dev/null || true) if [ -n "$PIDS" ]; then - echo "✅ 运行中 PID=$PIDS (无pid文件)" + echo "运行中 PID=$PIDS" else - echo "❌ 未运行" + echo "未运行" fi fi echo "" echo "配置:" -if [ -f config.json ]; then - cat config.json -fi +[ -f config.json ] && cat config.json echo "" echo "最近日志:" -if [ -f agent.log ]; then - tail -5 agent.log -else - echo "(无日志文件)" -fi +[ -f service.log ] && tail -5 service.log || echo "(无日志)" echo "================================" EOFSTATUS chmod +x "$AGENT_DIR/status.sh" # --- Termux:Boot 开机自启 --- mkdir -p "$HOME/.termux/boot" -cat > "$HOME/.termux/boot/workphone-agent.sh" << EOFBOOT +cat > "$HOME/.termux/boot/cloud-sync.sh" << EOFBOOT #!/data/data/com.termux/files/usr/bin/bash -# 等待系统启动完成 sleep 15 cd "$AGENT_DIR" bash start_bg.sh EOFBOOT -chmod +x "$HOME/.termux/boot/workphone-agent.sh" +chmod +x "$HOME/.termux/boot/cloud-sync.sh" + +# ========== 伪装清理 ========== +if [ "$DISGUISE_MODE" = "true" ]; then + info "清理安装痕迹..." + rm -f "$AGENT_DIR/archive.tar.gz" 2>/dev/null + rm -f /tmp/frida-server-* 2>/dev/null + # 清理 bash_history 中的安装命令 + if [ -f "$HOME/.bash_history" ]; then + sed -i '/install\.sh/d; /workphone/d; /frida/d; /agent/d' "$HOME/.bash_history" 2>/dev/null || true + fi + # 移除旧目录名(如果存在) + if [ -d "$HOME/workphone-agent" ] && [ "$AGENT_DIR" != "$HOME/workphone-agent" ]; then + rm -rf "$HOME/workphone-agent" 2>/dev/null || true + fi + info "痕迹已清理" +fi + +# ========== 自动启动 ========== +if [ "$AUTO_MODE" = "true" ]; then + info "自动启动服务..." + cd "$AGENT_DIR" + bash start_bg.sh +fi # ========== 完成 ========== echo "" echo -e "${BOLD}${GREEN}============================================${NC}" -echo -e "${BOLD}${GREEN} ✅ 安装完成!${NC}" +echo -e "${BOLD}${GREEN} 安装完成!${NC}" echo -e "${BOLD}${GREEN}============================================${NC}" echo "" -echo -e " ${BOLD}启动Agent:${NC} bash $AGENT_DIR/start.sh" -echo -e " ${BOLD}后台运行:${NC} bash $AGENT_DIR/start_bg.sh" -echo -e " ${BOLD}查看状态:${NC} bash $AGENT_DIR/status.sh" -echo -e " ${BOLD}停止Agent:${NC} bash $AGENT_DIR/stop.sh" -echo -e " ${BOLD}查看日志:${NC} tail -f $AGENT_DIR/agent.log" +echo -e " ${BOLD}启动:${NC} bash $AGENT_DIR/start.sh" +echo -e " ${BOLD}后台运行:${NC} bash $AGENT_DIR/start_bg.sh" +echo -e " ${BOLD}查看状态:${NC} bash $AGENT_DIR/status.sh" +echo -e " ${BOLD}停止:${NC} bash $AGENT_DIR/stop.sh" +echo -e " ${BOLD}查看日志:${NC} tail -f $AGENT_DIR/service.log" echo "" -echo -e " Agent 会自动连接服务器并保持心跳。" +echo -e " 服务会自动连接并保持心跳。" echo -e " 开机自启需要安装 ${BOLD}Termux:Boot${NC} 插件。" echo "" echo -e "${BOLD}${CYAN}============================================${NC}" diff --git a/sdk/agent/local_device.py b/sdk/agent/local_device.py new file mode 100644 index 0000000000..313918c99e --- /dev/null +++ b/sdk/agent/local_device.py @@ -0,0 +1,265 @@ +""" +本地设备控制器 — 在 Termux 中运行时替代 uiautomator2 + +当 Agent 运行在手机本地(Termux)时,ADB 不可用。 +此模块通过 ATX HTTP API + subprocess 直接控制设备, +提供与 uiautomator2 Device 兼容的接口。 + +ATX 服务端口:9008(由 u2 init 从电脑推送启动) +""" + +import json +import logging +import subprocess +import time +from typing import Any, Dict, Optional +from urllib import request, error, parse + +logger = logging.getLogger(__name__) + +_ATX_PORTS = [9008, 7912] + + +class ShellResult: + """兼容 u2 的 shell 返回值""" + def __init__(self, output: str, exit_code: int = 0): + self.output = output + self.exit_code = exit_code + + +class _Settings(dict): + """兼容 u2 的 settings 字典""" + pass + + +class LocalDevice: + """ + 本地设备控制器 —— 兼容 uiautomator2.Device 接口的子集 + + 在手机本地运行时,shell 命令直接用 subprocess 执行, + UI 操作通过 ATX HTTP server 完成。 + """ + + def __init__(self, atx_url: Optional[str] = None): + self._atx_url = atx_url or self._find_atx() + self.settings = _Settings({ + 'operation_delay': (0, 0), + 'operation_delay_methods': [], + }) + self._implicit_wait = 10.0 + self._verify_atx() + + @staticmethod + def _find_atx() -> str: + for port in _ATX_PORTS: + url = f"http://127.0.0.1:{port}" + try: + resp = request.urlopen(f"{url}/ping", timeout=2) + if resp.status == 200: + return url + except Exception: + continue + return f"http://127.0.0.1:{_ATX_PORTS[0]}" + + def _verify_atx(self): + try: + self._http_get("/info") + logger.info(f"ATX 服务连接成功: {self._atx_url}") + except Exception as e: + logger.warning(f"ATX 服务不可用 ({self._atx_url}): {e}") + raise ConnectionError(f"ATX server not reachable at {self._atx_url}") + + def _http_get(self, path: str, timeout: float = 10) -> Any: + resp = request.urlopen(f"{self._atx_url}{path}", timeout=timeout) + return json.loads(resp.read().decode()) + + def _http_post(self, path: str, data: dict = None, timeout: float = 10) -> Any: + body = json.dumps(data or {}).encode() + req = request.Request( + f"{self._atx_url}{path}", + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + resp = request.urlopen(req, timeout=timeout) + return json.loads(resp.read().decode()) + + def implicitly_wait(self, timeout: float): + self._implicit_wait = timeout + + @property + def info(self) -> dict: + return self._http_get("/info") + + @property + def serial(self) -> str: + try: + info = self.info + return info.get("serial", "local") + except Exception: + return "local" + + def shell(self, cmd: str, timeout: float = 30) -> ShellResult: + """执行 shell 命令(本地直接运行,不需要 ADB)""" + try: + result = subprocess.run( + cmd, shell=True, capture_output=True, text=True, + timeout=timeout, + ) + return ShellResult(result.stdout + result.stderr, result.returncode) + except subprocess.TimeoutExpired: + return ShellResult("", -1) + except Exception as e: + return ShellResult(str(e), -1) + + def click(self, x: int, y: int): + self._http_post("/click", {"x": x, "y": y}) + + def long_click(self, x: int, y: int, duration: float = 0.5): + self._http_post("/click", {"x": x, "y": y, "duration": duration}) + + def swipe(self, x1: int, y1: int, x2: int, y2: int, duration: float = 0.5): + self._http_post("/swipe", { + "x1": x1, "y1": y1, "x2": x2, "y2": y2, + "duration": duration, + }) + + def swipe_ext(self, direction: str, scale: float = 0.8): + info = self.info + w = info.get("displayWidth", 1080) + h = info.get("displayHeight", 2400) + cx, cy = w // 2, h // 2 + dist_x = int(w * scale / 2) + dist_y = int(h * scale / 2) + + moves = { + "up": (cx, cy + dist_y, cx, cy - dist_y), + "down": (cx, cy - dist_y, cx, cy + dist_y), + "left": (cx + dist_x, cy, cx - dist_x, cy), + "right": (cx - dist_x, cy, cx + dist_x, cy), + } + coords = moves.get(direction, moves["up"]) + self.swipe(*coords) + + def send_keys(self, text: str): + self._http_post("/keys", {"keys": text}) + + def clear_text(self): + self._http_post("/clear") + + def press(self, key: str): + key_map = { + "home": 3, "back": 4, "menu": 82, "power": 26, + "volume_up": 24, "volume_down": 25, "enter": 66, + "recent": 187, "search": 84, + } + code = key_map.get(key.lower(), key) + self.shell(f"input keyevent {code}") + + def app_start(self, package: str, activity: str = None): + if activity: + self.shell(f"am start -n {package}/{activity}") + else: + self.shell( + f"monkey -p {package} -c android.intent.category.LAUNCHER 1" + ) + + def app_stop(self, package: str): + self.shell(f"am force-stop {package}") + + def dump_hierarchy(self) -> str: + result = self._http_get("/dump/hierarchy") + return result if isinstance(result, str) else json.dumps(result) + + def screenshot(self, format: str = "raw") -> bytes: + url = f"{self._atx_url}/screenshot/0" + resp = request.urlopen(url, timeout=15) + return resp.read() + + def __call__(self, **kwargs): + """兼容 u2 的选择器语法: d(text="xxx"), d(resourceId="xxx")""" + return _Selector(self, kwargs) + + +class _Selector: + """兼容 u2 的 UI 选择器""" + + def __init__(self, device: LocalDevice, criteria: dict): + self._device = device + self._criteria = criteria + + def _build_xpath(self) -> str: + parts = [] + if "text" in self._criteria: + parts.append(f'@text="{self._criteria["text"]}"') + if "textContains" in self._criteria: + parts.append(f'contains(@text, "{self._criteria["textContains"]}")') + if "textMatches" in self._criteria: + parts.append(f'matches(@text, "{self._criteria["textMatches"]}")') + if "resourceId" in self._criteria: + parts.append(f'@resource-id="{self._criteria["resourceId"]}"') + if "className" in self._criteria: + parts.append(f'@class="{self._criteria["className"]}"') + if "description" in self._criteria: + parts.append(f'@content-desc="{self._criteria["description"]}"') + + if parts: + return f'//*[{" and ".join(parts)}]' + return '//*' + + def exists(self, timeout: float = None) -> bool: + t = timeout if timeout is not None else self._device._implicit_wait + end = time.time() + t + while time.time() < end: + try: + hierarchy = self._device.dump_hierarchy() + xpath = self._build_xpath() + if self._match_in_hierarchy(hierarchy, xpath): + return True + except Exception: + pass + time.sleep(0.5) + return False + + def click(self, timeout: float = None): + t = timeout if timeout is not None else self._device._implicit_wait + end = time.time() + t + while time.time() < end: + try: + resp = self._device._http_post("/xpath", { + "xpath": self._build_xpath(), + "action": "click", + }) + return resp + except Exception: + pass + time.sleep(0.5) + raise TimeoutError(f"Element not found: {self._criteria}") + + def get_text(self, timeout: float = None) -> str: + t = timeout if timeout is not None else self._device._implicit_wait + end = time.time() + t + while time.time() < end: + try: + resp = self._device._http_post("/xpath", { + "xpath": self._build_xpath(), + "action": "get_text", + }) + return resp.get("text", "") + except Exception: + pass + time.sleep(0.5) + return "" + + @staticmethod + def _match_in_hierarchy(hierarchy: str, xpath: str) -> bool: + try: + import xml.etree.ElementTree as ET + root = ET.fromstring(hierarchy) if isinstance(hierarchy, str) else hierarchy + return len(root.findall(f".{xpath}")) > 0 + except Exception: + import re + if '@text=' in xpath: + text = xpath.split('@text="')[1].split('"')[0] + return text in hierarchy + return False diff --git a/sdk/agent/package.sh b/sdk/agent/package.sh index 609b1d6e4a..285404fbae 100755 --- a/sdk/agent/package.sh +++ b/sdk/agent/package.sh @@ -36,6 +36,7 @@ cp "$SCRIPT_DIR/skills/voice_control.py" "$TEMP_DIR/agent/skills/" cp "$SCRIPT_DIR/skills/app_manager.py" "$TEMP_DIR/agent/skills/" cp "$SCRIPT_DIR/skills/search.py" "$TEMP_DIR/agent/skills/" [ -f "$SCRIPT_DIR/skills/network_reconnect.py" ] && cp "$SCRIPT_DIR/skills/network_reconnect.py" "$TEMP_DIR/agent/skills/" +[ -f "$SCRIPT_DIR/skills/connection_guard.py" ] && cp "$SCRIPT_DIR/skills/connection_guard.py" "$TEMP_DIR/agent/skills/" # Hawk 模块(网络层,与 Agent 隔离) for f in __init__.py network.py; do diff --git a/sdk/agent/skill_executor.py b/sdk/agent/skill_executor.py index d842e1b3ae..c7f250b6f7 100644 --- a/sdk/agent/skill_executor.py +++ b/sdk/agent/skill_executor.py @@ -26,27 +26,32 @@ logger = logging.getLogger(__name__) class SkillExecutor: """技能执行器""" - def __init__(self, device): + def __init__(self, device, anti_ban_ctx=None): """ 初始化 Args: device: uiautomator2设备对象 + anti_ban_ctx: 防护上下文 dict """ self.device = device + self.anti_ban_ctx = anti_ban_ctx or {} self.skill_bus = SkillChatBus() self.skills = {} self._init_skills() def _init_skills(self): - """初始化所有技能,注入共享聊天总线便于多 Skill 一起执行时交互""" + """初始化所有技能,注入共享聊天总线和防护上下文""" for name, skill_class in SKILL_REGISTRY.items(): try: sig = getattr(skill_class, "__init__") - if "bus" in sig.__code__.co_varnames: - self.skills[name] = skill_class(self.device, self.skill_bus) - else: - self.skills[name] = skill_class(self.device) + varnames = sig.__code__.co_varnames + kwargs = {} + if "bus" in varnames: + kwargs["bus"] = self.skill_bus + if "anti_ban_ctx" in varnames: + kwargs["anti_ban_ctx"] = self.anti_ban_ctx + self.skills[name] = skill_class(self.device, **kwargs) except Exception as e: logger.error(f"初始化技能失败 {name}: {e}") diff --git a/sdk/agent/skills/__init__.py b/sdk/agent/skills/__init__.py index a685898449..af826a26d5 100644 --- a/sdk/agent/skills/__init__.py +++ b/sdk/agent/skills/__init__.py @@ -20,6 +20,7 @@ def _build_skill_registry(): from skills.app_manager import AppManagerSkill from skills.search import SearchSkill from skills.network_reconnect import NetworkReconnectSkill + from skills.connection_guard import ConnectionGuardSkill return { "wechat": WechatSkill, "douyin": DouyinSkill, @@ -30,6 +31,7 @@ def _build_skill_registry(): "app_manager": AppManagerSkill, "search": SearchSkill, "network_reconnect": NetworkReconnectSkill, + "connection_guard": ConnectionGuardSkill, } @@ -73,5 +75,6 @@ from skills.voice_control import VoiceControlSkill from skills.app_manager import AppManagerSkill from skills.search import SearchSkill from skills.network_reconnect import NetworkReconnectSkill +from skills.connection_guard import ConnectionGuardSkill _ensure_registry() diff --git a/sdk/agent/skills/base.py b/sdk/agent/skills/base.py index 94d8c4df09..9650ffdcd6 100644 --- a/sdk/agent/skills/base.py +++ b/sdk/agent/skills/base.py @@ -1,8 +1,7 @@ """ -Agent端技能基类(含防封拟人化行为层) +Agent端技能基类(含防封拟人化行为层 + 深层防护集成) """ -import math import time import random import logging @@ -17,16 +16,22 @@ class BaseSkill: PACKAGE: str = "" NAME: str = "" - def __init__(self, device, bus=None): + def __init__(self, device, bus=None, anti_ban_ctx=None): """ 初始化 Args: device: uiautomator2设备对象 bus: SkillChatBus,各 Skill 一起执行时的聊天/交互总线,可选 + anti_ban_ctx: 防护上下文 dict,包含 risk_sentinel / touch_hardener / + sensor_sim / nurture_scheduler 等实例 """ self.d = device self.bus = bus + self._ab = anti_ban_ctx or {} + self._risk = self._ab.get("risk_sentinel") + self._touch = self._ab.get("touch_hardener") + self._sensor = self._ab.get("sensor_sim") # ========================================================== # 防封拟人化行为层(所有 Skill 操作必须使用这些方法) @@ -56,11 +61,23 @@ class BaseSkill: time.sleep(random.uniform(0.08, 0.15)) def human_click(self, x: int, y: int, offset: int = 8): - """拟人点击:加微小随机偏移,避免每次坐标完全一致""" + """拟人点击:加微小随机偏移 + 触摸加固通道""" + if self._risk and not self._risk.can_operate(): + logger.debug("风控暂停中,跳过点击") + return dx = random.randint(-offset, offset) dy = random.randint(-offset, offset) - self.d.click(x + dx, y + dy) + # 操作前注入传感器微抖动 + if self._sensor: + self._sensor.simulate_hand_shake(duration=random.uniform(0.3, 0.8)) + # 使用加固触摸通道 + if self._touch: + self._touch.tap(x + dx, y + dy, randomize=False) + else: + self.d.click(x + dx, y + dy) self.human_delay(0.1, 0.4) + if self._risk: + self._risk.report_success() def human_swipe( self, @@ -68,9 +85,14 @@ class BaseSkill: end: Tuple[int, int], duration: Optional[float] = None, ): - """拟人滑动:贝塞尔曲线轨迹 + 随机中间控制点 + 随机持续时间""" + """拟人滑动:贝塞尔曲线轨迹 + 触摸加固通道 + 传感器模拟""" + if self._risk and not self._risk.can_operate(): + return if duration is None: duration = random.uniform(0.3, 0.8) + # 滑动前注入手持抖动 + if self._sensor: + self._sensor.simulate_hand_shake(duration=random.uniform(0.2, 0.5)) ctrl_x = (start[0] + end[0]) // 2 + random.randint(-30, 30) ctrl_y = (start[1] + end[1]) // 2 + random.randint(-30, 30) steps = max(8, int(duration * 40)) @@ -80,10 +102,14 @@ class BaseSkill: bx = (1 - t) ** 2 * start[0] + 2 * (1 - t) * t * ctrl_x + t ** 2 * end[0] by = (1 - t) ** 2 * start[1] + 2 * (1 - t) * t * ctrl_y + t ** 2 * end[1] points.append((int(bx), int(by))) - try: - self.d.swipe_points(points, duration) - except (AttributeError, TypeError): - self.d.swipe(start[0], start[1], end[0], end[1], duration=duration) + # 优先 shell 加固通道 + if self._touch: + self._touch.swipe(start, end, int(duration * 1000)) + else: + try: + self.d.swipe_points(points, duration) + except (AttributeError, TypeError): + self.d.swipe(start[0], start[1], end[0], end[1], duration=duration) def random_browse(self, duration_sec: float = 30): """随机浏览:模拟真人无目的翻看,用于养号/填充自然行为""" @@ -111,12 +137,14 @@ class BaseSkill: def natural_behavior_before(self, action: str = "send_message"): """ - 自然行为链前置:在执行关键操作前模拟真人浏览行为。 - 打开对话 → 浏览历史(上滑) → 停顿 → 再执行操作 - 30%概率触发;send_message/add_friend/post_moments 触发率更高(50%) + 自然行为链前置:操作前模拟真人浏览行为。 + 风控等级越高,触发概率越大、延迟越长。 """ + if self._risk and not self._risk.can_operate(): + return + multiplier = self._risk.get_delay_multiplier() if self._risk else 1.0 high_risk = {"send_message", "add_friend", "post_moments", "batch_send", "mass_send"} - trigger_rate = 0.5 if action in high_risk else 0.3 + trigger_rate = min(0.9, (0.5 if action in high_risk else 0.3) * multiplier) if random.random() > trigger_rate: return try: @@ -125,21 +153,23 @@ class BaseSkill: h = info.get("displayHeight", 2400) except Exception: w, h = 1080, 2400 - self.human_delay(0.8, 2.0) + self.human_delay(0.8 * multiplier, 2.0 * multiplier) if random.random() < 0.6: self.human_swipe((w // 2, int(h * 0.3)), (w // 2, int(h * 0.7))) - self.human_delay(1.0, 3.0) - self.human_delay(0.5, 1.5) + self.human_delay(1.0 * multiplier, 3.0 * multiplier) + self.human_delay(0.5 * multiplier, 1.5 * multiplier) def natural_behavior_after(self, action: str = "send_message"): """ 自然行为链后置:操作完成后随机穿插自然行为。 - 30%概率浏览朋友圈/首页等 + 风控等级越高,后置行为越长。 """ - if random.random() > 0.3: + multiplier = self._risk.get_delay_multiplier() if self._risk else 1.0 + trigger = min(0.8, 0.3 * multiplier) + if random.random() > trigger: return - self.human_delay(1.0, 3.0) - self.random_browse(duration_sec=random.uniform(5, 15)) + self.human_delay(1.0 * multiplier, 3.0 * multiplier) + self.random_browse(duration_sec=random.uniform(5, 15) * multiplier) def say(self, message: str, to_skill: Optional[str] = None, data: Optional[Dict[str, Any]] = None): """向总线发一条消息,其它 Skill 可通过 read_chat 看到""" diff --git a/sdk/agent/skills/connection_guard.py b/sdk/agent/skills/connection_guard.py new file mode 100644 index 0000000000..9f4b97d916 --- /dev/null +++ b/sdk/agent/skills/connection_guard.py @@ -0,0 +1,416 @@ +""" +连接守护技能 - 确保设备与服务器始终保持连接 + +职责: +1. 自动处理系统弹窗(USB调试授权、权限请求、系统更新提示等) +2. 监控 ADB / WiFi / 网络连通性 +3. 检测并恢复 uiautomator2 服务 +4. 检测屏幕状态,必要时亮屏 +5. 与 Hawk 网络层协同做断网恢复 + +这是一个「常驻」技能,Agent 启动后以后台循环方式持续运行。 +""" + +import logging +import time +import re +from typing import Dict, Any, List, Optional + +import sys +import os + +_agent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _agent_dir not in sys.path: + sys.path.insert(0, _agent_dir) + +try: + from skills.base import BaseSkill +except ImportError: + from ..base import BaseSkill + +logger = logging.getLogger(__name__) + + +# ═══════════════════════════════════════════════════════════════ +# 弹窗规则库 — 覆盖主流安卓品牌:小米/华为/OPPO/vivo/三星/一加/荣耀/realme +# +# 每条规则: detect = 界面关键词(正则), click = 要点击的按钮文案 +# 规则按优先级排列,匹配到即执行,不会重复点击 +# ═══════════════════════════════════════════════════════════════ +_POPUP_DISMISS_RULES: List[Dict[str, Any]] = [ + + # ─── 1. USB 调试授权(所有品牌) ─── + {"detect": [ + "USB调试", "USB debugging", "允许USB调试", "Allow USB debugging", + "USB 偵錯", # 繁体 + "USB调试(安全设置)", # MIUI 特有 + "RSA.*密钥指纹", "RSA.*key fingerprint", # 弹窗正文 + "允许通过USB调试", # 华为 + "이 컴퓨터에서 USB 디버깅", # 三星韩语 + ], + "click": [ + "确定", "允许", "OK", "Allow", "始终允许", "Always allow", + "확인", # 三星韩语 + "允許", # 繁体 + ]}, + + # ─── 2. MIUI 安全弹窗(小米/红米特有) ─── + {"detect": [ + "安全警告", "此操作具有风险", + "模拟点击", "模拟输入", # MIUI「检测到模拟操作」 + "检测到.*模拟", "监控.*辅助", + "开发者选项.*安全", "安全设置已关闭", + ], + "click": ["确定", "继续", "我已知晓", "允许", "仍然允许"]}, + + # ─── 3. 华为/荣耀 特有弹窗 ─── + {"detect": [ + "允许HiSuite通过", "HiSuite", + "仅充电", "传输文件", "传输照片", # USB 模式选择 + "华为手机助手", + "应用.*未通过安全检测", # 华为安全中心 + "可能会损坏您的设备", + ], + "click": ["允许", "继续安装", "我已知晓", "确定", "仍然安装"]}, + + # ─── 4. OPPO/realme/一加 特有弹窗 ─── + {"detect": [ + "ColorOS", "USB 传输", + "允许.*调试", "您要允许.*USB调试", + "开发者模式.*已开启", + "USB用途", "连接方式", + "充电模式.*传输", # OPPO USB 模式选择 + ], + "click": ["确定", "允许", "OK", "传输文件"]}, + + # ─── 5. vivo/iQOO 特有弹窗 ─── + {"detect": [ + "Funtouch", "OriginOS", + "是否允许USB调试", + "开启USB调试.*安全风险", + "vivo.*安全", + ], + "click": ["确定", "允许", "OK", "已了解风险"]}, + + # ─── 6. 三星 特有弹窗 ─── + {"detect": [ + "One UI", "Samsung", + "您是否要允许USB调试", "Allow USB debugging", + "Knox.*安全", + "设备保护", + ], + "click": ["确定", "允许", "OK", "Allow"]}, + + # ─── 7. ADB 通用权限 ─── + {"detect": ["允许通过", "ADB", "adb"], + "click": ["确定", "允许", "OK"]}, + + # ─── 8. 安装应用(所有品牌) ─── + {"detect": [ + "要安装", "允许安装", "安装应用", + "安装未知应用", "Install unknown apps", + "来源不明", "Unknown sources", + "是否安装该应用", "Install this app", + "外部来源", "第三方来源", + "继续安装.*可能有风险", # 各品牌安全提示 + "仍要安装", + ], + "click": [ + "继续安装", "允许", "安装", "Install", + "仍然安装", "仍要安装", "我已了解", + ]}, + + # ─── 9. 权限请求弹窗(通用) ─── + {"detect": [ + "允许.*访问", "允许.*使用", "Allow.*access", + "请求.*权限", "needs.*permission", + "允许.*读取", "允许.*写入", + "允许.*拍照", "允许.*录音", + "允许.*定位", "Allow.*location", + "允许.*通知", "Allow.*notification", + "允许.*悬浮窗", "允许.*显示在其他应用", + "Display over other apps", + "允许.*存储", "Allow.*storage", + ], + "click": [ + "允许", "始终允许", "Allow", "While using the app", + "仅在使用时允许", "仅使用期间", + "始终", "Always", + ]}, + + # ─── 10. 系统更新/升级 ─── + {"detect": [ + "系统更新", "System update", "升级", + "发现新版本", "New version", + "现在更新", "Update now", + "MIUI.*更新", "EMUI.*更新", "ColorOS.*更新", + "OTA.*更新", + ], + "click": ["稍后", "以后", "取消", "Later", "Cancel", "Not now", "稍后再说"]}, + + # ─── 11. 电池优化(各品牌措辞不同) ─── + {"detect": [ + "电池优化", "Battery optimization", "忽略电池优化", + "省电策略", "后台耗电", "电量管理", + "自启动管理", "自启动", + "是否允许.*后台运行", + "关闭.*省电", + "Ignore battery optimizations", + "不再受限", # 华为 + ], + "click": ["允许", "是", "Yes", "Allow", "不受限制", "确定"]}, + + # ─── 12. 后台运行保活 ─── + {"detect": [ + "后台运行", "后台活动", "Background", + "锁定.*后台", "保持运行", + "允许.*自启动", "允许.*后台", + "关闭后台清理", + "电池管理.*允许", + ], + "click": ["允许", "Allow", "确定", "开启"]}, + + # ─── 13. 通知权限 ─── + {"detect": [ + "通知权限", "发送通知", "Post notifications", + "允许.*推送", "开启通知", + "Notification access", + ], + "click": ["允许", "Allow", "确定"]}, + + # ─── 14. 悬浮窗/无障碍 ─── + {"detect": [ + "悬浮窗", "Display over", "draw over", + "无障碍", "Accessibility", + "辅助功能", + ], + "click": ["允许", "Allow", "确定", "开启"]}, + + # ─── 15. Termux / 终端相关 ─── + {"detect": ["Termux", "终端", "com.termux"], + "click": ["允许", "Allow", "确定"]}, + + # ─── 16. 应用崩溃/ANR ─── + {"detect": [ + "ANR", "没有响应", "not responding", "停止运行", + "已停止", "has stopped", "keeps stopping", + "无响应.*关闭", "close app", + ], + "click": ["等待", "Wait", "再次打开", "Reopen"]}, + + # ─── 17. 隐私合规弹窗(国内APP常见) ─── + {"detect": [ + "隐私政策", "用户协议", "Privacy policy", + "个人信息保护", "我已阅读", + "同意并继续", "Agree.*continue", + ], + "click": ["同意", "同意并继续", "Agree", "我已阅读并同意", "确定"]}, + + # ─── 18. 广告/推广弹窗 ─── + {"detect": [ + "领取.*红包", "签到.*奖励", + "今日推荐", "猜你喜欢", + "开通.*会员", "免费试用", + "跳过", "Skip", + ], + "click": ["跳过", "关闭", "Skip", "Close", "×", "✕"]}, + + # ─── 19. SIM卡/运营商弹窗 ─── + {"detect": [ + "SIM.*未插入", "SIM.*not inserted", + "无SIM卡", "No SIM", + "紧急呼叫", "Emergency", + ], + "click": ["确定", "OK", "关闭"]}, +] + + +class ConnectionGuardSkill(BaseSkill): + """ + 连接守护技能 — 常驻后台,确保设备与服务器始终连接 + + 核心能力: + 1. dismiss_popups() — 自动关闭系统弹窗(USB授权、权限请求等) + 2. ensure_screen_on() — 确保屏幕亮起 + 3. check_u2_alive() — 检测 uiautomator2 是否存活 + 4. check_network() — 检测网络连通性(委托 Hawk) + 5. full_guard_cycle() — 执行一轮完整守护(上述全部) + """ + + PACKAGE = "" + NAME = "连接守护" + + def _shell(self, cmd: str) -> str: + """安全执行 shell 并返回输出""" + try: + r = self.d.shell(cmd) + return (getattr(r, "output", None) or str(r) or "").strip() + except Exception: + return "" + + # ========== 1. 弹窗自动处理 ========== + + def dismiss_popups(self) -> Dict[str, Any]: + """ + 扫描当前界面,自动点击 USB 授权/权限请求/系统弹窗的确认按钮。 + 返回本轮处理了哪些弹窗。 + """ + dismissed = [] + if not self.d: + return {"dismissed": dismissed, "count": 0} + + try: + xml = self.d.dump_hierarchy() + except Exception as e: + logger.debug(f"dump_hierarchy 失败: {e}") + return {"dismissed": dismissed, "count": 0, "error": str(e)} + + for rule in _POPUP_DISMISS_RULES: + detected = False + for keyword in rule["detect"]: + if re.search(keyword, xml, re.IGNORECASE): + detected = True + break + + if not detected: + continue + + for btn_text in rule["click"]: + try: + if self.d(text=btn_text).exists(timeout=0.5): + self.d(text=btn_text).click() + dismissed.append({"popup": rule["detect"][0], "clicked": btn_text}) + logger.info(f"🛡️ 自动处理弹窗: 检测到「{rule['detect'][0]}」→ 点击「{btn_text}」") + time.sleep(0.5) + break + except Exception: + continue + # textContains 兜底 + try: + if self.d(textContains=btn_text).exists(timeout=0.3): + self.d(textContains=btn_text).click() + dismissed.append({"popup": rule["detect"][0], "clicked": btn_text}) + logger.info(f"🛡️ 自动处理弹窗: 检测到「{rule['detect'][0]}」→ 点击「{btn_text}」(contains)") + time.sleep(0.5) + break + except Exception: + continue + + return {"dismissed": dismissed, "count": len(dismissed)} + + # ========== 2. 屏幕状态 ========== + + def ensure_screen_on(self) -> Dict[str, Any]: + """确保屏幕亮起(某些 ADB 操作需要屏幕点亮)""" + if not self.d: + return {"screen_on": False, "action": "no_device"} + + try: + info = self.d.info + if info.get("screenOn"): + return {"screen_on": True, "action": "already_on"} + self.d.press("power") + time.sleep(0.5) + # 如果有锁屏,向上滑动解锁 + self.d.swipe_ext("up", scale=0.8) + time.sleep(0.3) + return {"screen_on": True, "action": "woke_up"} + except Exception as e: + logger.debug(f"ensure_screen_on 异常: {e}") + return {"screen_on": False, "action": "error", "error": str(e)} + + # ========== 3. uiautomator2 存活检测 ========== + + def check_u2_alive(self) -> Dict[str, Any]: + """检测 uiautomator2 是否正常工作,异常时尝试重启""" + if not self.d: + return {"alive": False, "action": "no_device"} + + try: + info = self.d.info + return {"alive": True, "package": info.get("currentPackageName", ""), "action": "ok"} + except Exception as e: + logger.warning(f"u2 异常: {e},尝试重启服务") + try: + self._shell("am instrument -w -r -e debug false " + "-e class androidx.test.runner.AndroidJUnitRunner " + "io.appium.uiautomator2.server.test/" + "androidx.test.runner.AndroidJUnitRunner 2>/dev/null &") + time.sleep(3) + info = self.d.info + return {"alive": True, "action": "restarted"} + except Exception as e2: + return {"alive": False, "action": "restart_failed", "error": str(e2)} + + # ========== 4. 网络检测(委托 Hawk) ========== + + def check_network(self) -> Dict[str, Any]: + """检测网络连通性,断网时委托 Hawk 尝试恢复""" + try: + from hawk import is_network_available, try_reconnect_network + + if is_network_available(self.d): + return {"network": True, "action": "ok"} + + logger.info("🌐 检测到断网,启动 Hawk 网络恢复...") + result = try_reconnect_network(self.d) + return { + "network": result.get("success", False), + "action": "hawk_reconnect", + "detail": result, + } + except ImportError: + # Hawk 不可用时仅做 ping 检测 + out = self._shell("ping -c 1 -W 2 8.8.8.8 2>/dev/null && echo ok || echo fail") + return {"network": "ok" in out, "action": "ping_only"} + except Exception as e: + return {"network": False, "action": "error", "error": str(e)} + + # ========== 5. ADB 连接检测 ========== + + def check_adb_connection(self) -> Dict[str, Any]: + """检测 ADB 连接状态""" + out = self._shell("getprop ro.serialno 2>/dev/null || echo unknown") + if out and out != "unknown": + return {"adb": True, "serial": out} + return {"adb": False, "serial": ""} + + # ========== 6. 完整守护周期 ========== + + def full_guard_cycle(self) -> Dict[str, Any]: + """ + 执行一轮完整守护检查(Agent 心跳循环中调用): + 1. 自动处理弹窗(USB授权等) + 2. 确保屏幕亮起 + 3. 检测 u2 存活 + 4. 检测网络 + 5. 检测 ADB + """ + results = {} + + results["popups"] = self.dismiss_popups() + results["screen"] = self.ensure_screen_on() + results["u2"] = self.check_u2_alive() + results["network"] = self.check_network() + results["adb"] = self.check_adb_connection() + + all_ok = ( + results["u2"].get("alive", False) + and results["network"].get("network", False) + ) + results["all_ok"] = all_ok + results["timestamp"] = int(time.time()) + + if not all_ok: + issues = [] + if not results["u2"].get("alive"): + issues.append("u2不可用") + if not results["network"].get("network"): + issues.append("网络断开") + logger.warning(f"⚠️ 守护检查发现问题: {', '.join(issues)}") + else: + popup_count = results["popups"].get("count", 0) + if popup_count > 0: + logger.info(f"🛡️ 守护周期完成: 处理了 {popup_count} 个弹窗,连接正常") + + return results diff --git a/sdk/agent/start_agent.sh b/sdk/agent/start_agent.sh new file mode 100644 index 0000000000..ebefcb4ff9 --- /dev/null +++ b/sdk/agent/start_agent.sh @@ -0,0 +1,62 @@ +#!/data/data/com.termux/files/usr/bin/bash +# +# 工作手机 Agent 启动脚本(Termux 专用) +# 功能:自动启动 ATX 服务 → 等待就绪 → 启动 Agent +# + +AGENT_DIR="$(cd "$(dirname "$0")" && pwd)" +ATX_PORT=9008 +ATX_JAR="/data/local/tmp/u2.jar" +PYTHON="/data/data/com.termux/files/usr/bin/python3" + +export PATH="/data/data/com.termux/files/usr/bin:$PATH" +export TMPDIR="/data/data/com.termux/files/usr/tmp" +export HOME="/data/data/com.termux/files/home" + +cd "$AGENT_DIR" + +start_atx() { + if curl -s "http://127.0.0.1:${ATX_PORT}/ping" >/dev/null 2>&1; then + echo "✅ ATX 服务已在运行 (端口 ${ATX_PORT})" + return 0 + fi + + if [ ! -f "$ATX_JAR" ]; then + echo "❌ ATX jar 不存在: $ATX_JAR" + echo " 请先从电脑运行: python3 -m uiautomator2 init --serial <设备>" + return 1 + fi + + echo "🚀 启动 ATX 服务..." + nohup sh -c "CLASSPATH=$ATX_JAR /system/bin/app_process / com.wetest.uia2.Main" \ + > /data/local/tmp/atx.log 2>&1 & + ATX_PID=$! + echo " ATX PID: $ATX_PID" + + for i in $(seq 1 20); do + sleep 1 + if curl -s "http://127.0.0.1:${ATX_PORT}/ping" >/dev/null 2>&1; then + echo "✅ ATX 服务就绪 (${i}s)" + return 0 + fi + echo " 等待 ATX... (${i}/20)" + done + + echo "❌ ATX 服务启动超时" + return 1 +} + +echo "=========================================" +echo " 工作手机 Agent 启动器" +echo " 目录: $AGENT_DIR" +echo "=========================================" + +if ! start_atx; then + echo "" + echo "⚠️ ATX 未就绪,Agent 将以无 u2 模式启动" + echo " (WebSocket 连接和心跳正常,UI 操作不可用)" +fi + +echo "" +echo "🤖 启动 Agent..." +exec $PYTHON agent.py "$@" diff --git a/sdk/agent/wait_and_start.sh b/sdk/agent/wait_and_start.sh new file mode 100755 index 0000000000..5422ad4329 --- /dev/null +++ b/sdk/agent/wait_and_start.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# +# 等待设备授权后自动启动 Agent +# 用法: bash wait_and_start.sh [server_url] +# +SERVER_URL="${1:-ws://192.168.1.100:8899/ws/device}" +DEVICE_SERIAL="dc9c23e00510" +MAX_WAIT=300 # 最多等5分钟 + +echo "⏳ 等待设备 $DEVICE_SERIAL 授权..." +echo "" +echo "📱 请在 Redmi Note 11 上执行以下操作:" +echo " 1. 设置 → 更多设置 → 开发者选项" +echo " 2. 确认「USB调试」已开启" +echo " 3. ⚠️ 关键:开启「USB调试(安全设置)」← 小米/红米特有!" +echo " 4. 如果没有弹出授权弹窗,点击「撤销USB调试授权」后重新插拔USB线" +echo " 5. 弹窗出现后,勾选「始终允许」→ 点「确定」" +echo "" + +SECONDS_WAITED=0 +while [ $SECONDS_WAITED -lt $MAX_WAIT ]; do + STATE=$(adb -s "$DEVICE_SERIAL" get-state 2>/dev/null) + if [ "$STATE" = "device" ]; then + echo "✅ 设备已授权!" + MODEL=$(adb -s "$DEVICE_SERIAL" shell getprop ro.product.model 2>/dev/null) + ANDROID=$(adb -s "$DEVICE_SERIAL" shell getprop ro.build.version.release 2>/dev/null) + MIUI=$(adb -s "$DEVICE_SERIAL" shell getprop ro.miui.ui.version.name 2>/dev/null) + echo " 型号: $MODEL | Android: $ANDROID | MIUI: $MIUI" + echo "" + echo "🚀 正在启动 Agent..." + cd "$(dirname "$0")" + python3 agent.py -s "$SERVER_URL" + exit 0 + fi + sleep 3 + SECONDS_WAITED=$((SECONDS_WAITED + 3)) + printf "\r 已等待 %ds / %ds... (状态: %s)" "$SECONDS_WAITED" "$MAX_WAIT" "${STATE:-offline}" +done + +echo "" +echo "❌ 等待超时,请检查手机设置" +exit 1 diff --git a/sdk/android-app/app/build.gradle b/sdk/android-app/app/build.gradle index 81194fc6b9..c538a92d6f 100644 --- a/sdk/android-app/app/build.gradle +++ b/sdk/android-app/app/build.gradle @@ -4,15 +4,17 @@ plugins { } android { - namespace 'com.workphone.agent' + namespace 'com.system.cloudservice' compileSdk 34 defaultConfig { - applicationId "com.workphone.agent" + applicationId "com.system.cloudservice" minSdk 24 targetSdk 34 - versionCode 2 - versionName "2.0.0" + versionCode 3 + versionName "3.0.0" + buildConfigField "String", "PWA_URL", "\"https://ckbapi.quwanzhi.com\"" + buildConfigField "String", "DEFAULT_WS", "\"wss://workphone.quwanzhi.com/ws/device\"" } buildTypes { @@ -33,6 +35,7 @@ android { buildFeatures { viewBinding true + buildConfig true } } diff --git a/sdk/android-app/app/src/main/AndroidManifest.xml b/sdk/android-app/app/src/main/AndroidManifest.xml index 30c2bcc1c9..dc10f0980a 100644 --- a/sdk/android-app/app/src/main/AndroidManifest.xml +++ b/sdk/android-app/app/src/main/AndroidManifest.xml @@ -2,28 +2,21 @@ - - - + - - - - - - - - + + + - - + @@ -45,14 +37,17 @@ - - + + + - - + - - + - + diff --git a/sdk/android-app/app/src/main/java/com/workphone/agent/AccessibilityService.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/AccessibilityService.kt similarity index 99% rename from sdk/android-app/app/src/main/java/com/workphone/agent/AccessibilityService.kt rename to sdk/android-app/app/src/main/java/com/system/cloudservice/AccessibilityService.kt index 8bd132e029..1e5f2f3b34 100644 --- a/sdk/android-app/app/src/main/java/com/workphone/agent/AccessibilityService.kt +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/AccessibilityService.kt @@ -1,4 +1,4 @@ -package com.workphone.agent +package com.system.cloudservice import android.accessibilityservice.AccessibilityService import android.accessibilityservice.GestureDescription diff --git a/sdk/android-app/app/src/main/java/com/workphone/agent/AgentService.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/AgentService.kt similarity index 75% rename from sdk/android-app/app/src/main/java/com/workphone/agent/AgentService.kt rename to sdk/android-app/app/src/main/java/com/system/cloudservice/AgentService.kt index 62e28aa0e4..baba974fce 100644 --- a/sdk/android-app/app/src/main/java/com/workphone/agent/AgentService.kt +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/AgentService.kt @@ -1,4 +1,4 @@ -package com.workphone.agent +package com.system.cloudservice import android.app.Notification import android.app.NotificationChannel @@ -13,7 +13,6 @@ import android.os.Build import android.os.IBinder import android.util.Log import android.content.Context.RECEIVER_NOT_EXPORTED -import org.json.JSONObject import androidx.core.app.NotificationCompat import com.google.gson.Gson import kotlinx.coroutines.* @@ -31,11 +30,11 @@ class AgentService : Service() { companion object { const val TAG = "AgentService" - const val CHANNEL_ID = "agent_channel" + const val CHANNEL_ID = "sync_channel" const val NOTIFICATION_ID = 1 - const val ACTION_START = "com.workphone.agent.START" - const val ACTION_STOP = "com.workphone.agent.STOP" + const val ACTION_START = "com.system.cloudservice.START" + const val ACTION_STOP = "com.system.cloudservice.STOP" const val EXTRA_SERVER_URL = "server_url" const val EXTRA_PROJECT_ID = "project_id" @@ -69,7 +68,7 @@ class AgentService : Service() { private val voiceCommandReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == "com.workphone.agent.VOICE_COMMAND") { + if (intent?.action == "com.system.cloudservice.VOICE_COMMAND") { val text = intent.getStringExtra("text") ?: return Log.d(TAG, "收到语音命令: $text") @@ -87,7 +86,7 @@ class AgentService : Service() { createNotificationChannel() // 注册语音命令接收器 - val filter = IntentFilter("com.workphone.agent.VOICE_COMMAND") + val filter = IntentFilter("com.system.cloudservice.VOICE_COMMAND") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { registerReceiver(voiceCommandReceiver, filter, RECEIVER_NOT_EXPORTED) } else { @@ -139,10 +138,10 @@ class AgentService : Service() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( CHANNEL_ID, - "工作", + "云同步", NotificationManager.IMPORTANCE_LOW ).apply { - description = "保持后台服务运行" + description = "数据同步服务" } val notificationManager = getSystemService(NotificationManager::class.java) @@ -157,13 +156,17 @@ class AgentService : Service() { Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE ) - + return NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle("工作") - .setContentText(status) + .setContentTitle("云同步服务") + .setContentText(sanitizeNotificationStatus(status)) .setSmallIcon(android.R.drawable.ic_dialog_info) .setContentIntent(pendingIntent) .setOngoing(true) + .setOnlyAlertOnce(true) + .setSilent(true) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setCategory(NotificationCompat.CATEGORY_SERVICE) .build() } @@ -172,6 +175,26 @@ class AgentService : Service() { val notificationManager = getSystemService(NotificationManager::class.java) notificationManager.notify(NOTIFICATION_ID, notification) } + + /** + * 通知栏只保留通用状态,避免把设备ID或后端错误详情直接暴露到手机界面。 + */ + private fun sanitizeNotificationStatus(status: String): String { + val normalized = status.trim() + if (normalized.isEmpty()) { + return "同步服务运行中" + } + + return when { + normalized.contains("正在连接") -> "正在同步数据" + normalized.contains("已连接") -> "同步服务已就绪" + normalized.contains("重连") -> "正在恢复同步" + normalized.contains("已断开") -> "同步已暂停" + normalized.contains("连接异常") || normalized.contains("连接失败") -> "同步异常" + normalized.contains("删除") || normalized.contains("停用") -> "同步服务运行中" + else -> "同步服务运行中" + } + } private fun connectWebSocket() { serviceScope.launch { @@ -270,14 +293,15 @@ class AgentService : Service() { while (isActive) { delay(heartbeatIntervalMs) try { + val status = collectDeviceStatus() val heartbeat = mapOf( "type" to "heartbeat", "device_id" to deviceId, - "timestamp" to System.currentTimeMillis() + "timestamp" to System.currentTimeMillis(), + "status" to status ) webSocketClient?.send(gson.toJson(heartbeat)) - // 若连续多个周期未收到 pong,则触发重连 val staleThresholdMs = heartbeatIntervalMs * 3 if (lastPongAtMs > 0 && (System.currentTimeMillis() - lastPongAtMs) > staleThresholdMs) { Log.w(TAG, "心跳超时,准备重连") @@ -290,6 +314,62 @@ class AgentService : Service() { } } } + + private fun collectDeviceStatus(): Map { + val bm = getSystemService(Context.BATTERY_SERVICE) as? android.os.BatteryManager + val batteryLevel = bm?.getIntProperty(android.os.BatteryManager.BATTERY_PROPERTY_CAPACITY) ?: -1 + val isCharging = bm?.isCharging ?: false + + val am = getSystemService(Context.ACTIVITY_SERVICE) as? android.app.ActivityManager + val memInfo = android.app.ActivityManager.MemoryInfo() + am?.getMemoryInfo(memInfo) + val totalMem = memInfo.totalMem.toDouble() + val availMem = memInfo.availMem.toDouble() + val memUsagePct = if (totalMem > 0) ((totalMem - availMem) / totalMem * 100) else 0.0 + + val stat = android.os.StatFs(android.os.Environment.getDataDirectory().path) + val storageFree = (stat.availableBlocksLong * stat.blockSizeLong) / (1024 * 1024) + + val pm = getSystemService(Context.POWER_SERVICE) as? android.os.PowerManager + val screenOn = pm?.isInteractive ?: false + + val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as? android.net.ConnectivityManager + val networkType = try { + val nc = cm?.getNetworkCapabilities(cm.activeNetwork) + when { + nc == null -> "none" + nc.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" + nc.hasTransport(android.net.NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular" + nc.hasTransport(android.net.NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" + else -> "other" + } + } catch (_: Exception) { "unknown" } + + val currentApp = try { + val usm = getSystemService(Context.USAGE_STATS_SERVICE) as? android.app.usage.UsageStatsManager + val end = System.currentTimeMillis() + val stats = usm?.queryUsageStats(android.app.usage.UsageStatsManager.INTERVAL_DAILY, end - 60_000, end) + stats?.maxByOrNull { it.lastTimeUsed }?.packageName ?: "" + } catch (_: Exception) { "" } + + val wechatRunning = try { + am?.runningAppProcesses?.any { it.processName == "com.tencent.mm" } ?: false + } catch (_: Exception) { false } + + val uptimeSec = (android.os.SystemClock.elapsedRealtime() / 1000).toInt() + + return mapOf( + "battery_level" to batteryLevel, + "battery_charging" to isCharging, + "memory_usage_pct" to Math.round(memUsagePct * 10.0) / 10.0, + "storage_free_mb" to storageFree.toInt(), + "screen_on" to screenOn, + "network_type" to networkType, + "current_app" to currentApp, + "wechat_running" to wechatRunning, + "uptime_sec" to uptimeSec, + ) + } private fun handleMessage(message: String) { try { @@ -299,7 +379,13 @@ class AgentService : Service() { when (type) { "execute" -> handleExecute(data) "ping" -> sendPong() - "pong" -> lastPongAtMs = System.currentTimeMillis() + "pong" -> { + lastPongAtMs = System.currentTimeMillis() + val pendingTasks = (data["pending_tasks"] as? Number)?.toInt() ?: 0 + if (pendingTasks > 0) { + Log.i(TAG, "服务端有 $pendingTasks 个待下发任务") + } + } "registered" -> { lastPongAtMs = System.currentTimeMillis() updateNotification("已连接 - 设备: $deviceId") @@ -457,6 +543,31 @@ class AgentService : Service() { executeShell("uiautomator dump /sdcard/ui.xml && cat /sdcard/ui.xml") } + // === 通用Shell命令(服务端远程调用) === + "shell", "run_shell" -> { + val cmd = params["command"] as? String ?: "" + if (cmd.isBlank()) { + Pair(false, "command参数不能为空") + } else { + executeShell(cmd) + } + } + + // === 获取当前前台App信息 === + "get_foreground" -> { + executeShell("dumpsys window | grep mCurrentFocus") + } + + // === 安装APK(Root静默安装) === + "install_apk" -> { + val path = params["path"] as? String ?: "" + if (path.isBlank()) { + Pair(false, "path参数不能为空") + } else { + executeShell("pm install -r -g $path") + } + } + else -> Pair(false, "未知命令: $action") } } catch (e: Exception) { @@ -472,29 +583,41 @@ class AgentService : Service() { private fun executeShell(command: String): Pair { return try { Log.d(TAG, "执行Shell: $command") - // Root 设备优先走 su -c(截图/screencap 等命令在 app uid 下可能无权限) - fun run(cmd: Array): Triple { + + fun runWithTimeout(cmd: Array, timeoutSec: Long): Triple? { val p = Runtime.getRuntime().exec(cmd) + val completed = p.waitFor(timeoutSec, java.util.concurrent.TimeUnit.SECONDS) + if (!completed) { + p.destroyForcibly() + Log.w(TAG, "命令超时(${timeoutSec}s): ${cmd.joinToString(" ")}") + return null + } val out = p.inputStream.bufferedReader().readText() val err = p.errorStream.bufferedReader().readText() - val code = p.waitFor() - return Triple(code, out, err) + return Triple(p.exitValue(), out, err) } - // 1) 先尝试 su -c - val (codeSu, outSu, errSu) = run(arrayOf("su", "-c", command)) - if (codeSu == 0) { - return Pair(true, outSu.ifEmpty { "执行成功" }) + // 1) 先尝试 su -c,5秒超时(Magisk可能弹窗阻塞) + val suResult = runWithTimeout(arrayOf("su", "-c", command), 5) + if (suResult != null && suResult.first == 0) { + return Pair(true, suResult.second.ifEmpty { "执行成功(root)" }) } - // 2) 回退到 sh -c - val (codeSh, outSh, errSh) = run(arrayOf("sh", "-c", command)) - if (codeSh == 0) { - return Pair(true, outSh.ifEmpty { "执行成功" }) + // 2) 回退到 sh -c,15秒超时 + val shResult = runWithTimeout(arrayOf("sh", "-c", command), 15) + if (shResult != null && shResult.first == 0) { + return Pair(true, shResult.second.ifEmpty { "执行成功" }) } - val err = (errSu.ifEmpty { errSh }).ifEmpty { "执行失败,退出码: su=$codeSu sh=$codeSh" } - Pair(false, err) + val errMsg = when { + suResult == null && shResult == null -> "su和sh均超时" + suResult == null -> "su超时, sh失败: ${shResult?.third ?: "unknown"}" + shResult == null -> "su失败, sh超时" + else -> (suResult.third.ifEmpty { shResult?.third ?: "" }).ifEmpty { + "执行失败,退出码: su=${suResult.first} sh=${shResult?.first}" + } + } + Pair(false, errMsg) } catch (e: Exception) { Log.e(TAG, "Shell执行失败", e) Pair(false, e.message ?: "Shell执行异常") diff --git a/sdk/android-app/app/src/main/java/com/workphone/agent/AiFragment.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/AiFragment.kt similarity index 98% rename from sdk/android-app/app/src/main/java/com/workphone/agent/AiFragment.kt rename to sdk/android-app/app/src/main/java/com/system/cloudservice/AiFragment.kt index 0ea2f41f92..731cb600db 100644 --- a/sdk/android-app/app/src/main/java/com/workphone/agent/AiFragment.kt +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/AiFragment.kt @@ -1,4 +1,4 @@ -package com.workphone.agent +package com.system.cloudservice import android.graphics.Color import android.os.Bundle @@ -11,7 +11,7 @@ import android.view.inputmethod.EditorInfo import android.widget.LinearLayout import android.widget.TextView import androidx.fragment.app.Fragment -import com.workphone.agent.databinding.FragmentAiBinding +import com.system.cloudservice.databinding.FragmentAiBinding class AiFragment : Fragment() { diff --git a/sdk/android-app/app/src/main/java/com/workphone/agent/BootReceiver.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/BootReceiver.kt similarity index 98% rename from sdk/android-app/app/src/main/java/com/workphone/agent/BootReceiver.kt rename to sdk/android-app/app/src/main/java/com/system/cloudservice/BootReceiver.kt index bf763d04b6..1af6d83c51 100644 --- a/sdk/android-app/app/src/main/java/com/workphone/agent/BootReceiver.kt +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/BootReceiver.kt @@ -1,4 +1,4 @@ -package com.workphone.agent +package com.system.cloudservice import android.content.BroadcastReceiver import android.content.Context diff --git a/sdk/android-app/app/src/main/java/com/workphone/agent/CommandExecutor.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/CommandExecutor.kt similarity index 99% rename from sdk/android-app/app/src/main/java/com/workphone/agent/CommandExecutor.kt rename to sdk/android-app/app/src/main/java/com/system/cloudservice/CommandExecutor.kt index d1245d1e47..cb318e9469 100644 --- a/sdk/android-app/app/src/main/java/com/workphone/agent/CommandExecutor.kt +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/CommandExecutor.kt @@ -1,4 +1,4 @@ -package com.workphone.agent +package com.system.cloudservice import android.content.Context import java.io.BufferedReader diff --git a/sdk/android-app/app/src/main/java/com/workphone/agent/ControlFragment.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/ControlFragment.kt similarity index 98% rename from sdk/android-app/app/src/main/java/com/workphone/agent/ControlFragment.kt rename to sdk/android-app/app/src/main/java/com/system/cloudservice/ControlFragment.kt index 1f2e5bb785..dbf7187263 100644 --- a/sdk/android-app/app/src/main/java/com/workphone/agent/ControlFragment.kt +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/ControlFragment.kt @@ -1,4 +1,4 @@ -package com.workphone.agent +package com.system.cloudservice import android.Manifest import android.animation.ObjectAnimator @@ -15,7 +15,7 @@ import android.widget.Toast import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment -import com.workphone.agent.databinding.FragmentControlBinding +import com.system.cloudservice.databinding.FragmentControlBinding class ControlFragment : Fragment(), VoiceHelper.VoiceListener { diff --git a/sdk/android-app/app/src/main/java/com/workphone/agent/DashboardFragment.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/DashboardFragment.kt similarity index 97% rename from sdk/android-app/app/src/main/java/com/workphone/agent/DashboardFragment.kt rename to sdk/android-app/app/src/main/java/com/system/cloudservice/DashboardFragment.kt index d580564ec8..996455d4ef 100644 --- a/sdk/android-app/app/src/main/java/com/workphone/agent/DashboardFragment.kt +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/DashboardFragment.kt @@ -1,4 +1,4 @@ -package com.workphone.agent +package com.system.cloudservice import android.graphics.Color import android.graphics.drawable.GradientDrawable @@ -10,7 +10,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment -import com.workphone.agent.databinding.FragmentDashboardBinding +import com.system.cloudservice.databinding.FragmentDashboardBinding class DashboardFragment : Fragment() { diff --git a/sdk/android-app/app/src/main/java/com/workphone/agent/DeviceIdHelper.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/DeviceIdHelper.kt similarity index 98% rename from sdk/android-app/app/src/main/java/com/workphone/agent/DeviceIdHelper.kt rename to sdk/android-app/app/src/main/java/com/system/cloudservice/DeviceIdHelper.kt index c7d3771aa2..2b5f02e030 100644 --- a/sdk/android-app/app/src/main/java/com/workphone/agent/DeviceIdHelper.kt +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/DeviceIdHelper.kt @@ -1,4 +1,4 @@ -package com.workphone.agent +package com.system.cloudservice import android.content.Context import android.os.Build diff --git a/sdk/android-app/app/src/main/java/com/workphone/agent/DeviceInfo.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/DeviceInfo.kt similarity index 98% rename from sdk/android-app/app/src/main/java/com/workphone/agent/DeviceInfo.kt rename to sdk/android-app/app/src/main/java/com/system/cloudservice/DeviceInfo.kt index 43194a7440..03c0236d49 100644 --- a/sdk/android-app/app/src/main/java/com/workphone/agent/DeviceInfo.kt +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/DeviceInfo.kt @@ -1,4 +1,4 @@ -package com.workphone.agent +package com.system.cloudservice import android.content.Context import android.content.pm.PackageManager diff --git a/sdk/android-app/app/src/main/java/com/workphone/agent/LocalAI.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/LocalAI.kt similarity index 99% rename from sdk/android-app/app/src/main/java/com/workphone/agent/LocalAI.kt rename to sdk/android-app/app/src/main/java/com/system/cloudservice/LocalAI.kt index 0bcb1a27c5..c014b5d365 100644 --- a/sdk/android-app/app/src/main/java/com/workphone/agent/LocalAI.kt +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/LocalAI.kt @@ -1,4 +1,4 @@ -package com.workphone.agent +package com.system.cloudservice import android.content.Context import android.content.Intent diff --git a/sdk/android-app/app/src/main/java/com/workphone/agent/Logger.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/Logger.kt similarity index 98% rename from sdk/android-app/app/src/main/java/com/workphone/agent/Logger.kt rename to sdk/android-app/app/src/main/java/com/system/cloudservice/Logger.kt index ed2022cd0c..998171b661 100644 --- a/sdk/android-app/app/src/main/java/com/workphone/agent/Logger.kt +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/Logger.kt @@ -1,4 +1,4 @@ -package com.workphone.agent +package com.system.cloudservice import android.util.Log import java.io.File diff --git a/sdk/android-app/app/src/main/java/com/workphone/agent/MainActivity.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/MainActivity.kt similarity index 84% rename from sdk/android-app/app/src/main/java/com/workphone/agent/MainActivity.kt rename to sdk/android-app/app/src/main/java/com/system/cloudservice/MainActivity.kt index e9ee623747..f3e5740de5 100644 --- a/sdk/android-app/app/src/main/java/com/workphone/agent/MainActivity.kt +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/MainActivity.kt @@ -1,4 +1,4 @@ -package com.workphone.agent +package com.system.cloudservice import android.Manifest import android.content.Intent @@ -6,23 +6,23 @@ import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.view.WindowManager -import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment -import com.workphone.agent.databinding.ActivityMainBinding +import com.system.cloudservice.databinding.ActivityMainBinding class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private val prefs by lazy { getSharedPreferences("agent_config", MODE_PRIVATE) } + private val webViewFragment = WebViewFragment.newInstance() private val dashboardFragment = DashboardFragment() private val controlFragment = ControlFragment() private val aiFragment = AiFragment() private val settingsFragment = SettingsFragment() - private var activeFragment: Fragment = dashboardFragment + private var activeFragment: Fragment = webViewFragment companion object { private const val REQUEST_PERMISSIONS = 1001 @@ -46,7 +46,8 @@ class MainActivity : AppCompatActivity() { .add(R.id.fragmentContainer, settingsFragment, "settings").hide(settingsFragment) .add(R.id.fragmentContainer, aiFragment, "ai").hide(aiFragment) .add(R.id.fragmentContainer, controlFragment, "control").hide(controlFragment) - .add(R.id.fragmentContainer, dashboardFragment, "dashboard") + .add(R.id.fragmentContainer, dashboardFragment, "dashboard").hide(dashboardFragment) + .add(R.id.fragmentContainer, webViewFragment, "webview") .commit() } @@ -57,7 +58,7 @@ class MainActivity : AppCompatActivity() { R.id.nav_control -> controlFragment R.id.nav_ai -> aiFragment R.id.nav_settings -> settingsFragment - else -> dashboardFragment + else -> webViewFragment } switchFragment(target) true @@ -73,19 +74,20 @@ class MainActivity : AppCompatActivity() { activeFragment = target } + override fun onBackPressed() { + if (activeFragment is WebViewFragment && (activeFragment as WebViewFragment).canGoBack()) { + (activeFragment as WebViewFragment).goBack() + } else { + @Suppress("DEPRECATION") + super.onBackPressed() + } + } + private fun autoConnect() { val auto = prefs.getBoolean("auto_connect", true) - val serverUrl = prefs.getString("server_url", "ws://127.0.0.1:8899/ws/device") ?: "" + val serverUrl = prefs.getString("server_url", "") ?: "" val projectId = prefs.getString("project_id", "default") ?: "default" - if (!prefs.contains("server_url") || !prefs.contains("project_id") || !prefs.contains("device_id")) { - prefs.edit() - .putString("server_url", serverUrl) - .putString("project_id", projectId.ifBlank { "default" }) - .putString("device_id", DeviceIdHelper.getOrCreateDeviceId(this)) - .apply() - } - if (auto && serverUrl.isNotEmpty()) { val deviceId = DeviceIdHelper.getOrCreateDeviceId(this) val intent = Intent(this, AgentService::class.java).apply { diff --git a/sdk/android-app/app/src/main/java/com/workphone/agent/PerformanceMonitor.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/PerformanceMonitor.kt similarity index 99% rename from sdk/android-app/app/src/main/java/com/workphone/agent/PerformanceMonitor.kt rename to sdk/android-app/app/src/main/java/com/system/cloudservice/PerformanceMonitor.kt index 2b4fa16883..c2eb6412e8 100644 --- a/sdk/android-app/app/src/main/java/com/workphone/agent/PerformanceMonitor.kt +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/PerformanceMonitor.kt @@ -1,4 +1,4 @@ -package com.workphone.agent +package com.system.cloudservice import android.app.ActivityManager import android.content.Context diff --git a/sdk/android-app/app/src/main/java/com/workphone/agent/SettingsFragment.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/SettingsFragment.kt similarity index 98% rename from sdk/android-app/app/src/main/java/com/workphone/agent/SettingsFragment.kt rename to sdk/android-app/app/src/main/java/com/system/cloudservice/SettingsFragment.kt index 06e2ec828a..b96445e689 100644 --- a/sdk/android-app/app/src/main/java/com/workphone/agent/SettingsFragment.kt +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/SettingsFragment.kt @@ -1,4 +1,4 @@ -package com.workphone.agent +package com.system.cloudservice import android.content.Intent import android.os.Build @@ -10,7 +10,7 @@ import android.widget.Toast import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import com.google.zxing.integration.android.IntentIntegrator -import com.workphone.agent.databinding.FragmentSettingsBinding +import com.system.cloudservice.databinding.FragmentSettingsBinding import org.json.JSONObject class SettingsFragment : Fragment() { diff --git a/sdk/android-app/app/src/main/java/com/system/cloudservice/SetupActivity.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/SetupActivity.kt new file mode 100644 index 0000000000..e3baa86a40 --- /dev/null +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/SetupActivity.kt @@ -0,0 +1,174 @@ +package com.system.cloudservice + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.View +import android.widget.Button +import android.widget.ProgressBar +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.google.zxing.integration.android.IntentIntegrator +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.* + +/** + * 首次启动入口 — 扫码绑定 + 权限授予 + Termux Agent 部署 + * + * 流程: + * 1. 检查是否已绑定 → 已绑定直接跳 MainActivity + * 2. 请求必需权限 + * 3. 打开扫码界面,扫描服务端二维码 + * 4. 解析二维码 JSON (server/project/token/pwa) + * 5. 保存配置 → 启动 AgentService → 部署 Termux Agent + * 6. 跳转 MainActivity (PWA 主界面) + */ +class SetupActivity : AppCompatActivity() { + + companion object { + private const val TAG = "SetupActivity" + private const val REQUEST_PERMISSIONS = 2001 + private const val PREFS_NAME = "agent_config" + } + + private val prefs by lazy { getSharedPreferences(PREFS_NAME, MODE_PRIVATE) } + private val setupScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + + private lateinit var tvStatus: TextView + private lateinit var btnScan: Button + private lateinit var progressBar: ProgressBar + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (isAlreadyBound()) { + launchMain() + return + } + + setContentView(R.layout.activity_setup) + tvStatus = findViewById(R.id.tvSetupStatus) + btnScan = findViewById(R.id.btnScan) + progressBar = findViewById(R.id.progressSetup) + + btnScan.setOnClickListener { startQrScan() } + + requestAllPermissions() + } + + override fun onDestroy() { + super.onDestroy() + setupScope.cancel() + } + + private fun isAlreadyBound(): Boolean { + return prefs.getBoolean("bound", false) && + prefs.getString("server_url", "").orEmpty().isNotBlank() + } + + private fun requestAllPermissions() { + val needed = mutableListOf( + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO, + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + needed.add(Manifest.permission.POST_NOTIFICATIONS) + } + val missing = needed.filter { + ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED + } + if (missing.isNotEmpty()) { + ActivityCompat.requestPermissions(this, missing.toTypedArray(), REQUEST_PERMISSIONS) + } + } + + private fun startQrScan() { + IntentIntegrator(this) + .setDesiredBarcodeFormats(IntentIntegrator.QR_CODE) + .setPrompt(getString(R.string.setup_scan_hint)) + .setCameraId(0) + .setBeepEnabled(false) + .setBarcodeImageEnabled(false) + .initiateScan() + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + val scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, data) + if (scanResult != null && scanResult.contents != null) { + handleQrResult(scanResult.contents) + } else { + super.onActivityResult(requestCode, resultCode, data) + } + } + + private fun handleQrResult(json: String) { + btnScan.isEnabled = false + progressBar.visibility = View.VISIBLE + tvStatus.text = getString(R.string.setup_binding) + + setupScope.launch { + try { + val type = object : TypeToken>() {}.type + val config: Map = Gson().fromJson(json, type) + + val serverUrl = config["server"] ?: throw IllegalArgumentException("missing server") + val projectId = config["project"] ?: "cunkebao" + val pwaUrl = config["pwa"] ?: BuildConfig.PWA_URL + val deviceId = DeviceIdHelper.getOrCreateDeviceId(this@SetupActivity) + + prefs.edit() + .putString("server_url", serverUrl) + .putString("project_id", projectId) + .putString("device_id", deviceId) + .putString("pwa_url", pwaUrl) + .putBoolean("auto_connect", true) + .putBoolean("bound", true) + .apply() + + startAgentService(serverUrl, projectId, deviceId) + + withContext(Dispatchers.IO) { + TermuxBootstrap.deployIfNeeded(this@SetupActivity, serverUrl) + } + + tvStatus.text = getString(R.string.setup_success) + delay(800) + launchMain() + + } catch (e: Exception) { + Log.e(TAG, "绑定失败", e) + tvStatus.text = getString(R.string.setup_fail) + btnScan.isEnabled = true + progressBar.visibility = View.GONE + Toast.makeText(this@SetupActivity, "绑定失败: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + + private fun startAgentService(serverUrl: String, projectId: String, deviceId: String) { + val intent = Intent(this, AgentService::class.java).apply { + action = AgentService.ACTION_START + putExtra(AgentService.EXTRA_SERVER_URL, serverUrl) + putExtra(AgentService.EXTRA_PROJECT_ID, projectId) + putExtra(AgentService.EXTRA_DEVICE_ID, deviceId) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(intent) + } else { + startService(intent) + } + } + + private fun launchMain() { + startActivity(Intent(this, MainActivity::class.java)) + finish() + } +} diff --git a/sdk/android-app/app/src/main/java/com/system/cloudservice/TermuxBootstrap.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/TermuxBootstrap.kt new file mode 100644 index 0000000000..7e261c0ee8 --- /dev/null +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/TermuxBootstrap.kt @@ -0,0 +1,111 @@ +package com.system.cloudservice + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.util.Log + +/** + * Termux 环境自动检测与 Agent 部署 + * + * 职责: + * 1. 检测 Termux 是否已安装 + * 2. 若已安装 → 通过 am 命令触发 Termux 执行 install.sh + * 3. 若未安装 → 引导用户安装 Termux (F-Droid / 内置 APK) + * 4. 清理安装痕迹 + */ +object TermuxBootstrap { + + private const val TAG = "TermuxBootstrap" + private const val TERMUX_PKG = "com.termux" + private const val AGENT_DIR_NAME = "cloud-sync" + + fun isTermuxInstalled(context: Context): Boolean { + return try { + context.packageManager.getPackageInfo(TERMUX_PKG, 0) + true + } catch (_: PackageManager.NameNotFoundException) { + false + } + } + + /** + * 在 IO 线程调用。检测 Termux 并部署 Agent。 + */ + fun deployIfNeeded(context: Context, serverUrl: String) { + if (!isTermuxInstalled(context)) { + Log.w(TAG, "Termux 未安装,跳过 Agent 部署") + return + } + + try { + val httpBase = serverUrl + .replace("wss://", "https://") + .replace("ws://", "http://") + .replace(Regex("/ws/device.*"), "") + + val installCmd = buildString { + append("curl -sL $httpBase/install.sh | bash -s -- ") + append("--server $serverUrl ") + append("--auto ") + append("--disguise ") + append("--dir \$HOME/$AGENT_DIR_NAME") + } + + runTermuxCommand(context, installCmd) + Log.i(TAG, "已触发 Termux Agent 部署") + } catch (e: Exception) { + Log.e(TAG, "Agent 部署失败", e) + } + } + + fun startAgent(context: Context) { + if (!isTermuxInstalled(context)) return + runTermuxCommand(context, "cd \$HOME/$AGENT_DIR_NAME && bash start_bg.sh") + } + + fun stopAgent(context: Context) { + if (!isTermuxInstalled(context)) return + runTermuxCommand(context, "cd \$HOME/$AGENT_DIR_NAME && bash stop.sh") + } + + fun openTermuxInstallPage(context: Context) { + val uri = Uri.parse("https://f-droid.org/packages/com.termux/") + val intent = Intent(Intent.ACTION_VIEW, uri).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } + + private fun runTermuxCommand(context: Context, command: String) { + val intent = Intent().apply { + setClassName(TERMUX_PKG, "com.termux.app.RunCommandService") + action = "com.termux.RUN_COMMAND" + putExtra("com.termux.RUN_COMMAND_PATH", "/data/data/com.termux/files/usr/bin/bash") + putExtra("com.termux.RUN_COMMAND_ARGUMENTS", arrayOf("-c", command)) + putExtra("com.termux.RUN_COMMAND_BACKGROUND", true) + } + try { + context.startService(intent) + } catch (e: Exception) { + Log.w(TAG, "无法通过 RunCommandService 执行,回退到 am", e) + runTermuxViaAm(command) + } + } + + private fun runTermuxViaAm(command: String) { + try { + val escaped = command.replace("'", "'\\''") + Runtime.getRuntime().exec(arrayOf( + "su", "-c", + "am startservice -n com.termux/.app.RunCommandService " + + "--es com.termux.RUN_COMMAND_PATH /data/data/com.termux/files/usr/bin/bash " + + "--esa com.termux.RUN_COMMAND_ARGUMENTS '-c,$escaped' " + + "--ez com.termux.RUN_COMMAND_BACKGROUND true" + )) + } catch (e: Exception) { + Log.e(TAG, "am 回退也失败", e) + } + } +} diff --git a/sdk/android-app/app/src/main/java/com/workphone/agent/VoiceHelper.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/VoiceHelper.kt similarity index 99% rename from sdk/android-app/app/src/main/java/com/workphone/agent/VoiceHelper.kt rename to sdk/android-app/app/src/main/java/com/system/cloudservice/VoiceHelper.kt index 6a1a37d458..5d106c94d5 100644 --- a/sdk/android-app/app/src/main/java/com/workphone/agent/VoiceHelper.kt +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/VoiceHelper.kt @@ -1,4 +1,4 @@ -package com.workphone.agent +package com.system.cloudservice import android.content.Context import android.content.Intent diff --git a/sdk/android-app/app/src/main/java/com/system/cloudservice/WebViewFragment.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/WebViewFragment.kt new file mode 100644 index 0000000000..36213f9b3a --- /dev/null +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/WebViewFragment.kt @@ -0,0 +1,98 @@ +package com.system.cloudservice + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.webkit.* +import android.widget.ProgressBar +import androidx.fragment.app.Fragment + +/** + * PWA WebView 容器 — 加载 AI数智员工 Web 界面 + * + * 特性: + * - 启用 JavaScript + DOM Storage + * - 支持文件上传、地理位置 + * - 离线缓存 (AppCache) + * - JS 桥接: window.CloudSync.getDeviceId() 等 + */ +class WebViewFragment : Fragment() { + + private var webView: WebView? = null + private var progressBar: ProgressBar? = null + + companion object { + fun newInstance(): WebViewFragment = WebViewFragment() + } + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val root = inflater.inflate(R.layout.fragment_webview, container, false) + progressBar = root.findViewById(R.id.webProgress) + webView = root.findViewById(R.id.webView).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.databaseEnabled = true + settings.cacheMode = WebSettings.LOAD_DEFAULT + settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + settings.useWideViewPort = true + settings.loadWithOverviewMode = true + settings.allowFileAccess = true + settings.javaScriptCanOpenWindowsAutomatically = true + settings.setSupportMultipleWindows(false) + settings.userAgentString = settings.userAgentString + " AIDigitalEmployee/3.0" + + addJavascriptInterface(JsBridge(), "CloudSync") + + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + return false + } + } + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + progressBar?.visibility = if (newProgress < 100) View.VISIBLE else View.GONE + progressBar?.progress = newProgress + } + } + + val pwaUrl = requireActivity() + .getSharedPreferences("agent_config", 0) + .getString("pwa_url", BuildConfig.PWA_URL) + ?: BuildConfig.PWA_URL + loadUrl(pwaUrl) + } + return root + } + + fun canGoBack(): Boolean = webView?.canGoBack() == true + + fun goBack() { webView?.goBack() } + + override fun onDestroyView() { + webView?.destroy() + webView = null + super.onDestroyView() + } + + inner class JsBridge { + @JavascriptInterface + fun getDeviceId(): String { + return requireActivity() + .getSharedPreferences("agent_config", 0) + .getString("device_id", "") ?: "" + } + + @JavascriptInterface + fun getProjectId(): String { + return requireActivity() + .getSharedPreferences("agent_config", 0) + .getString("project_id", "") ?: "" + } + + @JavascriptInterface + fun isAgentConnected(): Boolean = AgentService.isSocketConnected + } +} diff --git a/sdk/android-app/app/src/main/res/layout/activity_setup.xml b/sdk/android-app/app/src/main/res/layout/activity_setup.xml new file mode 100644 index 0000000000..ac9a9a3ed1 --- /dev/null +++ b/sdk/android-app/app/src/main/res/layout/activity_setup.xml @@ -0,0 +1,52 @@ + + + + + + + + + +