""" 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": []}