feat: publish workphone SDK deployment and API docs

This commit is contained in:
Manus AI
2026-07-14 18:10:52 +08:00
commit 021d633cc1
534 changed files with 122391 additions and 0 deletions

777
sdk/agent/skills/base.py Normal file
View File

@@ -0,0 +1,777 @@
"""
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