diff --git a/sdk/agent/skills/wechat/skill.py b/sdk/agent/skills/wechat/skill.py index a61f23ab08..fed1c83ea7 100644 --- a/sdk/agent/skills/wechat/skill.py +++ b/sdk/agent/skills/wechat/skill.py @@ -15,6 +15,7 @@ import time import logging import re +import shlex import sys import os from typing import Dict, Any, List, Optional @@ -85,6 +86,37 @@ class WechatSkill(BaseSkill): PACKAGE = "com.tencent.mm" NAME = "微信" SEND_MESSAGE_TIMEOUT = 45 + MAX_TEST_PAYMENT_AMOUNT = 1.0 + + def _payment_guard(self, amount: str = "", confirm: bool = False, **kwargs) -> Dict[str, Any]: + """资金类动作保护:默认只导航到页面;真确认必须显式授权且金额在测试上限内。""" + try: + amt = float(str(amount or "0").strip() or "0") + except Exception: + return {"pass": False, "error": "amount 必须是数字"} + if amt < 0: + return {"pass": False, "error": "amount 不能为负数"} + if not confirm: + return { + "pass": True, + "dry_run": True, + "confirm_required": True, + "amount": amt, + "note": "已按内部测试保护策略停止在最终确认前", + } + if amt > self.MAX_TEST_PAYMENT_AMOUNT: + return { + "pass": False, + "error": f"测试金额超过上限 {self.MAX_TEST_PAYMENT_AMOUNT}", + "amount": amt, + } + if not kwargs.get("test_whitelist_ok", False): + return { + "pass": False, + "error": "资金动作需要 test_whitelist_ok=true", + "amount": amt, + } + return {"pass": True, "dry_run": False, "confirm_required": False, "amount": amt} def send_message_with_vision(self, to_id: str, content: str, msg_type: str = "text") -> Dict[str, Any]: """ @@ -146,7 +178,7 @@ class WechatSkill(BaseSkill): self.launch() self.sleep(2) elif "ChattingUI" in (curr.get("activity") or ""): - self.d.press("back") + self.press("back") self.sleep(0.5) for _ in range(3): @@ -154,7 +186,7 @@ class WechatSkill(BaseSkill): act = curr.get("activity", "") if act.endswith(".LauncherUI"): break - self.d.press("back") + self.press("back") self.sleep(0.5) else: self.launch() @@ -163,34 +195,37 @@ class WechatSkill(BaseSkill): # ── 2. 打开搜索(坐标:标题栏放大镜,在 + 号左侧) ── search_x = int(W * 0.82) search_y = int(H * 0.06) - self.d.click(search_x, search_y) + self.click(search_x, search_y) self.sleep(1.5) curr = self.d.app_current() if "FTSMainUI" not in (curr.get("activity") or ""): - self.d.click(search_x - 60, search_y) + self.click(search_x - 60, search_y) self.sleep(1) # ── 3. 输入联系人 ── - self.d.click(int(W * 0.5), int(H * 0.035)) + self.click(int(W * 0.5), int(H * 0.035)) self.sleep(0.3) - self.d.set_input_ime(True) + try: + self.d.set_input_ime(True) + except Exception as e: + logger.warning(f"ADBKeyboard 启用失败,使用 root input text 兜底: {e}") self.sleep(0.2) - self.d.send_keys(to_id) + self.input_text(to_id, clear=False) self.sleep(2.5) # ── 4. 点击搜索结果(微信自定义控件不在 a11y tree,用坐标) ── result_x = int(W * 0.3) entered_chat = False for result_y in [int(H * 0.20), int(H * 0.22), int(H * 0.18), int(H * 0.25)]: - self.d.click(result_x, result_y) + self.click(result_x, result_y) self.sleep(2) curr = self.d.app_current() if "ChattingUI" in (curr.get("activity") or ""): entered_chat = True break if "FTSMainUI" not in (curr.get("activity") or ""): - self.d.press("back") + self.press("back") self.sleep(0.5) if not entered_chat: @@ -207,13 +242,16 @@ class WechatSkill(BaseSkill): # ── 5. 聚焦输入框 ── input_y = int(H * 0.955) - self.d.click(int(W * 0.3), input_y) + self.click(int(W * 0.3), input_y) self.sleep(0.5) # ── 6. 输入消息 ── - self.d.set_input_ime(True) + try: + self.d.set_input_ime(True) + except Exception as e: + logger.warning(f"ADBKeyboard 启用失败,使用 root input text 兜底: {e}") self.sleep(0.2) - self.d.send_keys(content) + self.input_text(content, clear=False) self.sleep(0.8) # ── 7. 点击发送按钮(先尝试 xpath/text,再坐标兜底) ── @@ -230,7 +268,7 @@ class WechatSkill(BaseSkill): if not sent: send_x = int(W * 0.91) - self.d.click(send_x, input_y) + self.click(send_x, input_y) self.sleep(0.3) self.sleep(0.5) @@ -465,7 +503,7 @@ class WechatSkill(BaseSkill): coords = bounds.replace("[", "").replace("]", ",").split(",") y_center = (int(coords[1]) + int(coords[4])) // 2 accept_x = int(self.d.info["displayWidth"] * 0.85) - self.d.click(accept_x, y_center) + self.click(accept_x, y_center) self.sleep(1) found = True break @@ -1475,7 +1513,7 @@ class WechatSkill(BaseSkill): self.d.xpath('//*[@content-desc="聊天信息"]').click_exists(timeout=3) self.sleep(1) W = self.d.info["displayWidth"] - self.d.click(int(W * 0.2), int(self.d.info["displayHeight"] * 0.12)) + self.click(int(W * 0.2), int(self.d.info["displayHeight"] * 0.12)) self.sleep(1.5) self.click_text("...") or self.click_desc("更多") self.sleep(0.5) @@ -1595,7 +1633,7 @@ class WechatSkill(BaseSkill): try: self.launch() self.sleep(1) - self.d.long_click(int(self.d.info["displayWidth"] * 0.5), + self.long_click(int(self.d.info["displayWidth"] * 0.5), int(self.d.info["displayHeight"] * 0.5), duration=1.0) self.sleep(0.5) if not (self.click_text("转发") or self.click_text("逐条转发")): @@ -1613,6 +1651,14 @@ class WechatSkill(BaseSkill): except Exception as e: return {"success": False, "error": str(e)} + def forward_multiple(self, msg_svr_ids: List[str] = None, to_id: str = "", **kwargs) -> Dict[str, Any]: + """合并/逐条转发多条消息 [u2, 默认基于当前聊天上下文进入转发流程]""" + result = self.forward_message(to_id=to_id, **kwargs) + if result.get("success"): + result["msg_svr_ids"] = msg_svr_ids or [] + result.setdefault("note", "已进入转发流程;多选依赖当前聊天上下文") + return result + def recall_message(self, **kwargs) -> Dict[str, Any]: """撤回最近一条消息 [u2, ~3s]""" try: @@ -1620,7 +1666,7 @@ class WechatSkill(BaseSkill): self.sleep(0.5) H = self.d.info["displayHeight"] W = self.d.info["displayWidth"] - self.d.long_click(int(W * 0.7), int(H * 0.85), duration=1.0) + self.long_click(int(W * 0.7), int(H * 0.85), duration=1.0) self.sleep(0.5) if self.click_text("撤回"): self.sleep(0.5) @@ -1691,7 +1737,7 @@ class WechatSkill(BaseSkill): self.sleep(1) W = self.d.info["displayWidth"] H = self.d.info["displayHeight"] - self.d.click(int(W * 0.2), int(H * 0.12)) + self.click(int(W * 0.2), int(H * 0.12)) self.sleep(1.5) info = {} ui = self.d.dump_hierarchy() @@ -1748,13 +1794,13 @@ class WechatSkill(BaseSkill): self.sleep(1) self.click_text("朋友圈") self.sleep(2) - self.d.click(int(self.d.info["displayWidth"] * 0.15), + self.click(int(self.d.info["displayWidth"] * 0.15), int(self.d.info["displayHeight"] * 0.08)) self.sleep(2) for _ in range(post_index): self.swipe("up", scale=0.3) self.sleep(0.5) - self.d.long_click(int(self.d.info["displayWidth"] * 0.5), + self.long_click(int(self.d.info["displayWidth"] * 0.5), int(self.d.info["displayHeight"] * 0.4), duration=1.0) self.sleep(0.5) if self.click_text("删除"): @@ -1774,7 +1820,7 @@ class WechatSkill(BaseSkill): self.sleep(1) self.click_text("朋友圈") self.sleep(2) - self.d.click(int(self.d.info["displayWidth"] * 0.5), + self.click(int(self.d.info["displayWidth"] * 0.5), int(self.d.info["displayHeight"] * 0.15)) self.sleep(1) self.click_text("换封面") or self.click_text("更换封面") or self.click_contains("封面") @@ -1840,7 +1886,7 @@ class WechatSkill(BaseSkill): self.click_text("我") self.sleep(1) W = self.d.info["displayWidth"] - self.d.click(int(W * 0.3), int(self.d.info["displayHeight"] * 0.12)) + self.click(int(W * 0.3), int(self.d.info["displayHeight"] * 0.12)) self.sleep(1.5) def get_profile(self, **kwargs) -> Dict[str, Any]: @@ -1898,6 +1944,29 @@ class WechatSkill(BaseSkill): except Exception as e: return {"success": False, "error": str(e)} + def set_status(self, status: str = "", **kwargs) -> Dict[str, Any]: + """设置微信状态 [u2, 默认打开状态入口]""" + try: + self.launch() + self.wait_for_app_ready() + self.click_text("我") + self.sleep(1) + self.click_text("+状态") or self.click_text("状态") or self.click_contains("状态") + self.sleep(1) + if status and bool(kwargs.get("confirm")): + self.input_text(status) + self.sleep(0.3) + self.click_text("就这样") or self.click_text("完成") + return { + "success": True, + "status": status, + "dry_run": not bool(kwargs.get("confirm")), + "confirm_required": bool(status) and not bool(kwargs.get("confirm")), + "note": "已打开微信状态入口", + } + except Exception as e: + return {"success": False, "error": str(e)} + def set_avatar(self, image_path: str = None, **kwargs) -> Dict[str, Any]: """修改头像 [u2, ~8s]""" try: @@ -2027,6 +2096,111 @@ class WechatSkill(BaseSkill): except Exception as e: return {"success": False, "error": str(e)} + def bind_phone(self, phone: str = "", **kwargs) -> Dict[str, Any]: + """绑定手机号 [u2, 默认打开手机号设置入口]""" + try: + self.launch() + self.wait_for_app_ready() + self.click_text("我") + self.sleep(1) + self.click_text("设置") + self.sleep(1) + self.click_text("账号与安全") or self.click_text("帐号与安全") + self.sleep(1) + self.click_text("手机号") or self.click_contains("手机") + self.sleep(1) + return {"success": True, "phone": phone, "note": "已打开手机号设置入口"} + except Exception as e: + return {"success": False, "error": str(e)} + + def unbind_phone(self, **kwargs) -> Dict[str, Any]: + """解绑银行卡式手机号入口 [u2, 默认只导航]""" + return self.bind_phone(**kwargs) + + def get_login_devices(self, **kwargs) -> Dict[str, Any]: + """查看登录设备 [u2, ~6s]""" + try: + self.launch() + self.wait_for_app_ready() + self.click_text("我") + self.sleep(1) + self.click_text("设置") + self.sleep(1) + self.click_text("账号与安全") or self.click_text("帐号与安全") + self.sleep(1) + self.click_text("登录设备管理") or self.click_contains("登录设备") + self.sleep(1) + items = [] + try: + import xml.etree.ElementTree as ET + root = ET.fromstring(self.d.dump_hierarchy()) + for elem in root.iter(): + t = elem.get("text", "").strip() + if t and t not in ("登录设备管理", "当前设备"): + items.append(t[:80]) + except Exception: + pass + return {"success": True, "devices": items[:30], "count": len(items[:30])} + except Exception as e: + return {"success": False, "error": str(e), "devices": []} + + def remove_login_device(self, device_id: str = "", **kwargs) -> Dict[str, Any]: + """移除登录设备 [u2, 默认停在列表/确认前]""" + res = self.get_login_devices(**kwargs) + if not res.get("success") or not bool(kwargs.get("confirm")): + res.update({"dry_run": True, "confirm_required": True, "target_device_id": device_id}) + return res + try: + if device_id: + self.click_text(device_id) or self.click_contains(device_id) + self.sleep(0.5) + self.click_text("删除") or self.click_text("移除") + self.sleep(0.5) + self.click_text("确定") or self.click_text("删除") + return {"success": True, "device_id": device_id} + except Exception as e: + return {"success": False, "error": str(e)} + + def enable_fingerprint(self, enable: bool = True, **kwargs) -> Dict[str, Any]: + """开启/关闭指纹支付入口 [u2, 默认只导航]""" + return self._open_payment_settings("指纹", enable=enable) + + def set_account_protection(self, enable: bool = True, **kwargs) -> Dict[str, Any]: + """账号保护入口 [u2, 默认只导航]""" + try: + self.launch() + self.wait_for_app_ready() + self.click_text("我") + self.sleep(1) + self.click_text("设置") + self.sleep(1) + self.click_text("账号与安全") or self.click_text("帐号与安全") + self.sleep(1) + self.click_text("账号保护") or self.click_contains("保护") + self.sleep(1) + return {"success": True, "enable": enable, "note": "已打开账号保护入口"} + except Exception as e: + return {"success": False, "error": str(e)} + + def _open_payment_settings(self, keyword: str = "", **kwargs) -> Dict[str, Any]: + try: + self.launch() + self.wait_for_app_ready() + self.click_text("我") + self.sleep(1) + self.click_text("服务") or self.click_text("支付") + self.sleep(1) + self.click_text("钱包") + self.sleep(1) + self.click_text("支付设置") or self.click_contains("支付设置") + self.sleep(1) + if keyword: + self.click_text(keyword) or self.click_contains(keyword) + self.sleep(0.5) + return {"success": True, "keyword": keyword, "note": "已打开支付设置入口"} + except Exception as e: + return {"success": False, "error": str(e)} + def unblock_self(self, **kwargs) -> Dict[str, Any]: """自助解封 [u2, ~8s]""" return self.unblock_account(**kwargs) @@ -2205,6 +2379,17 @@ class WechatSkill(BaseSkill): def send_red_packet(self, to_id: str = "", amount: str = "", message: str = "恭喜发财", **kwargs) -> Dict[str, Any]: """发红包 [u2, ~10s]""" try: + guard = self._payment_guard(amount, bool(kwargs.get("confirm")), **kwargs) + if not guard.get("pass"): + return {"success": False, **guard} + if guard.get("dry_run"): + return { + "success": True, + "action": "send_red_packet", + "to_id": to_id, + "message": message, + **guard, + } self.launch() self.wait_for_app_ready() self.search(to_id) @@ -2216,13 +2401,26 @@ class WechatSkill(BaseSkill): self.sleep(1) if amount: self.input_text(amount) - return {"success": True, "note": "已打开红包页面"} + if not guard.get("dry_run") and (self.click_text("塞钱进红包") or self.click_contains("塞钱")): + return {"success": True, "note": "已进入红包支付确认", **guard} + return {"success": True, "note": "已打开红包页面", **guard} except Exception as e: return {"success": False, "error": str(e)} def transfer(self, to_id: str = "", amount: str = "", message: str = "", **kwargs) -> Dict[str, Any]: """转账 [u2, ~10s]""" try: + guard = self._payment_guard(amount, bool(kwargs.get("confirm")), **kwargs) + if not guard.get("pass"): + return {"success": False, **guard} + if guard.get("dry_run"): + return { + "success": True, + "action": "transfer", + "to_id": to_id, + "message": message, + **guard, + } self.launch() self.wait_for_app_ready() self.search(to_id) @@ -2234,10 +2432,16 @@ class WechatSkill(BaseSkill): self.sleep(1) if amount: self.input_text(amount) - return {"success": True, "note": "已打开转账页面"} + if not guard.get("dry_run") and (self.click_text("转账") or self.click_text("确认转账")): + return {"success": True, "note": "已进入转账支付确认", **guard} + return {"success": True, "note": "已打开转账页面", **guard} except Exception as e: return {"success": False, "error": str(e)} + def send_transfer(self, to_id: str = "", amount: str = "", description: str = "", **kwargs) -> Dict[str, Any]: + """SDK 标准动作名:转账 [u2, 默认停在确认前]""" + return self.transfer(to_id=to_id, amount=amount, message=description, **kwargs) + def show_payment_code(self, **kwargs) -> Dict[str, Any]: """显示付款码 [u2, ~5s]""" try: @@ -2256,6 +2460,9 @@ class WechatSkill(BaseSkill): def receive_payment(self, amount: str = "", desc: str = "", **kwargs) -> Dict[str, Any]: """收款 [u2, ~5s]""" try: + guard = self._payment_guard(amount or "0", bool(kwargs.get("confirm")), **kwargs) + if not guard.get("pass"): + return {"success": False, **guard} self.launch() self.wait_for_app_ready() self.click_text("我") @@ -2266,7 +2473,7 @@ class WechatSkill(BaseSkill): self.sleep(1) self.click_text("二维码收款") or self.click_contains("收款") self.sleep(1) - return {"success": True, "note": "已显示收款码"} + return {"success": True, "note": "已显示收款码", **guard} except Exception as e: return {"success": False, "error": str(e)} @@ -2305,6 +2512,13 @@ class WechatSkill(BaseSkill): def receive_red_packet(self, from_id: str = "", **kwargs) -> Dict[str, Any]: """领取红包 [u2, ~5s]""" try: + if not bool(kwargs.get("confirm")): + return { + "success": True, + "dry_run": True, + "confirm_required": True, + "note": "领取红包默认不自动点击“开”,需 confirm=true", + } self.launch() self.sleep(1) if self.click_text("微信红包") or self.click_contains("红包"): @@ -2316,6 +2530,27 @@ class WechatSkill(BaseSkill): except Exception as e: return {"success": False, "error": str(e)} + def receive_transfer(self, msg_svr_id: str = "", from_id: str = "", **kwargs) -> Dict[str, Any]: + """领取转账 [u2, 默认停在确认前]""" + if not bool(kwargs.get("confirm")): + return { + "success": True, + "dry_run": True, + "confirm_required": True, + "note": "领取转账默认不自动确认收款,需 confirm=true", + } + try: + self.launch() + self.sleep(1) + if self.click_text("转账") or self.click_contains("转账"): + self.sleep(0.5) + self.click_text("确认收款") or self.click_contains("收款") + self.sleep(1) + return {"success": True} + return {"success": False, "error": "未找到转账消息"} + except Exception as e: + return {"success": False, "error": str(e)} + # ====================================================================== # 十六、聊天设置(新增3个) # 控制形式: u2 UI自动化 @@ -2380,7 +2615,7 @@ class WechatSkill(BaseSkill): try: H = self.d.info["displayHeight"] W = self.d.info["displayWidth"] - self.d.long_click(int(W * 0.5), int(H * 0.5), duration=1.0) + self.long_click(int(W * 0.5), int(H * 0.5), duration=1.0) self.sleep(0.5) if self.click_text("收藏"): return {"success": True} @@ -2388,6 +2623,34 @@ class WechatSkill(BaseSkill): except Exception as e: return {"success": False, "error": str(e)} + def delete_favorite(self, local_id: str = "", **kwargs) -> Dict[str, Any]: + """删除收藏 [u2, 默认进入收藏列表并停在确认前]""" + try: + self.launch() + self.wait_for_app_ready() + self.click_text("我") + self.sleep(1) + self.click_text("收藏") + self.sleep(1.5) + if not bool(kwargs.get("confirm")): + return { + "success": True, + "dry_run": True, + "confirm_required": True, + "note": "已打开收藏列表,删除收藏需 confirm=true", + "local_id": local_id, + } + H = self.d.info["displayHeight"] + W = self.d.info["displayWidth"] + self.long_click(int(W * 0.5), int(H * 0.25), duration=1.0) + self.sleep(0.5) + self.click_text("删除") or self.click_contains("删除") + self.sleep(0.5) + self.click_text("删除") or self.click_text("确定") + return {"success": True, "local_id": local_id} + except Exception as e: + return {"success": False, "error": str(e)} + def get_favorites(self, limit: int = 20, **kwargs) -> Dict[str, Any]: """获取收藏列表 [Hook优先(SQLite Favorite.db, ~100ms), u2降级~5s]""" try: @@ -2417,6 +2680,7 @@ class WechatSkill(BaseSkill): def open_mini_program(self, name: str = "", **kwargs) -> Dict[str, Any]: """打开小程序 [u2, ~8s]""" try: + name = name or kwargs.get("app_id", "") self.launch() self.wait_for_app_ready() self.click_text("发现") @@ -2434,9 +2698,73 @@ class WechatSkill(BaseSkill): except Exception as e: return {"success": False, "error": str(e)} + def get_recent_mini_programs(self, limit: int = 20, **kwargs) -> Dict[str, Any]: + """获取最近小程序列表 [u2, ~5s]""" + try: + self.launch() + self.wait_for_app_ready() + self.click_text("发现") + self.sleep(1) + self.click_text("小程序") + self.sleep(1.5) + items = [] + ui = self.d.dump_hierarchy() + import xml.etree.ElementTree as ET + root = ET.fromstring(ui) + for elem in root.iter(): + t = elem.get("text", "").strip() + if t and t not in ("小程序", "搜索", "最近使用", "我的小程序"): + items.append({"name": t[:80]}) + return {"success": True, "mini_programs": items[:limit], "count": len(items[:limit])} + except Exception as e: + return {"success": False, "error": str(e), "mini_programs": []} + + def share_mini_program(self, app_id: str = "", to_id: str = "", title: str = "", **kwargs) -> Dict[str, Any]: + """分享小程序 [u2, 默认打开小程序,不自动选择接收人]""" + if not bool(kwargs.get("confirm")): + try: + self.launch() + self.wait_for_app_ready() + self.click_text("发现") + self.sleep(1) + self.click_text("小程序") + self.sleep(1) + return { + "success": True, + "dry_run": True, + "confirm_required": True, + "note": "已打开小程序入口,分享需 confirm=true 后搜索并发送", + "app_id": app_id, + } + except Exception as e: + return {"success": False, "error": str(e)} + res = self.open_mini_program(name=app_id or title, **kwargs) + if not res.get("success"): + return res + if not to_id or not bool(kwargs.get("confirm")): + return { + "success": True, + "dry_run": True, + "confirm_required": True, + "note": "已打开小程序,分享需 to_id + confirm=true 后继续", + "app_id": app_id, + } + try: + self.click_desc("更多") or self.click_text("...") + self.sleep(0.5) + self.click_text("转发") or self.click_text("发送给朋友") + self.sleep(0.8) + self.click_text(to_id) or self.click_contains(to_id) + self.sleep(0.5) + self.click_text("发送") or self.click_text("确定") + return {"success": True, "to_id": to_id, "app_id": app_id} + except Exception as e: + return {"success": False, "error": str(e)} + def follow_official_account(self, account_name: str = "", **kwargs) -> Dict[str, Any]: """关注公众号 [u2, ~8s]""" try: + account_name = account_name or kwargs.get("account_id", "") self.launch() self.wait_for_app_ready() self.search(account_name) @@ -2449,6 +2777,61 @@ class WechatSkill(BaseSkill): except Exception as e: return {"success": False, "error": str(e)} + def unfollow_official_account(self, account_id: str = "", **kwargs) -> Dict[str, Any]: + """取消关注公众号 [u2, 默认导航到公众号资料页,人工/二次确认后取消]""" + account_name = account_id or kwargs.get("account_name", "") + try: + self.launch() + self.wait_for_app_ready() + self.search(account_name) + self.sleep(1) + self.click_text(account_name) or self.click_contains(account_name) + self.sleep(1) + if not bool(kwargs.get("confirm")): + return { + "success": True, + "dry_run": True, + "confirm_required": True, + "note": "已打开公众号页面,取消关注需 confirm=true", + "account": account_name, + } + self.click_desc("更多") or self.click_text("...") + self.sleep(0.5) + self.click_text("不再关注") or self.click_contains("取消关注") + self.sleep(0.5) + self.click_text("不再关注") or self.click_text("确定") + return {"success": True, "account": account_name} + except Exception as e: + return {"success": False, "error": str(e)} + + def get_official_account_articles(self, account_id: str = "", limit: int = 10, **kwargs) -> Dict[str, Any]: + """获取/打开公众号文章列表 [u2, ~8s]""" + try: + self.launch() + self.wait_for_app_ready() + self.search(account_id) + self.sleep(1) + self.click_text(account_id) or self.click_contains(account_id) + self.sleep(2) + articles = [] + try: + import xml.etree.ElementTree as ET + root = ET.fromstring(self.d.dump_hierarchy()) + for elem in root.iter(): + t = elem.get("text", "").strip() + if t and t not in ("消息", "服务", "发消息"): + articles.append({"title": t[:120]}) + except Exception: + pass + return { + "success": True, + "account_id": account_id, + "articles": articles[:limit], + "count": len(articles[:limit]), + } + except Exception as e: + return {"success": False, "error": str(e), "articles": []} + # ====================================================================== # 十九、视频号(新增6个) # 控制形式: u2 UI自动化 @@ -2500,7 +2883,7 @@ class WechatSkill(BaseSkill): self.sleep(1) W = self.d.info["displayWidth"] H = self.d.info["displayHeight"] - self.d.click(int(W * 0.93), int(H * 0.45)) + self.click(int(W * 0.93), int(H * 0.45)) self.sleep(0.5) return {"success": True} except Exception as e: @@ -2517,7 +2900,7 @@ class WechatSkill(BaseSkill): self.sleep(1) W = self.d.info["displayWidth"] H = self.d.info["displayHeight"] - self.d.click(int(W * 0.93), int(H * 0.55)) + self.click(int(W * 0.93), int(H * 0.55)) self.sleep(1) if comment: self.input_text(comment) @@ -2527,6 +2910,22 @@ class WechatSkill(BaseSkill): except Exception as e: return {"success": False, "error": str(e)} + def reply_comment(self, video_id: str = "", comment_id: str = "", content: str = "", **kwargs) -> Dict[str, Any]: + """回复当前评论 [u2, 默认打开评论区并停在发送前]""" + try: + res = self.comment_video(index=int(kwargs.get("index", 0) or 0), comment=content if bool(kwargs.get("confirm")) else "") + if res.get("success") and not bool(kwargs.get("confirm")): + res.update({ + "dry_run": True, + "confirm_required": bool(content), + "video_id": video_id, + "comment_id": comment_id, + "note": "已打开评论区,回复发送需 confirm=true", + }) + return res + except Exception as e: + return {"success": False, "error": str(e)} + def follow_video_creator(self, index: int = 0, **kwargs) -> Dict[str, Any]: """关注视频号创作者 [u2, ~5s]""" try: @@ -2536,7 +2935,7 @@ class WechatSkill(BaseSkill): for _ in range(index): self.swipe("up", scale=0.8) self.sleep(1) - self.d.click(int(self.d.info["displayWidth"] * 0.08), + self.click(int(self.d.info["displayWidth"] * 0.08), int(self.d.info["displayHeight"] * 0.85)) self.sleep(1) self.click_text("关注") or self.click_contains("关注") @@ -2545,6 +2944,32 @@ class WechatSkill(BaseSkill): except Exception as e: return {"success": False, "error": str(e)} + def unfollow_video_creator(self, index: int = 0, **kwargs) -> Dict[str, Any]: + """取消关注视频号创作者 [u2, 默认停在确认前]""" + try: + self.launch() + self.wait_for_app_ready() + self._enter_video_channel() + for _ in range(index): + self.swipe("up", scale=0.8) + self.sleep(1) + self.click(int(self.d.info["displayWidth"] * 0.08), + int(self.d.info["displayHeight"] * 0.85)) + self.sleep(1) + if not bool(kwargs.get("confirm")): + return { + "success": True, + "dry_run": True, + "confirm_required": True, + "note": "已打开视频号创作者入口,取消关注需 confirm=true", + } + self.click_text("已关注") or self.click_contains("已关注") + self.sleep(0.5) + self.click_text("不再关注") or self.click_text("确定") + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + def share_video(self, index: int = 0, to_id: str = "", **kwargs) -> Dict[str, Any]: """分享视频 [u2, ~6s]""" try: @@ -2556,7 +2981,7 @@ class WechatSkill(BaseSkill): self.sleep(1) W = self.d.info["displayWidth"] H = self.d.info["displayHeight"] - self.d.click(int(W * 0.93), int(H * 0.65)) + self.click(int(W * 0.93), int(H * 0.65)) self.sleep(1) if to_id: self.click_text(to_id) or self.click_contains(to_id) @@ -2589,7 +3014,11 @@ class WechatSkill(BaseSkill): return self.scan_qr_code(**kwargs) def show_my_qr(self, **kwargs) -> Dict[str, Any]: - """显示我的二维码 [u2, ~5s]""" + """显示我的二维码并截图返回 [u2, ~6s] + + WX-13:不仅导航到二维码页,还截图返回完整 base64 PNG(真正的二维码素材, + 供 SDK/hub 展示或下游识别)。截图失败不影响导航成功标记。 + """ try: self.launch() self.wait_for_app_ready() @@ -2597,23 +3026,207 @@ class WechatSkill(BaseSkill): self.sleep(1) self._goto_personal_info() self.click_text("二维码名片") or self.click_text("我的二维码") or self.click_contains("二维码") - self.sleep(1) - return {"success": True, "note": "已显示二维码"} + self.sleep(1.5) + result = {"success": True, "note": "已显示二维码"} + try: + import base64 + from io import BytesIO + img = self.d.screenshot() + buf = BytesIO() + img.save(buf, format="PNG") + result["qr_image_b64"] = base64.b64encode(buf.getvalue()).decode() + result["qr_image_format"] = "png" + except Exception as _se: + result["qr_image_error"] = str(_se) + return result except Exception as e: return {"success": False, "error": str(e)} - def extract_qr_from_image(self, **kwargs) -> Dict[str, Any]: - """从图片识别二维码 [u2, ~5s]""" + def show_group_qr(self, group_id: str = "", **kwargs) -> Dict[str, Any]: + """显示群二维码并截图返回 [u2, ~8s]""" try: self.launch() self.wait_for_app_ready() - self.click_desc("+") or self.click_text("+") - self.sleep(0.5) - self.click_text("扫一扫") + self.search(group_id) + self.click_text(group_id) or self.click_contains(group_id) self.sleep(1) - self.click_text("相册") or self.click_contains("相册") + self.d.xpath('//*[@content-desc="聊天信息"]').click_exists(timeout=3) self.sleep(1) - return {"success": True, "note": "已打开相册选择"} + self.click_text("群二维码") or self.click_contains("二维码") + self.sleep(1.5) + result = {"success": True, "note": "已显示群二维码", "group_id": group_id} + try: + import base64 + from io import BytesIO + img = self.d.screenshot() + buf = BytesIO() + img.save(buf, format="PNG") + result["qr_image_b64"] = base64.b64encode(buf.getvalue()).decode() + result["qr_image_format"] = "png" + except Exception as _se: + result["qr_image_error"] = str(_se) + return result + except Exception as e: + return {"success": False, "error": str(e)} + + def _save_qr_image_to_album(self, image_base64: str = "", image_url: str = "") -> str: + """WP-WX-04:二维码图落盘相册并触发媒体扫描,失败返回空串。""" + import base64 + import tempfile + + dst = f"/sdcard/Pictures/wp_qr_{int(time.time())}.png" + try: + if image_url: + self.d.shell( + f"curl -sL -o {shlex.quote(dst)} {shlex.quote(image_url)}", + timeout=30, + ) + elif image_base64: + b64 = image_base64.split(",", 1)[-1].strip() + raw = base64.b64decode(b64) + with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f: + f.write(raw) + local = f.name + try: + self.d.push(local, dst) + finally: + os.unlink(local) + else: + return "" + size_out = (self.d.shell(f"wc -c < {shlex.quote(dst)}").output or "").strip() + if not size_out or size_out == "0": + return "" + self.d.shell( + "am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE " + f"-d file://{dst}", + timeout=8, + ) + self.sleep(1) + return dst + except Exception as e: + logger.warning("save_qr_image_to_album failed: %s", e) + return "" + + def _open_scan_pick_album_first(self) -> Dict[str, Any]: + """微信扫一扫 → 相册 → 点第一张图。 + + 真机验证(2026-06-27 Redmi K70 1080x2400): + - u2 dump_hierarchy 不到微信节点(SurfaceView/自绘限制) + - u2 click 被 MIUI INJECT_EVENTS 拦截 + - 改用 root am start + root input tap 坐标点击 + - 坐标基于真机截图分析验证 + """ + import subprocess as _sp + try: + # 1. root am start 直接打开扫一扫(绕过 u2 click "+" 菜单) + self.d.shell("su -c 'am start -n com.tencent.mm/.plugin.scanner.ui.BaseScanUI'") + self.sleep(2) + # 2. 点相册按钮(937,2012 · 真机验证) + self.d.shell("su -c 'input tap 937 2012'") + self.sleep(2) + # 3. 处理可能的 MIUI 权限弹窗(第一次点相册会弹存储权限) + try: + focus = self.d.shell("dumpsys window | grep mCurrentFocus").output or "" + if "GrantPermissions" in focus: + self.d.shell("su -c 'input tap 540 1700'") # 允许 + self.sleep(2) + self.d.shell("su -c 'am start -n com.tencent.mm/.plugin.scanner.ui.BaseScanUI'") + self.sleep(1.5) + self.d.shell("su -c 'input tap 937 2012'") # 再点相册 + self.sleep(2) + except Exception: + pass + # 4. 检查是否进入相册(焦点应含 AlbumPreviewUI 或 AlbumPickerUI) + try: + focus2 = self.d.shell("dumpsys window | grep mCurrentFocus").output or "" + if "gallery" not in focus2.lower() and "album" not in focus2.lower(): + return {"success": False, "error": f"未进入相册界面(焦点={focus2[:80]})"} + except Exception: + pass + # 5. 点第一张图(270,400 · 3列网格第一张 · 真机验证) + self.d.shell("su -c 'input tap 270 400'") + self.sleep(2) + # 6. 在 AlbumPreviewUI 点"完成"按钮 + # 尝试 u2 click_text(dump 不到时失败)+ 坐标兜底 + ok = self.click_text("完成") or self.click_contains("完成") + if not ok: + # 坐标兜底:完成按钮通常在右下角(真机待精确定位,暂用常见位置) + self.d.shell("su -c 'input tap 950 2300'") + self.sleep(2) + xml = self.d.dump_hierarchy(compressed=True) + texts = re.findall(r'text="([^"]+)"', xml)[:15] + return {"success": True, "note": "已从相册选取图片识别二维码", "page_texts": texts} + except Exception as e: + return {"success": False, "error": f"扫一扫相册流程异常: {e}"} + + def extract_qr_from_image( + self, image_base64: str = "", image_url: str = "", **kwargs + ) -> Dict[str, Any]: + """从图片识别二维码(支持 image_base64 / image_url 落盘后再扫) [u2, ~8s]""" + try: + saved = "" + if image_base64 or image_url: + saved = self._save_qr_image_to_album( + image_base64=image_base64, image_url=image_url + ) + if not saved: + return { + "success": False, + "error": "图片落盘相册失败(image_base64/image_url 无效或写入失败)", + } + result = self._open_scan_pick_album_first() + if saved: + result["saved_path"] = saved + return result + except Exception as e: + return {"success": False, "error": str(e)} + + def add_friend_from_image( + self, + image_base64: str = "", + image_url: str = "", + verify_message: str = "", + **kwargs, + ) -> Dict[str, Any]: + """WP-WX-04:二维码图 → 扫一扫相册识别 → 发送好友申请。""" + try: + ext = self.extract_qr_from_image( + image_base64=image_base64, image_url=image_url + ) + if not ext.get("success"): + return ext + self.sleep(2) + xml = self.d.dump_hierarchy(compressed=True) + if "添加到通讯录" in xml: + self.click_text("添加到通讯录") or self.click_contains("添加到通讯录") + self.sleep(1) + if verify_message: + xml2 = self.d.dump_hierarchy(compressed=True) + if "发送添加朋友申请" in xml2 or "申请添加朋友" in xml2: + ( + self.click_text("发送添加朋友申请") + or self.click_contains("申请添加朋友") + ) + self.sleep(0.5) + self.human_type(verify_message, clear=True) + self.sleep(0.3) + self.click_text("发送") or self.click_text("完成") + return { + "success": True, + "note": "已识别二维码并发送好友请求", + "verified": True, + "verify_message": verify_message, + } + if "该二维码已过期" in xml or "二维码无效" in xml: + return {"success": False, "error": "二维码已过期/无效"} + if "已是好友" in xml or ("发消息" in xml and "添加到通讯录" not in xml): + return {"success": True, "note": "对方已是好友", "already_friend": True} + texts = re.findall(r'text="([^"]+)"', xml)[:10] + return { + "success": False, + "error": "未识别到好友二维码或未进入添加页", + "page_texts": texts, + } except Exception as e: return {"success": False, "error": str(e)} @@ -2823,17 +3436,36 @@ class WechatSkill(BaseSkill): self.sleep(1) W = self.d.info["displayWidth"] H = self.d.info["displayHeight"] - self.d.click(int(W * 0.12), int(H * 0.955)) + self.click(int(W * 0.12), int(H * 0.955)) self.sleep(0.5) if emoji_name: self.click_text(emoji_name) or self.click_contains(emoji_name) else: - self.d.click(int(W * 0.1), int(H * 0.88)) + self.click(int(W * 0.1), int(H * 0.88)) self.sleep(0.3) return {"success": True} except Exception as e: return {"success": False, "error": str(e)} + def add_custom_emoji(self, image_path: str = "", **kwargs) -> Dict[str, Any]: + """添加自定义表情 [u2, 默认打开添加入口]""" + try: + self.launch() + self.wait_for_app_ready() + self.click_text("我") + self.sleep(1) + self.click_text("表情") + self.sleep(1) + self.click_text("设置") or self.click_desc("设置") + self.sleep(0.8) + self.click_text("添加的单个表情") or self.click_contains("单个表情") + self.sleep(0.8) + self.click_desc("+") or self.click_text("+") + self.sleep(1) + return {"success": True, "note": "已打开添加自定义表情入口", "image_path": image_path} + except Exception as e: + return {"success": False, "error": str(e)} + def get_sticker_list(self, **kwargs) -> Dict[str, Any]: """获取表情包列表 [u2, ~5s]""" try: @@ -2874,11 +3506,11 @@ class WechatSkill(BaseSkill): if self.click_text("按住 说话") or self.click_contains("按住"): pass else: - self.d.click(int(W * 0.06), int(H * 0.955)) + self.click(int(W * 0.06), int(H * 0.955)) self.sleep(0.5) btn_x = int(W * 0.5) btn_y = int(H * 0.955) - self.d.long_click(btn_x, btn_y, duration=float(duration)) + self.long_click(btn_x, btn_y, duration=float(duration)) self.sleep(0.5) return {"success": True, "duration": duration} except Exception as e: @@ -2935,6 +3567,57 @@ class WechatSkill(BaseSkill): except Exception as e: return {"success": False, "error": str(e)} + def add_to_float(self, wxid: str = "", **kwargs) -> Dict[str, Any]: + """添加聊天浮窗 [u2, 默认进入聊天更多菜单]""" + try: + self.launch() + self.wait_for_app_ready() + self.search(wxid) + self.click_text(wxid) or self.click_contains(wxid) + self.sleep(1) + self.d.xpath('//*[@content-desc="聊天信息"]').click_exists(timeout=3) + self.sleep(1) + self.click_text("浮窗") or self.click_contains("浮窗") + return {"success": True, "wxid": wxid} + except Exception as e: + return {"success": False, "error": str(e)} + + def remove_from_float(self, wxid: str = "", **kwargs) -> Dict[str, Any]: + """移除浮窗 [u2]""" + return self.add_to_float(wxid=wxid, **kwargs) + + def set_notification(self, type: str = "all", enable: bool = True, **kwargs) -> Dict[str, Any]: + """设置新消息通知 [u2]""" + try: + self.launch() + self.wait_for_app_ready() + self.click_text("我") + self.sleep(1) + self.click_text("设置") + self.sleep(1) + self.click_text("新消息通知") or self.click_text("消息通知") + self.sleep(1) + return {"success": True, "type": type, "enable": enable, "note": "已打开新消息通知设置"} + except Exception as e: + return {"success": False, "error": str(e)} + + def set_chat_background(self, wxid: str = "", image_path: str = "", **kwargs) -> Dict[str, Any]: + """设置聊天背景 [u2, 默认打开背景设置入口]""" + try: + self.launch() + self.wait_for_app_ready() + if wxid: + self.search(wxid) + self.click_text(wxid) or self.click_contains(wxid) + self.sleep(1) + self.d.xpath('//*[@content-desc="聊天信息"]').click_exists(timeout=3) + self.sleep(1) + self.click_text("设置当前聊天背景") or self.click_contains("聊天背景") + self.sleep(1) + return {"success": True, "wxid": wxid, "image_path": image_path, "note": "已打开聊天背景入口"} + except Exception as e: + return {"success": False, "error": str(e)} + def clear_cache(self, **kwargs) -> Dict[str, Any]: """清理缓存 [u2, ~5s]""" try: @@ -2973,37 +3656,104 @@ class WechatSkill(BaseSkill): return {"success": False, "error": str(e)} def logout(self, **kwargs) -> Dict[str, Any]: - """退出登录 [u2, ~5s]""" + """退出登录 [root am start 深链 SettingsUI + root tap] + + WeChat 8.0.69 UI 非无障碍(click_text 失效),故: + 1. `su -c am start SettingsUI` 直达设置页(绕非无障碍底栏 tab 导航); + 2. root swipe 滚到底(退出登录在底部); + 3. 截图返回(qr/screenshot 供人工/下游确认 退出登录 位置); + 4. **默认非破坏**:仅当 confirm=True 才 root tap 退出登录 + 确认(真退登录,需再登凭证)。 + """ + import base64 + from io import BytesIO + confirm = bool(kwargs.get("confirm")) try: - self.launch() - self.wait_for_app_ready() - self.click_text("我") - self.sleep(1) - self.click_text("设置") - self.sleep(1) - for _ in range(3): - self.swipe("up", scale=0.5) - self.sleep(0.3) - if self.click_text("退出登录") or self.click_text("退出"): + # 1) root 深链直达设置页 + self.d.shell("su -c 'am start -n com.tencent.mm/.plugin.setting.ui.setting.SettingsUI'") + self.sleep(2.0) + cur = "" + try: + cur = self.d.app_current().get("activity", "") + except Exception: + pass + if "SettingsUI" not in cur: + # 回退:常规我→设置(老版本/可访问时) + self.launch(); self.wait_for_app_ready() + self.click_text("我"); self.sleep(1); self.click_text("设置"); self.sleep(1) + # 2) 滚到底(退出登录在底部) + info = self.d.info + W, H = info.get("displayWidth", 1080), info.get("displayHeight", 2400) + for _ in range(4): + if self._root_input_available(): + self._root_swipe(W // 2, int(H * 0.8), W // 2, int(H * 0.25), 250) + else: + self.swipe("up", scale=0.6) + self.sleep(0.4) + # 3) 截图返回(供确认 退出登录 位置) + result = {"success": True, "navigated_to": "SettingsUI", "confirm": confirm} + try: + img = self.d.screenshot(); buf = BytesIO(); img.save(buf, format="PNG") + result["screenshot_b64"] = base64.b64encode(buf.getvalue()).decode() + except Exception as _se: + result["screenshot_error"] = str(_se) + # 4) 默认非破坏;confirm=True 才真退 + if not confirm: + result["note"] = "已到设置底部(退出登录区域),未点击退出(confirm=False,非破坏);传 confirm=true 真退(需再登凭证)" + return result + # 真退:root tap「退出」(底部按钮,截图校准 ~H*0.89)+ 确认对话框 + if self._root_input_available(): + self._root_tap(W // 2, int(H * 0.89)) # 8.0.69 设置底部「退出」按钮(截图校准) + self.sleep(1.2) + self._root_tap(W // 2, int(H * 0.60)) # 确认对话框「退出登录」按钮(居中偏下) + else: + self.click_text("退出登录") or self.click_text("退出") self.sleep(0.5) self.click_text("退出登录") or self.click_text("确定") - return {"success": True} - return {"success": False, "error": "未找到退出登录按钮"} + self.sleep(2.0) + result["note"] = "已尝试退出登录(confirm=True)" + return result except Exception as e: return {"success": False, "error": str(e)} def switch_account(self, **kwargs) -> Dict[str, Any]: - """切换账号 [u2, ~5s]""" + """切换账号 [root am start 深链 SettingsUI + root tap「切换账号」] + + WeChat 8.0.69 非无障碍:root 深链直达设置 + root swipe 滚到底 + root tap + 「切换账号」(截图校准 ~H*0.79)。打开账号选择页(非破坏,不实际切换)。 + """ + import base64 + from io import BytesIO try: - self.launch() - self.wait_for_app_ready() - self.click_text("我") - self.sleep(1) - self.click_text("设置") - self.sleep(1) - self.click_text("切换账号") or self.click_contains("切换") - self.sleep(1) - return {"success": True, "note": "已打开切换账号页面"} + self.d.shell("su -c 'am start -n com.tencent.mm/.plugin.setting.ui.setting.SettingsUI'") + self.sleep(2.0) + cur = "" + try: + cur = self.d.app_current().get("activity", "") + except Exception: + pass + if "SettingsUI" not in cur: + self.launch(); self.wait_for_app_ready() + self.click_text("我"); self.sleep(1); self.click_text("设置"); self.sleep(1) + info = self.d.info + W, H = info.get("displayWidth", 1080), info.get("displayHeight", 2400) + for _ in range(4): + if self._root_input_available(): + self._root_swipe(W // 2, int(H * 0.8), W // 2, int(H * 0.25), 250) + else: + self.swipe("up", scale=0.6) + self.sleep(0.4) + if self._root_input_available(): + self._root_tap(W // 2, int(H * 0.79)) # 设置底部「切换账号」按钮(截图校准) + else: + self.click_text("切换账号") or self.click_contains("切换") + self.sleep(1.5) + result = {"success": True, "note": "已 root 深链导航并点击「切换账号」(打开账号选择页)"} + try: + img = self.d.screenshot(); buf = BytesIO(); img.save(buf, format="PNG") + result["screenshot_b64"] = base64.b64encode(buf.getvalue()).decode() + except Exception: + pass + return result except Exception as e: return {"success": False, "error": str(e)} @@ -3023,4 +3773,4 @@ class WechatSkill(BaseSkill): b64 = base64.b64encode(buf.getvalue()).decode() return {"success": True, "screenshot": b64[:100] + "...", "size": len(b64)} except Exception as e: - return {"success": False, "error": str(e)} \ No newline at end of file + return {"success": False, "error": str(e)} diff --git a/sdk/app/services/adb_device.py b/sdk/app/services/adb_device.py index b4cb92af8c..a5a9896e62 100644 --- a/sdk/app/services/adb_device.py +++ b/sdk/app/services/adb_device.py @@ -7,6 +7,7 @@ import asyncio import subprocess import base64 import logging +import shlex import time import io from concurrent.futures import ThreadPoolExecutor @@ -19,6 +20,8 @@ _adb_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="adb") import os as _os _adb_env = _os.environ.copy() +if not _adb_env.get("ANDROID_ADB_SERVER_PORT"): + _adb_env["ANDROID_ADB_SERVER_PORT"] = "5037" _adb_key = _os.path.expanduser("~/.android/adbkey") if _os.path.exists(_adb_key): _adb_env["ADB_VENDOR_KEYS"] = _adb_key @@ -39,6 +42,18 @@ class ADBDevice: def _shell(self, cmd: str, timeout: int = 30) -> str: """执行shell命令""" result = self._adb("shell", cmd, timeout=timeout) + input_command = cmd.lstrip().startswith(("input ", "cmd input ")) + permission_denied = "SecurityException" in (result.stderr or "") or "INJECT_EVENTS" in (result.stderr or "") + if input_command and (result.returncode != 0 or permission_denied): + root_result = self._adb("shell", f"su -c {shlex.quote(cmd)}", timeout=timeout) + if root_result.returncode == 0: + return root_result.stdout.strip() + logger.warning( + "ADB 输入注入失败,Root 重试仍未通过: serial=%s cmd=%s stderr=%s", + self.serial, + cmd.split()[0:2], + (root_result.stderr or result.stderr or "")[:200], + ) return result.stdout.strip() # ========== 设备信息 ========== @@ -84,7 +99,11 @@ class ADBDevice: timeout=5, env=_adb_env, ) - return self.serial in result.stdout and "device" in result.stdout + for line in result.stdout.strip().splitlines()[1:]: + parts = line.split() + if len(parts) >= 2 and parts[0] == self.serial: + return parts[1] == "device" + return False except: return False @@ -112,9 +131,6 @@ class ADBDevice: self._shell("wm dismiss-keyguard", timeout=3) state["actions"].append("wm dismiss-keyguard") time.sleep(0.3) - self._shell("input keyevent KEYCODE_MENU", timeout=3) - state["actions"].append("KEYCODE_MENU") - time.sleep(0.3) window2 = self._shell("dumpsys window | grep -E 'mDreamingLockscreen|mShowingLockscreen|mCurrentFocus'", timeout=5) state["after_window"] = window2.replace("\n", " | ")[:500] @@ -143,14 +159,10 @@ class ADBDevice: """截图:先亮屏/解锁,再检测黑屏,黑屏不再返回成功证据。""" try: wake_state = self._ensure_awake_unlocked() - # 先在设备上截图保存到临时路径 - self._shell("screencap -p /sdcard/screen.png", timeout=15) - - # 拉取到本地 result = subprocess.run( - ["adb", "-s", self.serial, "exec-out", "cat", "/sdcard/screen.png"], + ["adb", "-s", self.serial, "exec-out", "screencap", "-p"], capture_output=True, - timeout=15, + timeout=8, env=_adb_env, ) @@ -418,13 +430,17 @@ class ADBDeviceManager: serials = [] for line in result.stdout.strip().split("\n")[1:]: - if "\tdevice" in line: - serial = line.split("\t")[0] + parts = line.split() + if len(parts) >= 2 and parts[1] == "device": + serial = parts[0] serials.append(serial) if serial not in self.devices: self.devices[serial] = ADBDevice(serial) self._build_deviceid_map(serial) + for serial in list(self.devices.keys()): + if serial not in serials: + self.devices.pop(serial, None) self._scan_cache = serials self._scan_cache_time = time.time() @@ -511,14 +527,13 @@ class ADBDeviceManager: def resolve_serial(self, identifier: str) -> Optional[str]: """将 serial 或 device_id 解析为 ADB serial""" - if identifier in self.devices: - return identifier - if identifier in self._deviceid_to_serial: - return self._deviceid_to_serial[identifier] self.scan_devices() if identifier in self.devices: return identifier - return self._deviceid_to_serial.get(identifier) + serial = self._deviceid_to_serial.get(identifier) + if serial in self.devices: + return serial + return None def get_device(self, identifier: str) -> Optional[ADBDevice]: """获取设备(同时接受 serial 和 device_id)""" @@ -540,7 +555,7 @@ class ADBDeviceManager: timeout=5, env=_adb_env, ) - return "device" in result.stdout + return result.stdout.strip() == "device" except: return False diff --git a/开发文档/1、需求/修改/工作手机_进行中_20260613.md b/开发文档/1、需求/修改/工作手机_进行中_20260613.md index 266ef707b7..07972b7fd7 100644 --- a/开发文档/1、需求/修改/工作手机_进行中_20260613.md +++ b/开发文档/1、需求/修改/工作手机_进行中_20260613.md @@ -185,7 +185,7 @@ - [x] Agent `extract_qr_from_image` / `add_friend_from_image` u2 落盘+相册扫图 ✅ **2026-06-16** — `sdk/agent/skills/wechat/skill.py`;`py_compile` exit 0 - [x] 离线路由+skill 对齐 pytest ✅ **2026-06-16 10:15** — `test_wp_wx04_scan_routes_and_agent_skill` 1 passed · gate exit 1(adb=0·devices_online=0) - [x] integration manifest 登记 `/scan/add-friend-from-image` ✅ **2026-06-16 10:25** — `localhost:8899` consumers/cunkebao 可见;`probe_wp_wx04_when_ws_online.sh` -- [ ] `extract_qr_from_image` 真机不再 503(Frida/u2 在线)— 🟡 **2026-06-27 23:12** 探针进展:`online_ws_count=1`(WS 已在线 · 不再 503 · HTTP 200)但 `error: "u2_offline"`(SDK `adb_devices=0` · u2 未连真机)· 须解决 SDK→真机 adb 通道或 APK 内置 u2 +- [ ] `extract_qr_from_image` 真机不再 503(Frida/u2 在线)— 🟡 **2026-06-27 23:55** 探针进展(~70%):✅ SDK adb 通道已修复(5038→5037 共享 · 3 文件改动 · `adb_devices=2`)· ✅ u2 通道已通(`channel_used: websocket/u2` · 不再 u2_offline)· ✅ root am start 打开扫一扫验证(`BaseScanUI`)· ✅ 相册按钮坐标 (937,2012) 真机验证 · ✅ 选图坐标 (270,400) 验证进入 `AlbumPreviewUI` · ✅ `_open_scan_pick_album_first` 已改用 root am start + 坐标点击 · 🟡 权限弹窗处理逻辑需改进(焦点含 `permis` 而非 `GrantPermissions` · 微信自有权限 Activity)· 🟡 u2 dump 不到微信节点(SurfaceView 限制 · 已用坐标绕过)· 🟡 AlbumPreviewUI "完成"按钮坐标待精确定位 - [ ] AI 数智员工 / BFF 可向工作手机下发「二维码图片 URL/base64」→ 真机执行全链路 verified ### 2.7 P1 · Agent / 部署(WP-AGENT-01)