Files
workphone-sdk/sdk/agent/skills/soul/skill.py

382 lines
13 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.

"""
Soul 控制技能 - Agent 端实现
功能模块:
1. 瞬间管理 - 发布瞬间(文字/图片/视频)
2. 私聊管理 - 发送消息、获取消息列表
3. 好友/关注 - 获取联系人列表
安卓包名cn.soulapp.android
UI 文案与 resourceId 需根据真机 Soul 版本微调。
@author 卡若
@version 3.0.0
"""
import time
import logging
from typing import Dict, Any, List, Optional
import sys
import os
_agent_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
if _agent_dir not in sys.path:
sys.path.insert(0, _agent_dir)
from skills.base import BaseSkill
logger = logging.getLogger(__name__)
class SoulSkill(BaseSkill):
"""
Soul 控制技能
支持的操作:
- post_moments: 发布瞬间(文字/图片/视频)
- comment_moments: 评论某条瞬间(如回复 123
- send_message: 发送私聊消息
- get_messages: 获取私聊消息列表
- get_contacts: 获取好友/关注列表
"""
PACKAGE = "cn.soulapp.android"
NAME = "Soul"
# Soul 常见 UI 文案(随版本可能变化,真机可调)
TEXT_PUBLISH = "发布"
TEXT_MOMENT = "瞬间"
TEXT_MESSAGE = "消息"
TEXT_ME = "我的"
TEXT_SEND = "发送"
TEXT_SEARCH = "搜索"
TEXT_VIDEO = "视频"
TEXT_PHOTO = "图片"
TEXT_TEXT = "文字"
# ========== 发瞬间 ==========
def post_moments(
self,
content: str,
images: Optional[List[str]] = None,
video_url: Optional[str] = None,
location: Optional[str] = None,
visible_list: Optional[List[str]] = None,
invisible_list: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""
发布 Soul 瞬间(文字/图片/视频)
Args:
content: 文案
images: 图片 URL 或本地路径列表(可选)
video_url: 视频 URL 或本地路径(可选)
location: 位置(可选)
visible_list / invisible_list: 可见/不可见名单可选Soul 若支持)
Returns:
执行结果
"""
try:
self.launch()
self.wait_for_app_ready(timeout=8)
self.sleep(1.5)
# 点击发布入口:常见为底部「+」或「发布」
published = False
for hint in ["+", "发布", "发瞬间", "写瞬间", "加号"]:
if self.click_text(hint) or self.click_contains(hint):
self.sleep(1)
published = True
break
if not published:
# 尝试点击屏幕中央偏下(常见发布按钮位置)
info = self.d.info
self.d.click(info["displayWidth"] // 2, info["displayHeight"] * 4 // 5)
self.sleep(1)
# 选择类型:优先视频 > 图片 > 文字
if video_url:
if self.click_text(self.TEXT_VIDEO) or self.click_contains("视频"):
self.sleep(1)
# TODO: 将 video_url 推到手机并选择文件
logger.warning("Soul post_moments: 视频选择需真机适配路径")
if images:
if self.click_text(self.TEXT_PHOTO) or self.click_contains("图片") or self.click_contains("相册"):
self.sleep(1)
# TODO: 将 images 推到手机并多选
logger.warning("Soul post_moments: 多图选择需真机适配路径")
# 输入文案
if content:
self.sleep(0.5)
self.input_text(content, clear=True)
self.sleep(0.3)
# 点击发布/发送
for send_btn in ["发布", "发送", "发瞬间", "完成", "下一步"]:
if self.click_text(send_btn) or self.click_contains(send_btn):
self.sleep(1.5)
break
logger.info("Soul 瞬间发布流程已执行")
return {
"success": True,
"message_id": f"soul_m_{int(time.time() * 1000)}",
"message": "发布流程已执行,请以真机实际界面为准核对",
}
except Exception as e:
logger.error(f"Soul 发布瞬间失败: {e}")
return {"success": False, "error": str(e)}
# ========== 评论瞬间 ==========
def comment_moments(
self,
user_id: str = "",
comment: str = "",
post_index: int = 0,
reply_to: Optional[str] = None,
) -> Dict[str, Any]:
"""
在 Soul 广场找一条瞬间并评论(如回复 123
Args:
user_id: 可选发瞬间的用户标识Soul 上可不传,按 post_index 选条数)
comment: 评论内容,如 "123"
post_index: 第几条瞬间0 表示第一条
reply_to: 可选,回复某人
Returns:
执行结果
"""
try:
self.launch()
self.wait_for_app_ready(timeout=8)
self.sleep(1.5)
# 进入广场/发现(首页往往是广场流)
for tab in ["广场", "发现", "首页", "推荐"]:
if self.click_text(tab) or self.click_contains(tab):
self.sleep(2)
break
else:
# 默认已在首页,稍等加载
self.sleep(2)
# 点第 post_index 条瞬间0=第一条):通过多次下滑再点,或直接点第一条
for _ in range(post_index):
self.swipe_up(scale=0.4)
self.sleep(0.8)
self.sleep(0.5)
# 点击第一条瞬间卡片进入详情(常见:点击卡片区域或「评论」入口)
info = self.d.info
self.d.click(info["displayWidth"] // 2, info["displayHeight"] // 3)
self.sleep(1.5)
# 点评论按钮
commented = False
for btn in ["评论", "写评论", "说点什么", "留言"]:
if self.click_text(btn) or self.click_contains(btn):
self.sleep(1)
commented = True
break
if not commented:
# 尝试点击屏幕下方评论图标区域
self.d.click(info["displayWidth"] // 2, info["displayHeight"] * 4 // 5)
self.sleep(1)
# 输入评论内容
if comment:
self.input_text(comment, clear=True)
self.sleep(0.3)
# 发送
for send_btn in ["发送", "发布", "评论", "完成"]:
if self.click_text(send_btn) or self.click_contains(send_btn):
self.sleep(1)
break
logger.info(f"Soul 评论瞬间已执行: {comment}")
return {
"success": True,
"message": f"已对第 {post_index + 1} 条瞬间评论: {comment}",
}
except Exception as e:
logger.error(f"Soul 评论瞬间失败: {e}")
return {"success": False, "error": str(e)}
# ========== 私聊 ==========
def send_message(
self,
to_id: str = None,
content: str = "",
msg_type: str = "text",
media_url: Optional[str] = None,
to: str = None,
**kwargs,
) -> Dict[str, Any]:
"""
发送 Soul 私聊消息
Args:
to_id: 对方昵称或 Soul ID
to: to_id 的别名AI Brain 兼容)
content: 消息内容
msg_type: text / image / video
media_url: 图片/视频 URL可选
Returns:
执行结果
"""
if to and not to_id:
to_id = to
if not to_id:
return {"success": False, "error": "缺少 to_id 参数"}
try:
self.launch()
self.wait_for_app_ready(timeout=8)
self.sleep(1)
# 进入消息
if not (self.click_text(self.TEXT_MESSAGE) or self.click_contains("消息")):
self.sleep(0.5)
self.click_text(self.TEXT_MESSAGE) or self.click_contains("消息")
self.sleep(1)
# 搜索或找到对方
if self.click_text(self.TEXT_SEARCH) or self.click_desc(self.TEXT_SEARCH):
self.sleep(0.5)
self.input_text(to_id, clear=True)
self.sleep(1.5)
if self.click_contains(to_id) or self.click_text(to_id):
self.sleep(1)
else:
return {"success": False, "error": f"未找到用户: {to_id}"}
# 输入并发送
self.input_text(content, clear=False)
self.sleep(0.3)
if self.click_text(self.TEXT_SEND) or self.click_contains("发送"):
self.sleep(0.5)
logger.info(f"Soul 私聊已发送: {to_id}")
return {
"success": True,
"message_id": f"soul_{int(time.time() * 1000)}",
}
except Exception as e:
logger.error(f"Soul 发送消息失败: {e}")
return {"success": False, "error": str(e)}
def get_messages(
self,
limit: int = 20,
conversation_id: Optional[str] = None,
count: int = None,
**kwargs,
) -> Dict[str, Any]:
"""
获取 Soul 私聊消息列表(当前会话或列表)
Args:
limit: 条数
count: limit 的别名AI Brain 兼容)
conversation_id: 会话对象(对方昵称或 ID
Returns:
消息列表
"""
if count is not None and limit == 20:
limit = count
try:
self.launch()
self.wait_for_app_ready(timeout=8)
self.sleep(1)
self.click_text(self.TEXT_MESSAGE) or self.click_contains("消息")
self.sleep(1)
if conversation_id:
if self.click_text(self.TEXT_SEARCH) or self.click_desc(self.TEXT_SEARCH):
self.sleep(0.5)
self.input_text(conversation_id, clear=True)
self.sleep(1.5)
self.click_contains(conversation_id) or self.click_text(conversation_id)
self.sleep(1)
messages = []
ui_tree = self.d.dump_hierarchy()
import xml.etree.ElementTree as ET
try:
root = ET.fromstring(ui_tree)
for elem in root.iter():
text = elem.get("text", "")
if text and "发送" not in text and "输入" not in text:
messages.append({"text": text, "timestamp": int(time.time() * 1000)})
except Exception:
pass
return {
"success": True,
"messages": messages[:limit],
"count": len(messages[:limit]),
}
except Exception as e:
logger.error(f"Soul 获取消息失败: {e}")
return {"success": False, "error": str(e), "messages": []}
# ========== 好友/联系人 ==========
def get_contacts(self, limit: int = 200) -> Dict[str, Any]:
"""
获取 Soul 好友/关注列表(从消息列表或「我的」-「关注」解析)
Args:
limit: 数量上限
Returns:
联系人列表
"""
try:
self.launch()
self.wait_for_app_ready(timeout=8)
self.sleep(1)
# 从消息页获取会话列表作为「联系人」近似
self.click_text(self.TEXT_MESSAGE) or self.click_contains("消息")
self.sleep(1.5)
contacts = []
ui_tree = self.d.dump_hierarchy()
import xml.etree.ElementTree as ET
try:
root = ET.fromstring(ui_tree)
for elem in root.iter():
text = elem.get("text", "")
if text and len(text) > 0:
if "消息" not in text and "搜索" not in text and "发送" not in text:
contacts.append({"name": text, "user_id": text})
except Exception:
pass
# 去重并限制数量
seen = set()
unique = []
for c in contacts:
k = c.get("name") or c.get("user_id", "")
if k and k not in seen:
seen.add(k)
unique.append(c)
if len(unique) >= limit:
break
return {
"success": True,
"contacts": unique,
"count": len(unique),
}
except Exception as e:
logger.error(f"Soul 获取联系人失败: {e}")
return {"success": False, "error": str(e), "contacts": []}