Files
workphone-sdk/sdk/agent/skills/base.py

778 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Agent端技能基类含防封拟人化行为层 + 深层防护集成)
"""
import time
import random
import logging
import shlex
from typing import Dict, Any, List, Optional, Tuple
logger = logging.getLogger(__name__)
class BaseSkill:
"""技能基类"""
PACKAGE: str = ""
NAME: str = ""
SPEED_MULTIPLIER: float = 0.3
def __init__(self, device, bus=None, anti_ban_ctx=None):
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")
self._ui_cache = None
self._ui_cache_time = 0
# ==========================================================
# 防封拟人化行为层(所有 Skill 操作必须使用这些方法)
# ==========================================================
@staticmethod
def human_delay(min_sec: float = 0.5, max_sec: float = 3.0):
"""拟人延迟:高斯分布,均值在 min-max 中间,避免机械化固定间隔"""
mean = (min_sec + max_sec) / 2
std = (max_sec - min_sec) / 6
delay = max(min_sec, min(max_sec, random.gauss(mean, std)))
time.sleep(delay)
def human_type(self, text: str, clear: bool = True):
"""拟人输入:逐字输入 + 随机间隔 + 偶尔打错重输"""
if clear:
self.d.clear_text()
self.human_delay(0.2, 0.5)
for i, char in enumerate(text):
self.d.send_keys(char)
time.sleep(random.uniform(0.04, 0.18))
if random.random() < 0.03 and i < len(text) - 1:
wrong = random.choice("abcdefg1234")
self.d.send_keys(wrong)
time.sleep(random.uniform(0.1, 0.25))
self.d.press("del")
time.sleep(random.uniform(0.08, 0.15))
def human_click(self, x: int, y: int, offset: int = 8):
"""拟人点击加微小随机偏移root tap 优先绕 BUG-8"""
dx = random.randint(-offset, offset)
dy = random.randint(-offset, offset)
tx, ty = x + dx, y + dy
if self._root_input_available() and self._root_tap(tx, ty):
self.human_delay(0.05, 0.2)
return
if self._touch:
try:
self._touch.tap(tx, ty, randomize=False)
except Exception:
self.d.click(tx, ty)
else:
self.d.click(tx, ty)
self.human_delay(0.05, 0.2)
def human_swipe(
self,
start: Tuple[int, int],
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))
points = []
for i in range(steps + 1):
t = i / steps
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)))
# root swipe 优先绕 BUG-8否则 shell 加固通道 / u2
if self._root_input_available() and self._root_swipe(start[0], start[1], end[0], end[1], int(duration * 1000)):
return
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):
"""随机浏览:模拟真人无目的翻看,用于养号/填充自然行为"""
try:
info = self.d.info
w = info.get("displayWidth", 1080)
h = info.get("displayHeight", 2400)
except Exception:
w, h = 1080, 2400
end_time = time.time() + duration_sec
while time.time() < end_time:
action = random.choice(["scroll", "pause", "scroll", "pause", "tap_safe"])
if action == "scroll":
self.human_swipe((w // 2, int(h * 0.75)), (w // 2, int(h * 0.25)))
self.human_delay(1.5, 4.0)
elif action == "pause":
self.human_delay(2.0, 6.0)
elif action == "tap_safe":
safe_x = random.randint(int(w * 0.1), int(w * 0.9))
safe_y = random.randint(int(h * 0.2), int(h * 0.7))
self.human_click(safe_x, safe_y, offset=5)
self.human_delay(1.0, 3.0)
self.d.press("back")
self.human_delay(0.5, 1.5)
def natural_behavior_before(self, action: str = "send_message"):
"""自然行为链前置:快速模式下仅做最小延迟"""
multiplier = self.SPEED_MULTIPLIER
high_risk = {"send_message", "add_friend", "post_moments", "batch_send", "mass_send"}
trigger_rate = min(0.9, (0.5 if action in high_risk else 0.3) * multiplier)
if random.random() > trigger_rate:
return
try:
info = self.d.info
w = info.get("displayWidth", 1080)
h = info.get("displayHeight", 2400)
except Exception:
w, h = 1080, 2400
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 * multiplier, 3.0 * multiplier)
self.human_delay(0.5 * multiplier, 1.5 * multiplier)
def natural_behavior_after(self, action: str = "send_message"):
"""自然行为链后置:快速模式下跳过"""
multiplier = self.SPEED_MULTIPLIER
trigger = min(0.8, 0.3 * multiplier)
if random.random() > trigger:
return
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 看到"""
if self.bus:
self.bus.append(self.NAME, message, to_skill=to_skill, data=data or {})
def read_chat(self, since_index: int = 0, from_skill: Optional[str] = None) -> List[Dict[str, Any]]:
"""读取总线上其它 Skill 的消息"""
if not self.bus:
return []
return self.bus.get_messages(since_index=since_index, from_skill=from_skill)
def last_from(self, skill_name: str) -> Optional[Dict[str, Any]]:
"""取指定 Skill 最后一条消息"""
if not self.bus:
return None
return self.bus.get_last(from_skill=skill_name)
# ========== APP控制 ==========
def launch(self) -> bool:
"""启动APP短间隔界面确认由 wait_for_app_ready 负责)"""
try:
self.d.app_start(self.PACKAGE)
time.sleep(0.5)
return True
except Exception as e:
logger.error(f"启动{self.NAME}失败: {e}")
return False
def close(self):
"""关闭APP"""
self.d.app_stop(self.PACKAGE)
def is_running(self) -> bool:
"""检查是否运行"""
try:
return self.d.app_current()['package'] == self.PACKAGE
except:
return False
# ========== UI操作 ==========
# BUG-8 修复MIUI/红米下「USB调试(安全设置)」未开时u2 的 injectInputEvent 被
# INJECT_EVENTS 权限拦截(连 adb shell input 也报 SecurityException。设备已 Root
# 故经 `su -c 'input ...'`root 上下文)绕过该限制(真机验证 exit=0 无异常)。
# 元素点击:先用 u2 dump 取 bounds只读不需注入再 root tap 中心。
def _root_input_available(self) -> bool:
"""检测 root input 是否可用(缓存)。"""
cached = getattr(self, "_root_input_ok", None)
if cached is not None:
return cached
ok = False
try:
out = (self.d.shell("su -c id").output or "")
ok = "uid=0" in out
except Exception:
ok = False
self._root_input_ok = ok
return ok
def _su_input(self, args: str) -> bool:
try:
self.d.shell("su -c 'input %s'" % args)
return True
except Exception as e:
logger.debug(f"root input 失败: {e}")
return False
def _root_text(self, text: str) -> bool:
"""通过 root input text 输入 ASCII 文本,绕过 ADBKeyboard 安装/启用失败。"""
if not self._root_input_available():
return False
try:
# Android input text 对空格使用 %s复杂 Unicode 仍优先走系统/u2 输入法。
escaped = str(text).replace("%", "\\%").replace(" ", "%s")
cmd = "input text " + shlex.quote(escaped)
self.d.shell("su -c %s" % shlex.quote(cmd))
return True
except Exception as e:
logger.debug(f"root text 输入失败: {e}")
return False
def _root_tap(self, x: int, y: int) -> bool:
return self._su_input("tap %d %d" % (int(x), int(y)))
def _root_swipe(self, x1: int, y1: int, x2: int, y2: int, ms: int = 300) -> bool:
return self._su_input("swipe %d %d %d %d %d" % (int(x1), int(y1), int(x2), int(y2), int(ms)))
def _root_key(self, keycode: int) -> bool:
return self._su_input("keyevent %d" % int(keycode))
def _element_center(self, sel):
"""取 u2 元素中心坐标bounds 只读,不需 INJECT_EVENTS"""
try:
if sel.exists:
b = sel.info.get("bounds") or {}
if b:
return (b["left"] + b["right"]) // 2, (b["top"] + b["bottom"]) // 2
except Exception:
pass
return None
def _click_selector(self, sel) -> bool:
"""优先 root tap 元素中心(绕 BUG-8失败回退 u2 click。"""
if self._root_input_available():
c = self._element_center(sel)
if c and self._root_tap(c[0], c[1]):
return True
try:
sel.click()
return True
except Exception:
return False
def click(self, x: int, y: int):
"""点击坐标root 优先绕 BUG-8"""
if self._root_input_available() and self._root_tap(x, y):
return
self.d.click(x, y)
# 常用按键名 → keyevent 码(绕 BUG-8 的 root keyevent
_KEYMAP = {"back": 4, "home": 3, "enter": 66, "menu": 82, "del": 67, "search": 84}
def press(self, key) -> None:
"""按键root keyevent 优先绕 BUG-8兼容 self.d.press 的键名/码。"""
code = self._KEYMAP.get(key, key) if isinstance(key, str) else key
if isinstance(code, int) and self._root_input_available() and self._root_key(code):
return
try:
self.d.press(key)
except Exception:
pass
def long_click(self, x: int, y: int, duration: float = 1.0) -> None:
"""长按root用 input swipe 原地停留 duration 模拟长按,绕 BUG-8"""
if self._root_input_available() and self._root_swipe(x, y, x, y, int(duration * 1000)):
return
try:
self.d.long_click(x, y, duration)
except Exception:
pass
def click_text(self, text: str, timeout: float = 10) -> bool:
"""点击文字"""
try:
sel = self.d(text=text)
if sel.wait(timeout=timeout):
return self._click_selector(sel)
return False
except:
return False
def click_contains(self, text: str, timeout: float = 10) -> bool:
"""点击包含文字的元素"""
try:
sel = self.d(textContains=text)
if sel.wait(timeout=timeout):
return self._click_selector(sel)
return False
except:
return False
def click_id(self, resource_id: str) -> bool:
"""点击资源ID"""
try:
return self._click_selector(self.d(resourceId=resource_id))
except:
return False
def click_desc(self, desc: str) -> bool:
"""点击content-desc"""
try:
return self._click_selector(self.d(description=desc))
except:
return False
def input_text(self, text: str, clear: bool = True):
"""输入文字"""
if clear:
try:
self.d.clear_text()
except Exception:
pass
try:
self.d.send_keys(text)
return
except Exception as e:
logger.warning(f"u2 输入失败,尝试 root input text: {e}")
if not self._root_text(text):
raise RuntimeError("文本输入失败u2/ADBKeyboard 不可用root input text 兜底失败")
def input_to(self, resource_id: str, text: str, clear: bool = True):
"""向指定元素输入"""
elem = self.d(resourceId=resource_id)
if clear:
elem.clear_text()
elem.send_keys(text)
def swipe(self, direction: str, scale: float = 0.8):
"""滑动"""
self.d.swipe_ext(direction, scale=scale)
def swipe_up(self, scale: float = 0.5):
"""向上滑动"""
self.d.swipe_ext("up", scale=scale)
def swipe_down(self, scale: float = 0.5):
"""向下滑动"""
self.d.swipe_ext("down", scale=scale)
# ========== 查找元素 ==========
def exists(self, text: str = None, resource_id: str = None, timeout: float = 3) -> bool:
"""检查元素是否存在"""
if text:
return self.d(text=text).exists(timeout=timeout)
if resource_id:
return self.d(resourceId=resource_id).exists(timeout=timeout)
return False
def wait_for(self, text: str = None, resource_id: str = None, timeout: float = 10) -> bool:
"""等待元素出现"""
if text:
return self.d(text=text).wait(timeout=timeout)
if resource_id:
return self.d(resourceId=resource_id).wait(timeout=timeout)
return False
def wait_gone(self, text: str = None, resource_id: str = None, timeout: float = 10) -> bool:
"""等待元素消失"""
if text:
return self.d(text=text).wait_gone(timeout=timeout)
if resource_id:
return self.d(resourceId=resource_id).wait_gone(timeout=timeout)
return False
# ========== 工具方法 ==========
def screenshot(self) -> bytes:
"""截图"""
return self.d.screenshot(format='raw')
def get_ui_tree(self, max_age: float = 2.0) -> str:
"""获取UI树带缓存max_age 秒内复用上次结果)"""
now = time.time()
if self._ui_cache and (now - self._ui_cache_time) < max_age:
return self._ui_cache
self._ui_cache = self.d.dump_hierarchy()
self._ui_cache_time = now
return self._ui_cache
def invalidate_ui_cache(self):
"""手动失效UI缓存点击/输入后调用)"""
self._ui_cache = None
self._ui_cache_time = 0
def sleep(self, seconds: float):
"""等待(应用速度乘数)"""
time.sleep(seconds * self.SPEED_MULTIPLIER)
def back(self):
"""返回键"""
self.d.press("back")
def home(self):
"""Home键"""
self.d.press("home")
# ========== 新增功能:搜索 ==========
def search(self, keyword: str, wait_time: float = 2.0) -> Dict[str, Any]:
"""
在APP内搜索
Args:
keyword: 搜索关键词
wait_time: 等待应用加载时间
Returns:
搜索结果
"""
try:
self.sleep(wait_time)
# 方法1: 查找搜索框(常见位置)
search_found = False
# 尝试点击搜索图标/文字
search_selectors = [
("text", "搜索"),
("description", "搜索"),
("textContains", "搜索"),
("resourceId", "search"),
]
for selector_type, selector_value in search_selectors:
try:
if selector_type == "text":
if self.d(text=selector_value).exists(timeout=1):
self.d(text=selector_value).click()
search_found = True
break
elif selector_type == "description":
if self.d(description=selector_value).exists(timeout=1):
self.d(description=selector_value).click()
search_found = True
break
elif selector_type == "textContains":
if self.d(textContains=selector_value).exists(timeout=1):
self.d(textContains=selector_value).click()
search_found = True
break
elif selector_type == "resourceId":
if self.d(resourceId=selector_value).exists(timeout=1):
self.d(resourceId=selector_value).click()
search_found = True
break
except:
continue
# 方法2: 如果没找到,尝试点击屏幕上方(常见搜索框位置)
if not search_found:
info = self.d.info
# 点击屏幕上方中间位置
self.d.click(info['displayWidth'] // 2, info['displayHeight'] // 8)
self.sleep(0.5)
# 输入搜索关键词
self.sleep(0.5)
self.input_text(keyword, clear=True)
self.sleep(1)
# 执行搜索(回车或点击搜索按钮)
try:
self.d.press("enter")
except:
# 尝试点击搜索按钮
if self.click_text("搜索") or self.click_text("确定"):
pass
self.sleep(1)
return {
"success": True,
"keyword": keyword,
"message": f"已搜索: {keyword}"
}
except Exception as e:
logger.error(f"搜索失败: {e}")
return {"success": False, "error": str(e), "keyword": keyword}
# ========== 新增功能:复合命令 ==========
def execute_compound_command(self, commands: list) -> Dict[str, Any]:
"""
执行复合命令
Args:
commands: 命令列表,每个命令是 {"action": "...", "params": {...}}
Returns:
执行结果
"""
results = []
for i, cmd in enumerate(commands):
action = cmd.get("action", "")
params = cmd.get("params", {})
try:
if action == "open_app":
result = self.launch()
results.append({"action": action, "success": result})
self.sleep(2) # 等待应用启动
elif action == "search":
keyword = params.get("keyword", "")
result = self.search(keyword)
results.append(result)
elif action == "click":
x = params.get("x", 0)
y = params.get("y", 0)
self.click(x, y)
results.append({"action": action, "success": True})
self.sleep(0.5)
elif action == "swipe":
direction = params.get("direction", "up")
scale = params.get("scale", 0.5)
self.swipe(direction, scale)
results.append({"action": action, "success": True})
self.sleep(0.5)
elif action == "input_text":
text = params.get("text", "")
self.input_text(text)
results.append({"action": action, "success": True})
self.sleep(0.5)
elif action == "back":
self.back()
results.append({"action": action, "success": True})
self.sleep(0.5)
elif action == "home":
self.home()
results.append({"action": action, "success": True})
self.sleep(0.5)
elif action == "wait":
seconds = params.get("seconds", 1)
self.sleep(seconds)
results.append({"action": action, "success": True})
else:
results.append({
"action": action,
"success": False,
"error": f"未知操作: {action}"
})
except Exception as e:
results.append({
"action": action,
"success": False,
"error": str(e)
})
success_count = sum(1 for r in results if r.get("success", False))
return {
"success": success_count == len(commands),
"total": len(commands),
"success_count": success_count,
"results": results
}
# ========== 新增功能:智能等待 ==========
def wait_for_app_ready(self, timeout: float = 5) -> bool:
"""等待APP完全加载截图确认界面间隔 0.2s 轮询)"""
try:
start_time = time.time()
while time.time() - start_time < timeout:
if self.is_running():
if not self.d(className="android.widget.ProgressBar").exists(timeout=0.2):
return True
time.sleep(0.2)
return False
except:
return True
def wait_for_ui(self, hints: list, timeout: float = 3) -> bool:
"""等待任一 UI 文案出现(界面确认后直接往下)"""
start = time.time()
while time.time() - start < timeout:
for h in hints:
if self.exists(h, timeout=0.3):
return True
time.sleep(0.15)
return False
# ========== 新增功能截图和OCR ==========
def screenshot_to_file(self, filepath: str = None) -> str:
"""
截图并保存到文件
Args:
filepath: 保存路径,默认 /sdcard/screenshot_{timestamp}.png
Returns:
文件路径
"""
if not filepath:
filepath = f"/sdcard/screenshot_{int(time.time() * 1000)}.png"
self.d.screenshot(filepath)
return filepath
def get_text_from_screen(self, region: Dict[str, int] = None) -> List[str]:
"""
从屏幕提取文字需要OCR
Args:
region: {"x": 0, "y": 0, "width": 1080, "height": 2400}
Returns:
文字列表
"""
# TODO: 集成OCR库如paddleocr
# 暂时返回空列表
return []
# ========== 新增功能:元素查找增强 ==========
# ========== 新增功能:错误处理和重试 ==========
def retry_operation(self, operation, max_retries: int = 3,
retry_delay: float = 1.0, **kwargs) -> Any:
"""
重试操作
Args:
operation: 操作函数
max_retries: 最大重试次数
retry_delay: 重试延迟(秒)
**kwargs: 传递给操作函数的参数
Returns:
操作结果
"""
last_error = None
for attempt in range(max_retries):
try:
result = operation(**kwargs)
if result and (not isinstance(result, dict) or result.get("success", True)):
return result
# 如果返回失败,继续重试
except Exception as e:
last_error = e
logger.warning(f"操作失败 (尝试 {attempt + 1}/{max_retries}): {e}")
if attempt < max_retries - 1:
self.sleep(retry_delay)
# 所有重试都失败
error_msg = str(last_error) if last_error else "操作失败"
logger.error(f"操作最终失败: {error_msg}")
return {"success": False, "error": error_msg}
def safe_click(self, selector_type: str, selector_value: str,
timeout: float = 10, max_retries: int = 2) -> bool:
"""
安全点击(带重试)
Args:
selector_type: 选择器类型text, description, resourceId等
selector_value: 选择器值
timeout: 超时时间
max_retries: 最大重试次数
Returns:
是否成功
"""
def _click():
if selector_type == "text":
return self.click_text(selector_value, timeout)
elif selector_type == "description":
return self.click_desc(selector_value)
elif selector_type == "resourceId":
return self.click_id(selector_value)
elif selector_type == "textContains":
return self.click_contains(selector_value, timeout)
return False
result = self.retry_operation(_click, max_retries=max_retries)
return result if isinstance(result, bool) else result.get("success", False)
def safe_input(self, text: str, clear: bool = True, max_retries: int = 2) -> bool:
"""
安全输入(带重试)
Args:
text: 输入文本
clear: 是否清空
max_retries: 最大重试次数
Returns:
是否成功
"""
def _input():
self.input_text(text, clear=clear)
return True
result = self.retry_operation(_input, max_retries=max_retries)
return result if isinstance(result, bool) else result.get("success", False)
def find_element_by_multiple(self, **kwargs) -> Any:
"""
通过多种方式查找元素
Args:
text: 文字
textContains: 包含文字
resourceId: 资源ID
description: content-desc
className: 类名
Returns:
元素对象或None
"""
for key, value in kwargs.items():
if not value:
continue
try:
if key == "text":
if self.d(text=value).exists(timeout=1):
return self.d(text=value)
elif key == "textContains":
if self.d(textContains=value).exists(timeout=1):
return self.d(textContains=value)
elif key == "resourceId":
if self.d(resourceId=value).exists(timeout=1):
return self.d(resourceId=value)
elif key == "description":
if self.d(description=value).exists(timeout=1):
return self.d(description=value)
elif key == "className":
if self.d(className=value).exists(timeout=1):
return self.d(className=value)
except:
continue
return None