diff --git a/sdk/agent/agent.py b/sdk/agent/agent.py index 3c74b73426..463e2c66a7 100644 --- a/sdk/agent/agent.py +++ b/sdk/agent/agent.py @@ -196,9 +196,22 @@ class WorkPhoneAgent: self.ws = None if self.running: + self.reconnect_attempts += 1 + # 断网时先尝试恢复网络(通过 Hawk 模块,仅合法操作:开 WiFi、打开设置、连接已保存网络) + if self.d: + try: + from hawk import try_reconnect_network + nr = try_reconnect_network(self.d) + if nr.get("success"): + logger.info(f"🌐 网络已恢复: {nr.get('message', '')}") + else: + logger.info(f"🌐 网络恢复: {nr.get('message', '')}") + except ImportError: + pass + except Exception as e: + logger.debug(f"网络恢复尝试异常: {e}") # 指数退避重连 delay = self._get_reconnect_delay() - self.reconnect_attempts += 1 logger.info(f"⏳ {delay:.1f}秒后第{self.reconnect_attempts}次重连...") await asyncio.sleep(delay) @@ -623,6 +636,10 @@ class WorkPhoneAgent: result = executor.execute_xhs_task(task) elif "闲鱼" in task: result = executor.execute_xianyu_task(task) + elif "Soul" in task or "soul" in task.lower() or "灵魂" in task: + result = executor.execute_soul_task(task) + elif any(k in task for k in ("连接网络", "恢复网络", "打开WiFi", "打开网络", "断网", "连不上网", "上网")): + result = executor.execute_network_reconnect() else: result = executor.execute_command(task) if result is None: @@ -698,6 +715,7 @@ class WorkPhoneAgent: "u2", "screenshot", "click", "input", "swipe", "ui_tree", "app_control", "skill_execute", "skill_wechat", "skill_douyin", "skill_xhs", "skill_xianyu", + "skill_network_reconnect", "hawk", "event", "device_request" ] diff --git a/sdk/agent/docs/AGENT_HAWK.md b/sdk/agent/docs/AGENT_HAWK.md new file mode 100644 index 0000000000..447995f679 --- /dev/null +++ b/sdk/agent/docs/AGENT_HAWK.md @@ -0,0 +1,41 @@ +# Agent 与 Hawk 边界与协同说明 + +## 代码隔离 + +| 板块 | 目录 | 职责 | +|------|------|------| +| **Agent** | `agent.py`, `skill_executor.py`, `skills/*` | 业务逻辑、技能执行、WebSocket 连接与重连策略、与服务器通信 | +| **Hawk** | `hawk/*` | 仅负责网络层:检测网络、开/关 WiFi、打开系统网络设置、连接已保存网络、重试等 | + +- Agent 不实现 Hawk 的细节(如 `svc wifi`、打开设置页 UI);Hawk 不依赖 Agent 或技能总线。 +- 协同仅通过**接口**:Agent 调用 `hawk.try_reconnect_network(device)` 与 `hawk.is_network_available(device)`。 + +## 协同方式 + +1. **断网时自动恢复网络** + Agent 在 WebSocket 断开且准备重连前,若存在设备句柄 `self.d`,会调用 `try_reconnect_network(self.d)`,由 Hawk 在设备上执行合法网络恢复(开 WiFi、打开设置、连接已保存网络等),再继续指数退避重连。 + +2. **自然语言触发** + 用户说「连接网络」「恢复网络」「打开 WiFi」「断网了」等时,Agent 通过 SkillExecutor 调用 `network_reconnect` 技能,该技能内部委托 Hawk 执行上述合法操作。 + +## 网络恢复行为(仅合法) + +Hawk 只做以下操作,**不包含且永不包含**破解密码、未授权访问等任何违规行为: + +- 通过系统 shell 开启 WiFi:`svc wifi enable` +- 打开系统「设置」→「网络/WLAN/Wi‑Fi」页面 +- 在设置页中点击「已保存」「已知网络」等,尝试连接**已保存**网络 +- 若仍无网络,返回说明「请在本机选择已保存网络或手动输入密码连接」 + +用户若需连接新网络或输入密码,仅在系统设置界面内**手动**完成。 + +## 依赖关系 + +``` +Agent (agent.py, skills/) + └─ 可选依赖 Hawk:from hawk import try_reconnect_network +Hawk (hawk/) + └─ 仅依赖 uiautomator2 设备对象,无 Agent 依赖 +``` + +Hawk 缺失时,Agent 仍可运行;断网时仅跳过「尝试恢复网络」步骤,直接进入重连退避。 diff --git a/sdk/agent/hawk/__init__.py b/sdk/agent/hawk/__init__.py new file mode 100644 index 0000000000..6845410ec7 --- /dev/null +++ b/sdk/agent/hawk/__init__.py @@ -0,0 +1,15 @@ +""" +Hawk - 设备端网络与连接恢复模块(与 Agent 代码隔离、协同) + +职责边界: +- Agent:业务逻辑、技能执行、WebSocket 连接与重连策略。 +- Hawk:仅负责「网络层」可做的合法操作:检测网络状态、打开系统网络设置、 + 开启/关闭 WiFi、触发连接已保存网络、重试等。不涉及任何未授权访问或破解行为。 + +协同方式:Agent 在断网/重连前可调用 Hawk.try_reconnect_network(device), +由 Hawk 在设备上执行合法网络恢复操作,再由 Agent 继续重连。 +""" + +from hawk.network import try_reconnect_network, is_network_available + +__all__ = ["try_reconnect_network", "is_network_available"] diff --git a/sdk/agent/hawk/network.py b/sdk/agent/hawk/network.py new file mode 100644 index 0000000000..00a40eaaea --- /dev/null +++ b/sdk/agent/hawk/network.py @@ -0,0 +1,109 @@ +""" +Hawk 网络层 - 仅做合法网络恢复:开 WiFi、打开设置、连接已保存网络、重试 + +不包含且永不包含:破解密码、未授权访问、绕过认证等任何违规操作。 +若设备上无可用已保存网络,仅打开系统 WiFi 设置供用户手动连接或输入密码。 +""" + +import logging +import time +from typing import Dict, Any + +logger = logging.getLogger(__name__) + + +def _shell_out(device, cmd: str) -> str: + """执行 shell 并返回输出字符串(兼容 u2 的 AdbDevice)。""" + try: + r = device.shell(cmd) + return (getattr(r, "output", None) or str(r) or "").strip() + except Exception: + return "" + + +def is_network_available(device) -> bool: + """检测当前是否有网络(ping 或 shell 查连接状态)。""" + try: + out = _shell_out(device, "ping -c 1 -W 2 8.8.8.8 2>/dev/null && echo ok || echo fail") + if out and "ok" in out: + return True + out = _shell_out(device, "dumpsys wifi | grep -i 'mWifiInfo' | head -1") + return "SSID:" in out + except Exception as e: + logger.debug(f"is_network_available: {e}") + return False + + +def try_reconnect_network(device, max_steps: int = 5) -> Dict[str, Any]: + """ + 在设备上尝试恢复网络(仅合法操作): + 1. 开 WiFi(若被关) + 2. 打开系统「网络/WiFi」设置页,便于连接已保存网络或用户手动输入 + 3. 可选:通过 UI 点击「已保存网络」进行连接(不输入新密码、不破解) + 4. 等待后再次检测网络 + + device: uiautomator2 设备对象(可为 None,此时只返回建议) + """ + result = {"success": False, "message": "", "steps": []} + + if device is None: + result["message"] = "无设备句柄,无法执行网络恢复" + return result + + try: + # Step 1: 尝试通过 shell 开启 WiFi(合法系统 API) + try: + device.shell("svc wifi enable 2>/dev/null") + result["steps"].append("svc wifi enable") + time.sleep(2) + except Exception as e: + result["steps"].append(f"svc wifi enable 失败: {e}") + + if is_network_available(device): + result["success"] = True + result["message"] = "WiFi 已开启且检测到网络" + return result + + # Step 2: 打开系统设置中的网络/WiFi 页面,供用户连接或输入密码 + try: + device.app_start("com.android.settings") + time.sleep(1.5) + # 尝试进入 WLAN / 网络 / WiFi 设置 + for text in ["WLAN", "网络", "Wi‑Fi", "WiFi", "互联网"]: + try: + if device(text=text).wait(timeout=2): + device(text=text).click() + result["steps"].append(f"打开设置并点击 {text}") + break + except Exception: + continue + time.sleep(2) + except Exception as e: + result["steps"].append(f"打开设置失败: {e}") + + # Step 3: 若有「已保存」或「已知网络」列表,可点击第一个尝试连接(仅限已保存,不涉及破解) + try: + for hint in ["已保存", "已知网络", "已连接", "连接"]: + if device(textContains=hint).wait(timeout=1.5): + device(textContains=hint).click() + result["steps"].append(f"点击包含「{hint}」的项") + time.sleep(2) + break + except Exception: + pass + + # Step 4: 再次检测 + time.sleep(2) + if is_network_available(device): + result["success"] = True + result["message"] = "网络已恢复(已连接或已打开设置)" + else: + result["message"] = "已打开 WiFi 设置,请在本机选择已保存网络或手动输入密码连接" + result["steps"].append("建议用户在设置页手动连接") + + except Exception as e: + logger.exception("try_reconnect_network 异常") + result["message"] = str(e) + result["steps"].append(f"异常: {e}") + + return result diff --git a/sdk/agent/hook/README.md b/sdk/agent/hook/README.md new file mode 100644 index 0000000000..85dae61682 --- /dev/null +++ b/sdk/agent/hook/README.md @@ -0,0 +1,54 @@ +# 微信 Hook 设备端脚本(Root 设备) + +本目录用于 Root 安卓设备(如红米11)部署微信 Hook 脚本。 + +## 文件说明 + +- `wechat_hook_v1.js`:微信 Hook 初版脚本(含 RPC + 消息事件上报骨架) +- `setup_redmi11.sh`:红米11 一键初始化脚本 + +## 快速执行 + +```bash +cd sdk/agent/hook +bash setup_redmi11.sh +``` + +## 与后端接口联动 + +1. 上传脚本 + +```bash +curl -s -X POST "http://localhost:8899/api/v3/scripts?module_id=wechat_hook_v1&version=1.0.0" \ + -F "file=@wechat_hook_v1.js" | python3 -m json.tool +``` + +2. 部署脚本到设备 + +```bash +curl -s -X POST "http://localhost:8899/api/v3/scripts/wechat_hook_v1_1.0.0/deploy" \ + -H "Content-Type: application/json" \ + -d '{"device_ids":[""],"auto_reload":true}' | python3 -m json.tool +``` + +3. 检查设备模块状态 + +```bash +curl -s "http://localhost:8899/api/v3/devices//modules" | python3 -m json.tool +``` + +4. 查看 Hook 事件流(WebSocket) + +`ws://localhost:8899/api/v3/hook/events/stream` + +## 说明 + +当前 `wechat_hook_v1.js` 已具备: +- RPC通信骨架 +- 进程信息读取 +- SQLite消息表写入监听(事件上报) + +下一步按微信版本补齐: +- `sendMessage` 真正发消息 Hook 点 +- 联系人读取 DB 结构适配 +- 好友请求/朋友圈/群管理扩展 diff --git a/sdk/agent/hook/setup_redmi11.sh b/sdk/agent/hook/setup_redmi11.sh new file mode 100755 index 0000000000..7698a0c610 --- /dev/null +++ b/sdk/agent/hook/setup_redmi11.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# 红米11 Root 设备 Hook 环境初始化脚本 +# 用法: bash setup_redmi11.sh + +set -euo pipefail + +DEVICE_ID="${1:-}" +if [[ -z "$DEVICE_ID" ]]; then + echo "用法: bash setup_redmi11.sh " + exit 1 +fi + +echo "== 1) 基础连通检查 ==" +adb -s "$DEVICE_ID" get-state +adb -s "$DEVICE_ID" shell getprop ro.product.model +adb -s "$DEVICE_ID" shell getprop ro.build.version.release + +echo "== 2) Root 权限检查 ==" +adb -s "$DEVICE_ID" shell "id -u; which su || true" + +echo "== 3) push Hook 脚本 ==" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +adb -s "$DEVICE_ID" push "$SCRIPT_DIR/wechat_hook_v1.js" /data/local/tmp/wechat_hook_v1.js + +echo "== 4) frida-server 检查 ==" +adb -s "$DEVICE_ID" shell "frida-server --version || true" + +echo "== 5) 启动微信并验证包名 ==" +adb -s "$DEVICE_ID" shell "monkey -p com.tencent.mm -c android.intent.category.LAUNCHER 1" >/dev/null 2>&1 || true +sleep 2 +adb -s "$DEVICE_ID" shell "dumpsys window | grep -E 'mCurrentFocus|mFocusedApp' | head -2" + +echo "== 6) SDK Hook 探测接口建议调用 ==" +echo "curl -s http://localhost:8899/api/v3/devices/$DEVICE_ID/modules | python3 -m json.tool" + +echo "完成:红米11 Hook 基础环境就绪(脚本已推送)" diff --git a/sdk/agent/hook/wechat_hook_v1.js b/sdk/agent/hook/wechat_hook_v1.js new file mode 100644 index 0000000000..795d5f5619 --- /dev/null +++ b/sdk/agent/hook/wechat_hook_v1.js @@ -0,0 +1,101 @@ +// wechat_hook_v1.js +'use strict'; + +const CONFIG = { + WECHAT_PACKAGE: 'com.tencent.mm', + LOG_LEVEL: 'info', +}; + +function log(level, tag, message, extra) { + send({ + type: 'log', + level: level, + tag: tag, + message: String(message || ''), + extra: extra || {}, + timestamp: Date.now(), + }); +} + +function safeToString(v) { + if (v === null || v === undefined) return ''; + try { + return v.toString(); + } catch (_) { + return ''; + } +} + +rpc.exports = { + ping: function () { + return 'pong from wechat_hook_v1'; + }, + + getProcessInfo: function () { + return { + pid: Process.id, + arch: Process.arch, + moduleCount: Process.enumerateModules().length, + }; + }, + + // 当前版本先提供统一入口,实际发送逻辑在后续版本按微信版本细化 + sendMessage: function (params) { + const toId = (params && params.to_id) || ''; + const content = (params && params.content) || ''; + log('info', 'rpc', 'sendMessage called', { to_id: toId, content_len: content.length }); + return { + success: false, + error: 'sendMessage 需按当前微信版本补齐具体Hook点,已完成RPC通道与事件上报', + to_id: toId, + }; + }, + + getContacts: function (params) { + const limit = (params && params.limit) || 100; + return { + success: true, + data: [], + limit: limit, + note: '联系人读取建议通过DB Hook实现(EnMicroMsg.db)', + }; + }, +}; + +Java.perform(function () { + log('info', 'init', 'wechat_hook_v1 loaded'); + + try { + const SQLiteDatabase = Java.use('android.database.sqlite.SQLiteDatabase'); + SQLiteDatabase.insert.overload( + 'java.lang.String', + 'java.lang.String', + 'android.content.ContentValues' + ).implementation = function (table, nullColumnHack, values) { + const result = this.insert(table, nullColumnHack, values); + try { + const tableName = safeToString(table); + if (tableName === 'message' || tableName === 'rconversation') { + const talker = safeToString(values.getAsString(Java.use('java.lang.String').$new('talker'))); + const content = safeToString(values.getAsString(Java.use('java.lang.String').$new('content'))); + if (talker || content) { + send({ + type: 'hook_event', + event_type: 'message_received', + platform: 'wechat', + payload: { + from_id: talker, + content: content, + }, + timestamp: new Date().toISOString(), + }); + } + } + } catch (_) {} + return result; + }; + log('info', 'hook', 'SQLite message hook enabled'); + } catch (e) { + log('warn', 'hook', 'SQLite hook unavailable', { error: String(e) }); + } +}); diff --git a/sdk/agent/package.sh b/sdk/agent/package.sh index 4086d25092..d063c176ba 100755 --- a/sdk/agent/package.sh +++ b/sdk/agent/package.sh @@ -18,7 +18,8 @@ echo "📦 打包 Agent 代码..." mkdir -p "$TEMP_DIR/agent/skills/wechat" \ "$TEMP_DIR/agent/skills/douyin" \ "$TEMP_DIR/agent/skills/xhs" \ - "$TEMP_DIR/agent/skills/xianyu" + "$TEMP_DIR/agent/skills/xianyu" \ + "$TEMP_DIR/agent/hawk" # 复制核心文件 for f in agent.py skill_executor.py skill_bus.py error_handler.py vision_helper.py voice_agent.py requirements.txt config.json.example; do @@ -33,6 +34,18 @@ cp "$SCRIPT_DIR/skills/base.py" "$TEMP_DIR/agent/skills/" cp "$SCRIPT_DIR/skills/voice_control.py" "$TEMP_DIR/agent/skills/" cp "$SCRIPT_DIR/skills/app_manager.py" "$TEMP_DIR/agent/skills/" cp "$SCRIPT_DIR/skills/search.py" "$TEMP_DIR/agent/skills/" +[ -f "$SCRIPT_DIR/skills/network_reconnect.py" ] && cp "$SCRIPT_DIR/skills/network_reconnect.py" "$TEMP_DIR/agent/skills/" + +# Hawk 模块(网络层,与 Agent 隔离) +for f in __init__.py network.py; do + [ -f "$SCRIPT_DIR/hawk/$f" ] && cp "$SCRIPT_DIR/hawk/$f" "$TEMP_DIR/agent/hawk/" +done + +# Hook 模块(Frida Hook 通道) +mkdir -p "$TEMP_DIR/agent/hook/scripts" +for f in README.md setup_redmi11.sh wechat_hook_v1.js; do + [ -f "$SCRIPT_DIR/hook/$f" ] && cp "$SCRIPT_DIR/hook/$f" "$TEMP_DIR/agent/hook/" +done for sub in wechat douyin xhs xianyu; do if [ -d "$SCRIPT_DIR/skills/$sub" ]; then diff --git a/sdk/agent/skill_executor.py b/sdk/agent/skill_executor.py index 7773d82754..d842e1b3ae 100644 --- a/sdk/agent/skill_executor.py +++ b/sdk/agent/skill_executor.py @@ -116,6 +116,8 @@ class SkillExecutor: current_skill = self.skills.get("xhs") elif package == "com.taobao.idlefish": current_skill = self.skills.get("xianyu") + elif package == "cn.soulapp.android": + current_skill = self.skills.get("soul") else: current_skill = None self.skill_bus.append( @@ -268,3 +270,20 @@ class SkillExecutor: if not xianyu_skill: xianyu_skill = SKILL_REGISTRY["xianyu"](self.device) return self.execute_command(task) + + def execute_soul_task(self, task: str) -> Dict[str, Any]: + """执行 Soul 任务""" + soul_skill = self.skills.get("soul") + if not soul_skill: + soul_skill = SKILL_REGISTRY["soul"](self.device) + return self.execute_command(task) + + def execute_network_reconnect(self) -> Dict[str, Any]: + """执行网络恢复(委托 Hawk:开 WiFi、打开设置、连接已保存网络等合法操作)""" + net_skill = self.skills.get("network_reconnect") + if not net_skill: + try: + net_skill = SKILL_REGISTRY["network_reconnect"](self.device, self.skill_bus) + except KeyError: + return {"success": False, "error": "network_reconnect 技能未注册"} + return net_skill.try_reconnect() diff --git a/sdk/agent/skills/__init__.py b/sdk/agent/skills/__init__.py index eebc3a5d67..a685898449 100644 --- a/sdk/agent/skills/__init__.py +++ b/sdk/agent/skills/__init__.py @@ -15,17 +15,21 @@ def _build_skill_registry(): from skills.douyin.skill import DouyinSkill from skills.xhs.skill import XhsSkill from skills.xianyu.skill import XianyuSkill + from skills.soul.skill import SoulSkill from skills.voice_control import VoiceControlSkill from skills.app_manager import AppManagerSkill from skills.search import SearchSkill + from skills.network_reconnect import NetworkReconnectSkill return { "wechat": WechatSkill, "douyin": DouyinSkill, "xhs": XhsSkill, "xianyu": XianyuSkill, + "soul": SoulSkill, "voice_control": VoiceControlSkill, "app_manager": AppManagerSkill, "search": SearchSkill, + "network_reconnect": NetworkReconnectSkill, } @@ -57,6 +61,9 @@ def get_skill(name: str): elif name == "xianyu": from skills.xianyu.skill import XianyuSkill return XianyuSkill + elif name == "soul": + from skills.soul.skill import SoulSkill + return SoulSkill else: raise ImportError(f"未知技能: {name}") @@ -65,5 +72,6 @@ def get_skill(name: str): from skills.voice_control import VoiceControlSkill from skills.app_manager import AppManagerSkill from skills.search import SearchSkill +from skills.network_reconnect import NetworkReconnectSkill _ensure_registry() diff --git a/sdk/agent/skills/network_reconnect.py b/sdk/agent/skills/network_reconnect.py new file mode 100644 index 0000000000..49bc45f741 --- /dev/null +++ b/sdk/agent/skills/network_reconnect.py @@ -0,0 +1,60 @@ +""" +网络连接恢复技能 - 断网时尝试恢复上网(仅合法操作) + +通过 Hawk 模块执行:开 WiFi、打开系统网络设置、连接已保存网络等。 +不涉及且永不涉及:破解密码、未授权访问等任何违规行为。 +若设备无可用已保存网络,仅打开 WiFi 设置供用户手动连接或输入密码。 +""" + +import logging +import sys +import os +from typing import Dict, Any + +# 兼容独立运行和包导入 +_agent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _agent_dir not in sys.path: + sys.path.insert(0, _agent_dir) + +try: + from skills.base import BaseSkill +except ImportError: + from ..base import BaseSkill + +logger = logging.getLogger(__name__) + + +class NetworkReconnectSkill(BaseSkill): + """网络连接恢复技能 - 与 Hawk 协同,仅做合法网络恢复""" + + PACKAGE = "" + NAME = "网络恢复" + + def try_reconnect(self) -> Dict[str, Any]: + """ + 尝试恢复网络:委托 Hawk 执行开 WiFi、打开设置、连接已保存网络等合法操作。 + 若仍连不上,返回建议(如「请在设置中手动连接或输入密码」)。 + """ + try: + from hawk import try_reconnect_network, is_network_available + + if self.d is None: + return {"success": False, "message": "无设备,无法执行网络恢复", "steps": []} + + if is_network_available(self.d): + return {"success": True, "message": "当前已有网络", "steps": ["检测到网络"]} + + result = try_reconnect_network(self.d) + if self.bus: + self.bus.append(self.NAME, result.get("message", ""), data=result) + return result + except ImportError as e: + logger.warning("Hawk 模块未安装,无法执行网络恢复: %s", e) + return { + "success": False, + "message": "Hawk 模块不可用,请检查网络或手动连接 WiFi", + "steps": [], + } + except Exception as e: + logger.exception("网络恢复失败") + return {"success": False, "message": str(e), "steps": []} diff --git a/sdk/agent/skills/soul/__init__.py b/sdk/agent/skills/soul/__init__.py new file mode 100644 index 0000000000..9d2544eed3 --- /dev/null +++ b/sdk/agent/skills/soul/__init__.py @@ -0,0 +1,4 @@ +# Soul 技能模块 - 发瞬间、私聊、获取好友等 +from .skill import SoulSkill + +__all__ = ["SoulSkill"] diff --git a/sdk/agent/skills/soul/skill.py b/sdk/agent/skills/soul/skill.py new file mode 100644 index 0000000000..e32e9aa33d --- /dev/null +++ b/sdk/agent/skills/soul/skill.py @@ -0,0 +1,369 @@ +""" +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, + content: str, + msg_type: str = "text", + media_url: Optional[str] = None, + ) -> Dict[str, Any]: + """ + 发送 Soul 私聊消息 + + Args: + to_id: 对方昵称或 Soul ID + content: 消息内容 + msg_type: text / image / video + media_url: 图片/视频 URL(可选) + + Returns: + 执行结果 + """ + 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, + ) -> Dict[str, Any]: + """ + 获取 Soul 私聊消息列表(当前会话或列表) + + Args: + limit: 条数 + conversation_id: 会话对象(对方昵称或 ID) + + 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) + + 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": []} diff --git a/sdk/android-app/.settings/org.eclipse.buildship.core.prefs b/sdk/android-app/.settings/org.eclipse.buildship.core.prefs index 5c54c17c46..840cb287b2 100644 --- a/sdk/android-app/.settings/org.eclipse.buildship.core.prefs +++ b/sdk/android-app/.settings/org.eclipse.buildship.core.prefs @@ -5,7 +5,7 @@ connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER) connection.project.dir= eclipse.preferences.version=1 gradle.user.home= -java.home=/Library/Java/JavaVirtualMachines/temurin-25.jdk/Contents/Home +java.home=/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home jvm.arguments= offline.mode=false override.workspace.settings=true diff --git a/sdk/android-app/README.md b/sdk/android-app/README.md index c4fa8bd70a..7d6b03d850 100644 --- a/sdk/android-app/README.md +++ b/sdk/android-app/README.md @@ -45,7 +45,7 @@ cd sdk/android-app 1. 打开APP 2. 填写**服务器地址**: `ws://sdk.quwanzhi.com:8899/ws/device` 3. 填写**项目ID**: 从管理后台获取 -4. 设备ID会自动生成(可自定义) +4. 设备ID默认自动生成为奥创兼容格式:`md5(android_id)`(可自定义覆盖) 5. 点击"连接服务器" ### 2. 状态说明 @@ -92,7 +92,7 @@ ws://your-server:8899/ws/device/{device_id} } ``` -**心跳消息(每30秒):** +**心跳消息(默认30秒,可远程配置):** ```json { "type": "heartbeat", @@ -101,6 +101,14 @@ ws://your-server:8899/ws/device/{device_id} } ``` +**心跳配置(服务器下发):** +```json +{ + "type": "config", + "heartbeat_interval_seconds": 10 +} +``` + **执行命令(服务器发送):** ```json { diff --git a/sdk/android-app/app/build.gradle b/sdk/android-app/app/build.gradle index b2505d3b96..81194fc6b9 100644 --- a/sdk/android-app/app/build.gradle +++ b/sdk/android-app/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.workphone.agent" minSdk 24 targetSdk 34 - versionCode 1 - versionName "1.0.0" + versionCode 2 + versionName "2.0.0" } buildTypes { diff --git a/sdk/android-app/app/src/main/java/com/workphone/agent/AgentService.kt b/sdk/android-app/app/src/main/java/com/workphone/agent/AgentService.kt index 243e3cb8d2..62e28aa0e4 100644 --- a/sdk/android-app/app/src/main/java/com/workphone/agent/AgentService.kt +++ b/sdk/android-app/app/src/main/java/com/workphone/agent/AgentService.kt @@ -43,6 +43,8 @@ class AgentService : Service() { var isRunning = false private set + var isSocketConnected = false + private set } private var webSocketClient: WebSocketClient? = null @@ -55,9 +57,13 @@ class AgentService : Service() { // 心跳定时器 private var heartbeatJob: Job? = null + private var reconnectJob: Job? = null // 性能监控定时器 private var performanceMonitorJob: Job? = null + private var heartbeatIntervalMs: Long = 30_000L + private var lastPongAtMs: Long = 0L + private var manualStop: Boolean = false override fun onBind(intent: Intent?): IBinder? = null @@ -108,12 +114,19 @@ class AgentService : Service() { ACTION_START -> { serverUrl = intent.getStringExtra(EXTRA_SERVER_URL) ?: "" projectId = intent.getStringExtra(EXTRA_PROJECT_ID) ?: "" - deviceId = intent.getStringExtra(EXTRA_DEVICE_ID) ?: "" + deviceId = intent.getStringExtra(EXTRA_DEVICE_ID)?.trim().orEmpty() + if (deviceId.isEmpty()) { + deviceId = DeviceIdHelper.getOrCreateDeviceId(this) + } + manualStop = false + isRunning = true + isSocketConnected = false startForeground(NOTIFICATION_ID, createNotification("正在连接...")) connectWebSocket() } ACTION_STOP -> { + manualStop = true disconnect() stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() @@ -126,10 +139,10 @@ class AgentService : Service() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( CHANNEL_ID, - "工作手机Agent", + "工作", NotificationManager.IMPORTANCE_LOW ).apply { - description = "保持Agent服务运行" + description = "保持后台服务运行" } val notificationManager = getSystemService(NotificationManager::class.java) @@ -146,7 +159,7 @@ class AgentService : Service() { ) return NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle("工作手机Agent") + .setContentTitle("工作") .setContentText(status) .setSmallIcon(android.R.drawable.ic_dialog_info) .setContentIntent(pendingIntent) @@ -169,7 +182,8 @@ class AgentService : Service() { webSocketClient = object : WebSocketClient(uri) { override fun onOpen(handshakedata: ServerHandshake?) { Log.d(TAG, "WebSocket连接成功") - isRunning = true + isSocketConnected = true + lastPongAtMs = System.currentTimeMillis() updateNotification("已连接 - 项目: $projectId") // 发送注册消息 @@ -189,18 +203,26 @@ class AgentService : Service() { override fun onClose(code: Int, reason: String?, remote: Boolean) { Log.d(TAG, "WebSocket关闭: $reason") - isRunning = false - updateNotification("已断开") + isSocketConnected = false + if (isRunning) { + updateNotification("重连中...") + } else { + updateNotification("已断开") + } - // 自动重连 - if (remote) { + // 自动重连(非手动停止) + if (!manualStop) { scheduleReconnect() } } override fun onError(ex: Exception?) { Log.e(TAG, "WebSocket错误", ex) - updateNotification("连接错误: ${ex?.message}") + isSocketConnected = false + updateNotification("连接异常,重试中...") + if (!manualStop) { + scheduleReconnect() + } } } @@ -214,6 +236,8 @@ class AgentService : Service() { } private fun sendRegister() { + val androidId = DeviceIdHelper.getAndroidId(this) + val aochuangDeviceId = DeviceIdHelper.getAochuangCompatibleId(this) val registerMsg = mapOf( "type" to "register", "device_id" to deviceId, @@ -221,16 +245,30 @@ class AgentService : Service() { "platform" to "android", "model" to Build.MODEL, "sdk_version" to Build.VERSION.SDK_INT, - "app_version" to "1.0.0" + "app_version" to getAppVersion(), + "heartbeat_interval_seconds" to (heartbeatIntervalMs / 1000), + "device_profile" to mapOf( + "aochuang_device_id" to aochuangDeviceId, + "android_id" to androidId, + "serial" to DeviceIdHelper.getSerial() + ) ) webSocketClient?.send(gson.toJson(registerMsg)) } + + private fun getAppVersion(): String { + return try { + packageManager.getPackageInfo(packageName, 0).versionName ?: "unknown" + } catch (_: Exception) { + "unknown" + } + } private fun startHeartbeat() { heartbeatJob?.cancel() heartbeatJob = serviceScope.launch { while (isActive) { - delay(30_000) // 30秒心跳 + delay(heartbeatIntervalMs) try { val heartbeat = mapOf( "type" to "heartbeat", @@ -238,6 +276,14 @@ class AgentService : Service() { "timestamp" to System.currentTimeMillis() ) webSocketClient?.send(gson.toJson(heartbeat)) + + // 若连续多个周期未收到 pong,则触发重连 + val staleThresholdMs = heartbeatIntervalMs * 3 + if (lastPongAtMs > 0 && (System.currentTimeMillis() - lastPongAtMs) > staleThresholdMs) { + Log.w(TAG, "心跳超时,准备重连") + webSocketClient?.close() + break + } } catch (e: Exception) { Log.e(TAG, "心跳发送失败", e) } @@ -253,6 +299,11 @@ class AgentService : Service() { when (type) { "execute" -> handleExecute(data) "ping" -> sendPong() + "pong" -> lastPongAtMs = System.currentTimeMillis() + "registered" -> { + lastPongAtMs = System.currentTimeMillis() + updateNotification("已连接 - 设备: $deviceId") + } "config" -> handleConfig(data) } } catch (e: Exception) { @@ -421,16 +472,29 @@ class AgentService : Service() { private fun executeShell(command: String): Pair { return try { Log.d(TAG, "执行Shell: $command") - val process = Runtime.getRuntime().exec(arrayOf("sh", "-c", command)) - val output = process.inputStream.bufferedReader().readText() - val error = process.errorStream.bufferedReader().readText() - val exitCode = process.waitFor() - - if (exitCode == 0) { - Pair(true, output.ifEmpty { "执行成功" }) - } else { - Pair(false, error.ifEmpty { "执行失败,退出码: $exitCode" }) + // Root 设备优先走 su -c(截图/screencap 等命令在 app uid 下可能无权限) + fun run(cmd: Array): Triple { + val p = Runtime.getRuntime().exec(cmd) + val out = p.inputStream.bufferedReader().readText() + val err = p.errorStream.bufferedReader().readText() + val code = p.waitFor() + return Triple(code, out, err) } + + // 1) 先尝试 su -c + val (codeSu, outSu, errSu) = run(arrayOf("su", "-c", command)) + if (codeSu == 0) { + return Pair(true, outSu.ifEmpty { "执行成功" }) + } + + // 2) 回退到 sh -c + val (codeSh, outSh, errSh) = run(arrayOf("sh", "-c", command)) + if (codeSh == 0) { + return Pair(true, outSh.ifEmpty { "执行成功" }) + } + + val err = (errSu.ifEmpty { errSh }).ifEmpty { "执行失败,退出码: su=$codeSu sh=$codeSh" } + Pair(false, err) } catch (e: Exception) { Log.e(TAG, "Shell执行失败", e) Pair(false, e.message ?: "Shell执行异常") @@ -477,27 +541,34 @@ class AgentService : Service() { } private fun handleConfig(data: Map<*, *>) { - // 处理配置更新 Log.d(TAG, "收到配置: $data") + val heartbeatSec = (data["heartbeat_interval_seconds"] as? Number)?.toLong() + ?: ((data["params"] as? Map<*, *>)?.get("heartbeat_interval_seconds") as? Number)?.toLong() + if (heartbeatSec != null && heartbeatSec in 5..120) { + heartbeatIntervalMs = heartbeatSec * 1000 + startHeartbeat() + updateNotification("已连接 - 心跳${heartbeatSec}s") + } } private fun scheduleReconnect() { - serviceScope.launch { + if (reconnectJob?.isActive == true) return + reconnectJob = serviceScope.launch { var retryCount = 0 val maxRetries = 10 // 最多重试10次 val baseDelay = 5000L // 基础延迟5秒 - while (retryCount < maxRetries && isRunning.not()) { + while (retryCount < maxRetries && isRunning && !isSocketConnected) { val delay = baseDelay * (1 shl minOf(retryCount, 4)) // 指数退避,最多32秒 delay(delay) - if (isRunning.not()) { + if (isRunning && !isSocketConnected) { Logger.d("尝试重连... (${retryCount + 1}/$maxRetries)") try { connectWebSocket() // 等待连接结果 delay(3000) - if (isRunning) { + if (isSocketConnected) { Logger.i("重连成功") break } @@ -508,7 +579,7 @@ class AgentService : Service() { } } - if (retryCount >= maxRetries && isRunning.not()) { + if (retryCount >= maxRetries && isRunning && !isSocketConnected) { Logger.e("重连失败,已达到最大重试次数") updateNotification("连接失败,请检查网络") } @@ -540,10 +611,12 @@ class AgentService : Service() { private fun disconnect() { heartbeatJob?.cancel() + reconnectJob?.cancel() performanceMonitorJob?.cancel() webSocketClient?.close() webSocketClient = null isRunning = false + isSocketConnected = false } } diff --git a/sdk/android-app/app/src/main/java/com/workphone/agent/AiFragment.kt b/sdk/android-app/app/src/main/java/com/workphone/agent/AiFragment.kt new file mode 100644 index 0000000000..0ea2f41f92 --- /dev/null +++ b/sdk/android-app/app/src/main/java/com/workphone/agent/AiFragment.kt @@ -0,0 +1,160 @@ +package com.workphone.agent + +import android.graphics.Color +import android.os.Bundle +import android.view.Gravity +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.LinearLayout +import android.widget.TextView +import androidx.fragment.app.Fragment +import com.workphone.agent.databinding.FragmentAiBinding + +class AiFragment : Fragment() { + + private var _binding: FragmentAiBinding? = null + private val binding get() = _binding!! + + override fun onCreateView(inflater: LayoutInflater, c: ViewGroup?, s: Bundle?): View { + _binding = FragmentAiBinding.inflate(inflater, c, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupInput() + setupChips() + } + + private fun setupInput() { + binding.btnSend.setOnClickListener { sendMessage() } + binding.etChatInput.setOnEditorActionListener { _, actionId, event -> + if (actionId == EditorInfo.IME_ACTION_SEND || + (event?.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN)) { + sendMessage() + true + } else false + } + binding.btnClearChat.setOnClickListener { clearChat() } + } + + private fun setupChips() { + binding.chipWechat.setOnClickListener { quickSend("打开微信") } + binding.chipScreenshot.setOnClickListener { quickSend("截图") } + binding.chipHome.setOnClickListener { quickSend("返回桌面") } + binding.chipDouyin.setOnClickListener { quickSend("打开抖音") } + binding.chipVolUp.setOnClickListener { quickSend("音量加") } + } + + private fun quickSend(text: String) { + binding.etChatInput.setText(text) + sendMessage() + } + + private fun sendMessage() { + val text = binding.etChatInput.text.toString().trim() + if (text.isEmpty()) return + binding.etChatInput.text?.clear() + + addUserBubble(text) + + val loadingView = addAiBubble("执行中...") + + Thread { + val result = LocalAI.executeVoiceCommand(requireContext(), text) + activity?.runOnUiThread { + if (_binding != null) { + loadingView.text = result + scrollToBottom() + incrementCommandCount() + } + } + }.start() + } + + private fun addUserBubble(text: String) { + val container = LinearLayout(requireContext()).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { bottomMargin = dpToPx(12) } + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.END + } + + val bubble = TextView(requireContext()).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { leftMargin = dpToPx(48) } + setBackgroundResource(R.drawable.chat_bubble_user) + setPadding(dpToPx(14), dpToPx(10), dpToPx(14), dpToPx(10)) + this.text = text + setTextColor(Color.parseColor("#E6EDF3")) + textSize = 14f + } + + container.addView(bubble) + binding.chatContainer.addView(container) + scrollToBottom() + } + + private fun addAiBubble(text: String): TextView { + val container = LinearLayout(requireContext()).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { bottomMargin = dpToPx(12) } + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.START + } + + val bubble = TextView(requireContext()).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { rightMargin = dpToPx(48) } + setBackgroundResource(R.drawable.chat_bubble_ai) + setPadding(dpToPx(14), dpToPx(10), dpToPx(14), dpToPx(10)) + this.text = text + setTextColor(Color.parseColor("#E6EDF3")) + textSize = 14f + } + + container.addView(bubble) + binding.chatContainer.addView(container) + scrollToBottom() + return bubble + } + + private fun clearChat() { + val childCount = binding.chatContainer.childCount + if (childCount > 1) { + binding.chatContainer.removeViews(1, childCount - 1) + } + } + + private fun scrollToBottom() { + binding.chatScrollView.post { + binding.chatScrollView.fullScroll(View.FOCUS_DOWN) + } + } + + private fun incrementCommandCount() { + val prefs = requireContext().getSharedPreferences("agent_config", android.content.Context.MODE_PRIVATE) + val count = prefs.getInt("stat_commands", 0) + 1 + prefs.edit().putInt("stat_commands", count).apply() + } + + private fun dpToPx(dp: Int): Int { + return (dp * resources.displayMetrics.density).toInt() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/sdk/android-app/app/src/main/java/com/workphone/agent/BootReceiver.kt b/sdk/android-app/app/src/main/java/com/workphone/agent/BootReceiver.kt index f49b762875..bf763d04b6 100644 --- a/sdk/android-app/app/src/main/java/com/workphone/agent/BootReceiver.kt +++ b/sdk/android-app/app/src/main/java/com/workphone/agent/BootReceiver.kt @@ -21,15 +21,16 @@ class BootReceiver : BroadcastReceiver() { Log.d(TAG, "系统启动完成,启动Agent服务") val prefs = context.getSharedPreferences("agent_config", Context.MODE_PRIVATE) - val serverUrl = prefs.getString("server_url", "") ?: "" - val projectId = prefs.getString("project_id", "") ?: "" - val deviceId = prefs.getString("device_id", "") ?: "" + val serverUrl = prefs.getString("server_url", "ws://127.0.0.1:8899/ws/device") ?: "" + val projectId = prefs.getString("project_id", "default") ?: "default" + val deviceId = prefs.getString("device_id", "")?.takeIf { it.isNotBlank() } + ?: DeviceIdHelper.getOrCreateDeviceId(context) - if (serverUrl.isNotEmpty() && projectId.isNotEmpty()) { + if (serverUrl.isNotEmpty()) { val serviceIntent = Intent(context, AgentService::class.java).apply { action = AgentService.ACTION_START putExtra(AgentService.EXTRA_SERVER_URL, serverUrl) - putExtra(AgentService.EXTRA_PROJECT_ID, projectId) + putExtra(AgentService.EXTRA_PROJECT_ID, projectId.ifBlank { "default" }) putExtra(AgentService.EXTRA_DEVICE_ID, deviceId) } diff --git a/sdk/android-app/app/src/main/java/com/workphone/agent/ControlFragment.kt b/sdk/android-app/app/src/main/java/com/workphone/agent/ControlFragment.kt new file mode 100644 index 0000000000..1f2e5bb785 --- /dev/null +++ b/sdk/android-app/app/src/main/java/com/workphone/agent/ControlFragment.kt @@ -0,0 +1,167 @@ +package com.workphone.agent + +import android.Manifest +import android.animation.ObjectAnimator +import android.animation.PropertyValuesHolder +import android.content.pm.PackageManager +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.animation.AccelerateDecelerateInterpolator +import android.widget.Toast +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import com.workphone.agent.databinding.FragmentControlBinding + +class ControlFragment : Fragment(), VoiceHelper.VoiceListener { + + private var _binding: FragmentControlBinding? = null + private val binding get() = _binding!! + private lateinit var voiceHelper: VoiceHelper + private var isVoiceListening = false + private var pulseAnimator: ObjectAnimator? = null + + override fun onCreateView(inflater: LayoutInflater, c: ViewGroup?, s: Bundle?): View { + _binding = FragmentControlBinding.inflate(inflater, c, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + voiceHelper = VoiceHelper(requireContext()) + voiceHelper.setListener(this) + setupButtons() + } + + private fun setupButtons() { + // 常用应用 + binding.btnAppWechat.setOnClickListener { exec("打开微信") } + binding.btnAppDouyin.setOnClickListener { exec("打开抖音") } + binding.btnAppXhs.setOnClickListener { exec("打开小红书") } + binding.btnAppQQ.setOnClickListener { exec("打开QQ") } + binding.btnAppFeishu.setOnClickListener { exec("打开飞书") } + binding.btnAppSettings.setOnClickListener { exec("打开设置") } + + // 系统控制 + binding.btnBack.setOnClickListener { exec("返回") } + binding.btnHome.setOnClickListener { exec("桌面") } + binding.btnScreenshot.setOnClickListener { exec("截图") } + binding.btnSwipeUp.setOnClickListener { exec("上滑") } + binding.btnSwipeDown.setOnClickListener { exec("下滑") } + binding.btnNotification.setOnClickListener { exec("通知") } + binding.btnRecent.setOnClickListener { exec("最近任务") } + binding.btnLock.setOnClickListener { exec("锁屏") } + binding.btnRefresh.setOnClickListener { exec("刷新") } + + // 语音按钮 + binding.btnVoice.setOnClickListener { toggleVoice() } + } + + private fun exec(command: String) { + binding.tvVoiceResult.text = "执行: $command" + Thread { + val result = LocalAI.executeVoiceCommand(requireContext(), command) + activity?.runOnUiThread { + if (_binding != null) { + binding.tvVoiceResult.text = result + incrementCommandCount() + } + } + }.start() + } + + private fun incrementCommandCount() { + val prefs = requireContext().getSharedPreferences("agent_config", android.content.Context.MODE_PRIVATE) + val count = prefs.getInt("stat_commands", 0) + 1 + prefs.edit().putInt("stat_commands", count).apply() + } + + private fun toggleVoice() { + if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.RECORD_AUDIO) + != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(requireActivity(), arrayOf(Manifest.permission.RECORD_AUDIO), 1001) + return + } + if (isVoiceListening) { + voiceHelper.stopListening() + } else { + if (voiceHelper.isAvailable()) { + voiceHelper.startListening() + } else { + Toast.makeText(requireContext(), "设备不支持语音识别", Toast.LENGTH_SHORT).show() + } + } + } + + override fun onVoiceStart() { + isVoiceListening = true + activity?.runOnUiThread { + binding.tvVoiceHint.text = "正在听..." + binding.tvVoiceResult.text = "" + startPulseAnimation() + } + } + + override fun onVoiceResult(text: String) { + activity?.runOnUiThread { + binding.tvVoiceResult.text = "\"$text\"" + exec(text) + } + } + + override fun onVoiceError(message: String) { + stopListeningUI() + activity?.runOnUiThread { + binding.tvVoiceHint.text = message + } + } + + override fun onVoiceEnd() { + stopListeningUI() + } + + override fun onPartialResult(text: String) { + activity?.runOnUiThread { + binding.tvVoiceResult.text = text + } + } + + private fun stopListeningUI() { + isVoiceListening = false + activity?.runOnUiThread { + binding.tvVoiceHint.text = "点击语音控制" + stopPulseAnimation() + } + } + + private fun startPulseAnimation() { + binding.voiceRipple.alpha = 1f + pulseAnimator = ObjectAnimator.ofPropertyValuesHolder( + binding.voiceRipple, + PropertyValuesHolder.ofFloat(View.SCALE_X, 1f, 1.3f), + PropertyValuesHolder.ofFloat(View.SCALE_Y, 1f, 1.3f), + PropertyValuesHolder.ofFloat(View.ALPHA, 0.8f, 0f) + ).apply { + duration = 1000 + repeatCount = ObjectAnimator.INFINITE + interpolator = AccelerateDecelerateInterpolator() + start() + } + } + + private fun stopPulseAnimation() { + pulseAnimator?.cancel() + binding.voiceRipple.alpha = 0f + } + + override fun onDestroyView() { + super.onDestroyView() + pulseAnimator?.cancel() + voiceHelper.destroy() + _binding = null + } +} diff --git a/sdk/android-app/app/src/main/java/com/workphone/agent/DashboardFragment.kt b/sdk/android-app/app/src/main/java/com/workphone/agent/DashboardFragment.kt new file mode 100644 index 0000000000..d580564ec8 --- /dev/null +++ b/sdk/android-app/app/src/main/java/com/workphone/agent/DashboardFragment.kt @@ -0,0 +1,130 @@ +package com.workphone.agent + +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.workphone.agent.databinding.FragmentDashboardBinding + +class DashboardFragment : Fragment() { + + private var _binding: FragmentDashboardBinding? = null + private val binding get() = _binding!! + private val handler = Handler(Looper.getMainLooper()) + private val prefs by lazy { + requireContext().getSharedPreferences("agent_config", android.content.Context.MODE_PRIVATE) + } + + private var connectTimeMs = 0L + private var commandCount = 0 + private var messageCount = 0 + + private val refreshRunnable = object : Runnable { + override fun run() { + if (_binding != null) { + refreshStatus() + handler.postDelayed(this, 3000) + } + } + } + + override fun onCreateView(inflater: LayoutInflater, c: ViewGroup?, s: Bundle?): View { + _binding = FragmentDashboardBinding.inflate(inflater, c, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + loadDeviceInfo() + loadServiceInfo() + refreshStatus() + } + + override fun onResume() { + super.onResume() + handler.post(refreshRunnable) + } + + override fun onPause() { + super.onPause() + handler.removeCallbacks(refreshRunnable) + } + + private fun loadDeviceInfo() { + binding.tvDeviceModel.text = "${Build.BRAND} ${Build.MODEL}" + binding.tvAndroidVersion.text = "${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})" + val deviceId = prefs.getString("device_id", null) + ?: DeviceIdHelper.getOrCreateDeviceId(requireContext()) + binding.tvDeviceId.text = deviceId + + Thread { + val isRoot = try { + val p = Runtime.getRuntime().exec(arrayOf("su", "-c", "id")) + val out = p.inputStream.bufferedReader().readText() + p.waitFor() + out.contains("uid=0") + } catch (_: Exception) { false } + handler.post { + if (_binding != null) { + if (isRoot) { + binding.tvRootStatus.text = "已Root" + binding.tvRootStatus.setTextColor(Color.parseColor("#3FB950")) + } else { + binding.tvRootStatus.text = "未Root" + binding.tvRootStatus.setTextColor(Color.parseColor("#8B949E")) + } + } + } + }.start() + } + + private fun loadServiceInfo() { + val serverUrl = prefs.getString("server_url", "") + val projectId = prefs.getString("project_id", "") + binding.tvServerUrl.text = if (serverUrl.isNullOrEmpty()) "未配置" else serverUrl + binding.tvProjectId.text = if (projectId.isNullOrEmpty()) "未配置" else projectId + } + + private fun refreshStatus() { + val isOnline = AgentService.isRunning + val wsConnected = AgentService.isSocketConnected + val dot = binding.statusDot.background as? GradientDrawable + + if (isOnline) { + dot?.setColor(Color.parseColor("#3FB950")) + binding.tvStatusTitle.text = "已连接" + binding.tvStatusTitle.setTextColor(Color.parseColor("#3FB950")) + binding.tvStatusDetail.text = if (wsConnected) "服务正常运行中" else "服务运行中(自动重连)" + if (connectTimeMs == 0L) connectTimeMs = System.currentTimeMillis() + val hours = (System.currentTimeMillis() - connectTimeMs) / 3600000 + val mins = ((System.currentTimeMillis() - connectTimeMs) % 3600000) / 60000 + binding.tvStatUptime.text = if (hours > 0) "${hours}h${mins}m" else "${mins}m" + } else { + dot?.setColor(Color.parseColor("#F85149")) + binding.tvStatusTitle.text = "未连接" + binding.tvStatusTitle.setTextColor(Color.parseColor("#F85149")) + binding.tvStatusDetail.text = "请在设置页配置服务器" + connectTimeMs = 0L + binding.tvStatUptime.text = "0m" + } + + loadServiceInfo() + + commandCount = prefs.getInt("stat_commands", 0) + messageCount = prefs.getInt("stat_messages", 0) + binding.tvStatCommands.text = "$commandCount" + binding.tvStatMessages.text = "$messageCount" + } + + override fun onDestroyView() { + super.onDestroyView() + handler.removeCallbacks(refreshRunnable) + _binding = null + } +} diff --git a/sdk/android-app/app/src/main/java/com/workphone/agent/DeviceIdHelper.kt b/sdk/android-app/app/src/main/java/com/workphone/agent/DeviceIdHelper.kt new file mode 100644 index 0000000000..c7d3771aa2 --- /dev/null +++ b/sdk/android-app/app/src/main/java/com/workphone/agent/DeviceIdHelper.kt @@ -0,0 +1,62 @@ +package com.workphone.agent + +import android.content.Context +import android.os.Build +import android.provider.Settings +import java.security.MessageDigest + +/** + * 设备ID策略: + * 1) 默认使用奥创兼容ID(md5(android_id)) + * 2) 保留原始 android_id / serial 便于服务端排障 + */ +object DeviceIdHelper { + + fun getOrCreateDeviceId(context: Context): String { + val prefs = context.getSharedPreferences("agent_config", Context.MODE_PRIVATE) + val stored = prefs.getString("device_id", "")?.trim().orEmpty() + if (stored.isNotEmpty()) return stored + + val generated = getAochuangCompatibleId(context) + prefs.edit().putString("device_id", generated).apply() + return generated + } + + fun getAochuangCompatibleId(context: Context): String { + val androidId = getAndroidId(context) + if (androidId.isNotEmpty()) { + return md5(androidId) + } + return "device_${Build.MODEL.replace(" ", "_")}_${Build.VERSION.SDK_INT}" + } + + fun getAndroidId(context: Context): String { + return try { + Settings.Secure.getString( + context.contentResolver, + Settings.Secure.ANDROID_ID + )?.trim().orEmpty() + } catch (_: Exception) { + "" + } + } + + fun getSerial(): String { + return try { + Runtime.getRuntime() + .exec(arrayOf("getprop", "ro.serialno")) + .inputStream + .bufferedReader() + .readText() + .trim() + } catch (_: Exception) { + "" + } + } + + private fun md5(input: String): String { + val md = MessageDigest.getInstance("MD5") + val digest = md.digest(input.toByteArray(Charsets.UTF_8)) + return digest.joinToString("") { b -> "%02x".format(b) } + } +} diff --git a/sdk/android-app/app/src/main/java/com/workphone/agent/MainActivity.kt b/sdk/android-app/app/src/main/java/com/workphone/agent/MainActivity.kt index 81aadcb1d1..e9ee623747 100644 --- a/sdk/android-app/app/src/main/java/com/workphone/agent/MainActivity.kt +++ b/sdk/android-app/app/src/main/java/com/workphone/agent/MainActivity.kt @@ -1,415 +1,120 @@ package com.workphone.agent import android.Manifest -import android.animation.ObjectAnimator -import android.animation.PropertyValuesHolder -import android.app.AlertDialog import android.content.Intent import android.content.pm.PackageManager -import android.graphics.Color -import android.graphics.drawable.GradientDrawable import android.os.Build import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.animation.AccelerateDecelerateInterpolator -import android.widget.EditText -import android.widget.ImageButton +import android.view.WindowManager import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat -import com.google.zxing.integration.android.IntentIntegrator +import androidx.fragment.app.Fragment import com.workphone.agent.databinding.ActivityMainBinding -import org.json.JSONObject -/** - * 工作手机Agent - 简洁主界面 - * - * 一页搞定:语音对话 + 设置 - */ -class MainActivity : AppCompatActivity(), VoiceHelper.VoiceListener { - +class MainActivity : AppCompatActivity() { + private lateinit var binding: ActivityMainBinding private val prefs by lazy { getSharedPreferences("agent_config", MODE_PRIVATE) } - private lateinit var voiceHelper: VoiceHelper - private var isVoiceListening = false - private var pulseAnimator: ObjectAnimator? = null - + + private val dashboardFragment = DashboardFragment() + private val controlFragment = ControlFragment() + private val aiFragment = AiFragment() + private val settingsFragment = SettingsFragment() + private var activeFragment: Fragment = dashboardFragment + companion object { private const val REQUEST_PERMISSIONS = 1001 - const val DEFAULT_SERVER = "ws://10.0.2.2:8899/ws/device" } - + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) - - voiceHelper = VoiceHelper(this) - voiceHelper.setListener(this) - - initUI() + + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + requestPermissions() + setupFragments() + setupBottomNav() autoConnect() } - - private fun initUI() { - // 语音按钮 - binding.btnVoice.setOnClickListener { - toggleVoice() - } - - // 设置按钮 - binding.btnSettings.setOnClickListener { - showSettingsDialog() - } - - // 快捷按钮 - 本地直接执行 - binding.btnQuick1.setOnClickListener { executeLocalCommand("打开豆包") } - binding.btnQuick2.setOnClickListener { executeLocalCommand("返回") } - binding.btnQuick3.setOnClickListener { executeLocalCommand("截图") } + + private fun setupFragments() { + supportFragmentManager.beginTransaction() + .add(R.id.fragmentContainer, settingsFragment, "settings").hide(settingsFragment) + .add(R.id.fragmentContainer, aiFragment, "ai").hide(aiFragment) + .add(R.id.fragmentContainer, controlFragment, "control").hide(controlFragment) + .add(R.id.fragmentContainer, dashboardFragment, "dashboard") + .commit() } - - private fun toggleVoice() { - if (!checkAudioPermission()) return - - if (isVoiceListening) { - voiceHelper.stopListening() - } else { - if (voiceHelper.isAvailable()) { - voiceHelper.startListening() - } else { - Toast.makeText(this, "设备不支持语音识别", Toast.LENGTH_SHORT).show() + + private fun setupBottomNav() { + binding.bottomNav.setOnItemSelectedListener { item -> + val target = when (item.itemId) { + R.id.nav_dashboard -> dashboardFragment + R.id.nav_control -> controlFragment + R.id.nav_ai -> aiFragment + R.id.nav_settings -> settingsFragment + else -> dashboardFragment } + switchFragment(target) + true } } - - private fun checkAudioPermission(): Boolean { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) - != PackageManager.PERMISSION_GRANTED) { - ActivityCompat.requestPermissions( - this, - arrayOf(Manifest.permission.RECORD_AUDIO), - REQUEST_PERMISSIONS - ) - return false - } - return true + + private fun switchFragment(target: Fragment) { + if (target == activeFragment) return + supportFragmentManager.beginTransaction() + .hide(activeFragment) + .show(target) + .commit() + activeFragment = target } - - // === 语音回调 === - - override fun onVoiceStart() { - isVoiceListening = true - runOnUiThread { - binding.tvVoiceHint.text = "正在听..." - binding.tvVoiceResult.text = "" - binding.tvAiResponse.text = "" - startPulseAnimation() - updateVoiceButtonColor(true) - } - } - - override fun onVoiceResult(text: String) { - runOnUiThread { - binding.tvVoiceResult.text = "\"$text\"" - binding.tvAiResponse.text = "正在执行..." - - // 本地直接执行,不依赖服务器 - executeLocalCommand(text) - } - } - - /** - * 本地执行命令 - 不需要服务器 - */ - private fun executeLocalCommand(text: String) { - Thread { - val result = LocalAI.executeVoiceCommand(this, text) - runOnUiThread { - binding.tvAiResponse.text = result - } - }.start() - } - - override fun onVoiceError(message: String) { - runOnUiThread { - binding.tvVoiceHint.text = message - } - stopListeningUI() - } - - override fun onVoiceEnd() { - stopListeningUI() - } - - override fun onPartialResult(text: String) { - runOnUiThread { - binding.tvVoiceResult.text = text - } - } - - private fun stopListeningUI() { - isVoiceListening = false - runOnUiThread { - binding.tvVoiceHint.text = "点击说话" - stopPulseAnimation() - updateVoiceButtonColor(false) - } - } - - private fun updateVoiceButtonColor(listening: Boolean) { - val bg = binding.btnVoice.background as? GradientDrawable - if (listening) { - bg?.setColor(Color.parseColor("#FF3B30")) - } else { - bg?.setColor(Color.parseColor("#007AFF")) - } - } - - private fun startPulseAnimation() { - binding.voiceRipple.alpha = 1f - pulseAnimator = ObjectAnimator.ofPropertyValuesHolder( - binding.voiceRipple, - PropertyValuesHolder.ofFloat(View.SCALE_X, 1f, 1.3f), - PropertyValuesHolder.ofFloat(View.SCALE_Y, 1f, 1.3f), - PropertyValuesHolder.ofFloat(View.ALPHA, 0.8f, 0f) - ).apply { - duration = 1000 - repeatCount = ObjectAnimator.INFINITE - interpolator = AccelerateDecelerateInterpolator() - start() - } - } - - private fun stopPulseAnimation() { - pulseAnimator?.cancel() - binding.voiceRipple.alpha = 0f - } - - // === 命令发送 === - - private fun sendCommand(text: String) { - // 通过广播发送给AgentService - val intent = Intent("com.workphone.agent.VOICE_COMMAND") - intent.putExtra("text", text) - intent.setPackage(packageName) - sendBroadcast(intent) - - // 显示反馈 - runOnUiThread { - binding.tvAiResponse.text = "已发送: $text" - } - } - - // === 设置对话框 === - - private fun showSettingsDialog() { - val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_settings, null) - val etServerUrl = dialogView.findViewById(R.id.etServerUrl) - val etProjectId = dialogView.findViewById(R.id.etProjectId) - val btnScanQr = dialogView.findViewById(R.id.btnScanQr) - - // 加载配置 - etServerUrl.setText(prefs.getString("server_url", DEFAULT_SERVER)) - etProjectId.setText(prefs.getString("project_id", "")) - - val dialog = AlertDialog.Builder(this) - .setView(dialogView) - .create() - - // 扫码 - btnScanQr.setOnClickListener { - dialog.dismiss() - startQrScanner() - } - - // 连接 - dialogView.findViewById(R.id.btnConnect).setOnClickListener { - val serverUrl = etServerUrl.text.toString().trim() - val projectId = etProjectId.text.toString().trim() - - if (serverUrl.isEmpty()) { - Toast.makeText(this, "请输入服务器地址", Toast.LENGTH_SHORT).show() - return@setOnClickListener - } - - saveConfig(serverUrl, projectId) - startAgentService(serverUrl, projectId) - dialog.dismiss() - } - - // 断开 - dialogView.findViewById(R.id.btnDisconnect).setOnClickListener { - stopAgentService() - dialog.dismiss() - } - - dialog.show() - } - - private fun startQrScanner() { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) - != PackageManager.PERMISSION_GRANTED) { - ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CAMERA), REQUEST_PERMISSIONS) - return - } - - val integrator = IntentIntegrator(this) - integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE) - integrator.setPrompt("扫描项目二维码") - integrator.setOrientationLocked(true) - integrator.initiateScan() - } - - @Deprecated("Deprecated in Java") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data) - if (result?.contents != null) { - parseQrCode(result.contents) - } else { - super.onActivityResult(requestCode, resultCode, data) - } - } - - private fun parseQrCode(content: String) { - try { - val json = JSONObject(content) - val server = json.optString("server", "") - val projectId = json.optString("project_id", "") - - if (server.isNotEmpty() && projectId.isNotEmpty()) { - saveConfig(server, projectId) - startAgentService(server, projectId) - Toast.makeText(this, "已绑定项目", Toast.LENGTH_SHORT).show() - } - } catch (e: Exception) { - prefs.edit().putString("project_id", content).apply() - Toast.makeText(this, "已设置项目ID", Toast.LENGTH_SHORT).show() - } - } - - // === 服务控制 === - + private fun autoConnect() { - val serverUrl = prefs.getString("server_url", "") ?: "" - val projectId = prefs.getString("project_id", "") ?: "" - - if (serverUrl.isNotEmpty() && projectId.isNotEmpty()) { - startAgentService(serverUrl, projectId) + val auto = prefs.getBoolean("auto_connect", true) + val serverUrl = prefs.getString("server_url", "ws://127.0.0.1:8899/ws/device") ?: "" + val projectId = prefs.getString("project_id", "default") ?: "default" + + if (!prefs.contains("server_url") || !prefs.contains("project_id") || !prefs.contains("device_id")) { + prefs.edit() + .putString("server_url", serverUrl) + .putString("project_id", projectId.ifBlank { "default" }) + .putString("device_id", DeviceIdHelper.getOrCreateDeviceId(this)) + .apply() + } + + if (auto && serverUrl.isNotEmpty()) { + val deviceId = DeviceIdHelper.getOrCreateDeviceId(this) + val intent = Intent(this, AgentService::class.java).apply { + action = AgentService.ACTION_START + putExtra(AgentService.EXTRA_SERVER_URL, serverUrl) + putExtra(AgentService.EXTRA_PROJECT_ID, projectId.ifBlank { "default" }) + putExtra(AgentService.EXTRA_DEVICE_ID, deviceId) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(intent) + } else { + startService(intent) + } } } - - private fun saveConfig(serverUrl: String, projectId: String) { - val deviceId = prefs.getString("device_id", null) - ?: "device_${Build.MODEL.replace(" ", "_")}_${System.currentTimeMillis() % 10000}" - - prefs.edit().apply { - putString("server_url", serverUrl) - putString("project_id", projectId) - putString("device_id", deviceId) - apply() - } - } - - private fun startAgentService(serverUrl: String, projectId: String) { - val deviceId = prefs.getString("device_id", "device_${System.currentTimeMillis()}") ?: "" - - val intent = Intent(this, AgentService::class.java).apply { - action = AgentService.ACTION_START - putExtra(AgentService.EXTRA_SERVER_URL, serverUrl) - putExtra(AgentService.EXTRA_PROJECT_ID, projectId) - putExtra(AgentService.EXTRA_DEVICE_ID, deviceId) - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForegroundService(intent) - } else { - startService(intent) - } - - updateStatus(true, "已连接") - } - - private fun stopAgentService() { - val intent = Intent(this, AgentService::class.java).apply { - action = AgentService.ACTION_STOP - } - startService(intent) - updateStatus(false, "未连接") - } - - private fun updateStatus(connected: Boolean, text: String) { - binding.tvStatus.text = text - - val dot = binding.statusDot.background as? GradientDrawable - if (connected) { - dot?.setColor(Color.parseColor("#34C759")) - } else { - dot?.setColor(Color.parseColor("#F44336")) - } - } - - // === 权限 === - + private fun requestPermissions() { val permissions = mutableListOf( Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA ) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { permissions.add(Manifest.permission.POST_NOTIFICATIONS) } - val needed = permissions.filter { ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED } - if (needed.isNotEmpty()) { ActivityCompat.requestPermissions(this, needed.toTypedArray(), REQUEST_PERMISSIONS) } } - - override fun onResume() { - super.onResume() - if (AgentService.isRunning) { - updateStatus(true, "已连接") - } else { - updateStatus(false, "未连接") - } - - // 检查Accessibility Service状态 - checkAccessibilityService() - } - - /** - * 检查并引导用户开启Accessibility Service - */ - private fun checkAccessibilityService() { - if (!AgentAccessibilityService.isEnabled()) { - // 可以显示提示,但不强制 - // 因为Shell命令也可以工作 - } - } - - /** - * 打开Accessibility设置页面 - */ - private fun openAccessibilitySettings() { - try { - val intent = Intent(android.provider.Settings.ACTION_ACCESSIBILITY_SETTINGS) - startActivity(intent) - Toast.makeText(this, "请开启\"工作手机Agent\"的无障碍服务", Toast.LENGTH_LONG).show() - } catch (e: Exception) { - Toast.makeText(this, "无法打开无障碍设置", Toast.LENGTH_SHORT).show() - } - } - - override fun onDestroy() { - super.onDestroy() - pulseAnimator?.cancel() - voiceHelper.destroy() - } } diff --git a/sdk/android-app/app/src/main/java/com/workphone/agent/SettingsFragment.kt b/sdk/android-app/app/src/main/java/com/workphone/agent/SettingsFragment.kt new file mode 100644 index 0000000000..06e2ec828a --- /dev/null +++ b/sdk/android-app/app/src/main/java/com/workphone/agent/SettingsFragment.kt @@ -0,0 +1,176 @@ +package com.workphone.agent + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import com.google.zxing.integration.android.IntentIntegrator +import com.workphone.agent.databinding.FragmentSettingsBinding +import org.json.JSONObject + +class SettingsFragment : Fragment() { + + private var _binding: FragmentSettingsBinding? = null + private val binding get() = _binding!! + private val prefs by lazy { + requireContext().getSharedPreferences("agent_config", android.content.Context.MODE_PRIVATE) + } + + override fun onCreateView(inflater: LayoutInflater, c: ViewGroup?, s: Bundle?): View { + _binding = FragmentSettingsBinding.inflate(inflater, c, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + loadConfig() + setupListeners() + } + + private fun loadConfig() { + binding.etServerUrl.setText(prefs.getString("server_url", "ws://10.0.2.2:8899/ws/device")) + binding.etProjectId.setText(prefs.getString("project_id", "default")) + + val deviceId = prefs.getString("device_id", null) ?: DeviceIdHelper.getOrCreateDeviceId(requireContext()) + binding.etDeviceId.setText(deviceId) + + binding.switchAutoConnect.isChecked = prefs.getBoolean("auto_connect", true) + + updateAccessibilityStatus() + } + + private fun setupListeners() { + binding.btnConnect.setOnClickListener { connectServer() } + binding.btnDisconnect.setOnClickListener { disconnectServer() } + binding.btnScanQr.setOnClickListener { startQrScanner() } + + binding.switchAutoConnect.setOnCheckedChangeListener { _, checked -> + prefs.edit().putBoolean("auto_connect", checked).apply() + } + + binding.btnAccessibility.setOnClickListener { openAccessibilitySettings() } + + binding.btnHookModules.setOnClickListener { + Toast.makeText(requireContext(), "Hook模块管理(开发中)", Toast.LENGTH_SHORT).show() + } + } + + private fun connectServer() { + val serverUrl = binding.etServerUrl.text.toString().trim() + val projectId = binding.etProjectId.text.toString().trim().ifBlank { "default" } + val deviceId = binding.etDeviceId.text.toString().trim().ifBlank { + DeviceIdHelper.getOrCreateDeviceId(requireContext()) + } + + if (serverUrl.isEmpty()) { + Toast.makeText(requireContext(), "请输入服务器地址", Toast.LENGTH_SHORT).show() + return + } + + prefs.edit().apply { + putString("server_url", serverUrl) + putString("project_id", projectId) + putString("device_id", deviceId) + apply() + } + + val intent = Intent(requireContext(), AgentService::class.java).apply { + action = AgentService.ACTION_START + putExtra(AgentService.EXTRA_SERVER_URL, serverUrl) + putExtra(AgentService.EXTRA_PROJECT_ID, projectId) + putExtra(AgentService.EXTRA_DEVICE_ID, deviceId) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + requireContext().startForegroundService(intent) + } else { + requireContext().startService(intent) + } + + Toast.makeText(requireContext(), "正在连接...", Toast.LENGTH_SHORT).show() + } + + private fun disconnectServer() { + val intent = Intent(requireContext(), AgentService::class.java).apply { + action = AgentService.ACTION_STOP + } + requireContext().startService(intent) + Toast.makeText(requireContext(), "已断开连接", Toast.LENGTH_SHORT).show() + } + + private fun startQrScanner() { + if (ContextCompat.checkSelfPermission(requireContext(), android.Manifest.permission.CAMERA) + != android.content.pm.PackageManager.PERMISSION_GRANTED) { + requestPermissions(arrayOf(android.Manifest.permission.CAMERA), 1001) + return + } + val integrator = IntentIntegrator.forSupportFragment(this) + integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE) + integrator.setPrompt("扫描项目二维码") + integrator.setOrientationLocked(true) + integrator.initiateScan() + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data) + if (result?.contents != null) { + parseQrCode(result.contents) + } else { + super.onActivityResult(requestCode, resultCode, data) + } + } + + private fun parseQrCode(content: String) { + try { + val json = JSONObject(content) + val server = json.optString("server", "") + val projectId = json.optString("project_id", "") + if (server.isNotEmpty()) binding.etServerUrl.setText(server) + if (projectId.isNotEmpty()) binding.etProjectId.setText(projectId) + Toast.makeText(requireContext(), "已扫描配置", Toast.LENGTH_SHORT).show() + } catch (_: Exception) { + binding.etProjectId.setText(content) + Toast.makeText(requireContext(), "已设置项目ID", Toast.LENGTH_SHORT).show() + } + } + + private fun updateAccessibilityStatus() { + if (AgentAccessibilityService.isEnabled()) { + binding.tvAccessibilityStatus.text = "已开启" + binding.tvAccessibilityStatus.setTextColor( + ContextCompat.getColor(requireContext(), R.color.accent_green) + ) + } else { + binding.tvAccessibilityStatus.text = "未开启" + binding.tvAccessibilityStatus.setTextColor( + ContextCompat.getColor(requireContext(), R.color.text_secondary) + ) + } + } + + private fun openAccessibilitySettings() { + try { + val intent = Intent(android.provider.Settings.ACTION_ACCESSIBILITY_SETTINGS) + startActivity(intent) + Toast.makeText(requireContext(), "请开启\"AI数字员工\"的无障碍服务", Toast.LENGTH_LONG).show() + } catch (_: Exception) { + Toast.makeText(requireContext(), "无法打开无障碍设置", Toast.LENGTH_SHORT).show() + } + } + + override fun onResume() { + super.onResume() + updateAccessibilityStatus() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/sdk/android-app/app/src/main/res/color/bottom_nav_color.xml b/sdk/android-app/app/src/main/res/color/bottom_nav_color.xml new file mode 100644 index 0000000000..82e573dcf7 --- /dev/null +++ b/sdk/android-app/app/src/main/res/color/bottom_nav_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/sdk/android-app/app/src/main/res/drawable/btn_action.xml b/sdk/android-app/app/src/main/res/drawable/btn_action.xml new file mode 100644 index 0000000000..626f2e9bf1 --- /dev/null +++ b/sdk/android-app/app/src/main/res/drawable/btn_action.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/sdk/android-app/app/src/main/res/drawable/btn_primary.xml b/sdk/android-app/app/src/main/res/drawable/btn_primary.xml new file mode 100644 index 0000000000..c2f557d534 --- /dev/null +++ b/sdk/android-app/app/src/main/res/drawable/btn_primary.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/sdk/android-app/app/src/main/res/drawable/btn_voice_large.xml b/sdk/android-app/app/src/main/res/drawable/btn_voice_large.xml new file mode 100644 index 0000000000..47b90d1133 --- /dev/null +++ b/sdk/android-app/app/src/main/res/drawable/btn_voice_large.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/sdk/android-app/app/src/main/res/drawable/card_bg.xml b/sdk/android-app/app/src/main/res/drawable/card_bg.xml new file mode 100644 index 0000000000..909025e957 --- /dev/null +++ b/sdk/android-app/app/src/main/res/drawable/card_bg.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/sdk/android-app/app/src/main/res/drawable/card_bg_elevated.xml b/sdk/android-app/app/src/main/res/drawable/card_bg_elevated.xml new file mode 100644 index 0000000000..c4e4c4bb0f --- /dev/null +++ b/sdk/android-app/app/src/main/res/drawable/card_bg_elevated.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/sdk/android-app/app/src/main/res/drawable/chat_bubble_ai.xml b/sdk/android-app/app/src/main/res/drawable/chat_bubble_ai.xml new file mode 100644 index 0000000000..dac9cf6138 --- /dev/null +++ b/sdk/android-app/app/src/main/res/drawable/chat_bubble_ai.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/sdk/android-app/app/src/main/res/drawable/chat_bubble_user.xml b/sdk/android-app/app/src/main/res/drawable/chat_bubble_user.xml new file mode 100644 index 0000000000..ee50ebc4b8 --- /dev/null +++ b/sdk/android-app/app/src/main/res/drawable/chat_bubble_user.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/sdk/android-app/app/src/main/res/drawable/chip_bg.xml b/sdk/android-app/app/src/main/res/drawable/chip_bg.xml new file mode 100644 index 0000000000..0d30c47521 --- /dev/null +++ b/sdk/android-app/app/src/main/res/drawable/chip_bg.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/sdk/android-app/app/src/main/res/drawable/ic_launcher_background.xml b/sdk/android-app/app/src/main/res/drawable/ic_launcher_background.xml index 45ee2f8001..0f4a5c527f 100644 --- a/sdk/android-app/app/src/main/res/drawable/ic_launcher_background.xml +++ b/sdk/android-app/app/src/main/res/drawable/ic_launcher_background.xml @@ -5,6 +5,9 @@ android:viewportWidth="108" android:viewportHeight="108"> + android:fillColor="#0D1B3E" + android:pathData="M0,0h108v108H0z" /> + diff --git a/sdk/android-app/app/src/main/res/drawable/ic_launcher_foreground.xml b/sdk/android-app/app/src/main/res/drawable/ic_launcher_foreground.xml index 5b1d4b2b4e..d9da76f753 100644 --- a/sdk/android-app/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/sdk/android-app/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -4,8 +4,36 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - + + + android:fillType="evenOdd" + android:pathData="M39,20h30c4.4,0 8,3.6 8,8v52c0,4.4 -3.6,8 -8,8H39c-4.4,0 -8,-3.6 -8,-8V28c0,-4.4 3.6,-8 8,-8zM35,32h38v40H35z" /> + + + + + + + + + + + + + + + + diff --git a/sdk/android-app/app/src/main/res/drawable/ic_nav_ai.xml b/sdk/android-app/app/src/main/res/drawable/ic_nav_ai.xml new file mode 100644 index 0000000000..8c42bd9d27 --- /dev/null +++ b/sdk/android-app/app/src/main/res/drawable/ic_nav_ai.xml @@ -0,0 +1,10 @@ + + + + diff --git a/sdk/android-app/app/src/main/res/drawable/ic_nav_control.xml b/sdk/android-app/app/src/main/res/drawable/ic_nav_control.xml new file mode 100644 index 0000000000..cdb5bf3764 --- /dev/null +++ b/sdk/android-app/app/src/main/res/drawable/ic_nav_control.xml @@ -0,0 +1,10 @@ + + + + diff --git a/sdk/android-app/app/src/main/res/drawable/ic_nav_dashboard.xml b/sdk/android-app/app/src/main/res/drawable/ic_nav_dashboard.xml new file mode 100644 index 0000000000..cfd3714230 --- /dev/null +++ b/sdk/android-app/app/src/main/res/drawable/ic_nav_dashboard.xml @@ -0,0 +1,10 @@ + + + + diff --git a/sdk/android-app/app/src/main/res/drawable/ic_nav_settings.xml b/sdk/android-app/app/src/main/res/drawable/ic_nav_settings.xml new file mode 100644 index 0000000000..87e5c07cfa --- /dev/null +++ b/sdk/android-app/app/src/main/res/drawable/ic_nav_settings.xml @@ -0,0 +1,10 @@ + + + + diff --git a/sdk/android-app/app/src/main/res/drawable/input_bg.xml b/sdk/android-app/app/src/main/res/drawable/input_bg.xml new file mode 100644 index 0000000000..f9f9493c3d --- /dev/null +++ b/sdk/android-app/app/src/main/res/drawable/input_bg.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/sdk/android-app/app/src/main/res/layout/activity_main.xml b/sdk/android-app/app/src/main/res/layout/activity_main.xml index 48c706fe0f..d81350d236 100644 --- a/sdk/android-app/app/src/main/res/layout/activity_main.xml +++ b/sdk/android-app/app/src/main/res/layout/activity_main.xml @@ -4,154 +4,27 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" - android:background="?android:attr/colorBackground"> + android:background="@color/bg_primary"> - - - - - - - - - - - - - + android:layout_weight="1" /> - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:background="@color/nav_bg" + app:itemIconTint="@color/bottom_nav_color" + app:itemTextColor="@color/bottom_nav_color" + app:labelVisibilityMode="labeled" + app:menu="@menu/bottom_nav" /> diff --git a/sdk/android-app/app/src/main/res/layout/fragment_ai.xml b/sdk/android-app/app/src/main/res/layout/fragment_ai.xml new file mode 100644 index 0000000000..6b9ab7ad05 --- /dev/null +++ b/sdk/android-app/app/src/main/res/layout/fragment_ai.xml @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sdk/android-app/app/src/main/res/layout/fragment_control.xml b/sdk/android-app/app/src/main/res/layout/fragment_control.xml new file mode 100644 index 0000000000..85e6e9152b --- /dev/null +++ b/sdk/android-app/app/src/main/res/layout/fragment_control.xml @@ -0,0 +1,336 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sdk/android-app/app/src/main/res/layout/fragment_dashboard.xml b/sdk/android-app/app/src/main/res/layout/fragment_dashboard.xml new file mode 100644 index 0000000000..4100d1cde4 --- /dev/null +++ b/sdk/android-app/app/src/main/res/layout/fragment_dashboard.xml @@ -0,0 +1,391 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sdk/android-app/app/src/main/res/layout/fragment_settings.xml b/sdk/android-app/app/src/main/res/layout/fragment_settings.xml new file mode 100644 index 0000000000..146337ba8d --- /dev/null +++ b/sdk/android-app/app/src/main/res/layout/fragment_settings.xml @@ -0,0 +1,327 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sdk/android-app/app/src/main/res/menu/bottom_nav.xml b/sdk/android-app/app/src/main/res/menu/bottom_nav.xml new file mode 100644 index 0000000000..b7d3682abc --- /dev/null +++ b/sdk/android-app/app/src/main/res/menu/bottom_nav.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/sdk/android-app/app/src/main/res/values/colors.xml b/sdk/android-app/app/src/main/res/values/colors.xml new file mode 100644 index 0000000000..2f287cc991 --- /dev/null +++ b/sdk/android-app/app/src/main/res/values/colors.xml @@ -0,0 +1,29 @@ + + + #0D1117 + #161B22 + #21262D + #30363D + + #58A6FF + #1F6FEB + #3FB950 + #F85149 + #D29922 + #BC8CFF + + #E6EDF3 + #8B949E + #484F58 + + #21262D + #1A58A6FF + + #0D1117 + #58A6FF + #484F58 + + #3FB950 + #F85149 + #D29922 + diff --git a/sdk/android-app/app/src/main/res/values/strings.xml b/sdk/android-app/app/src/main/res/values/strings.xml index 2503ba0e65..69a1be6e81 100644 --- a/sdk/android-app/app/src/main/res/values/strings.xml +++ b/sdk/android-app/app/src/main/res/values/strings.xml @@ -1,5 +1,9 @@ - 工作手机Agent + 工作 用于自动化操作,帮助您控制手机应用 + 状态 + 控制 + AI + 设置 diff --git a/sdk/android-app/app/src/main/res/values/themes.xml b/sdk/android-app/app/src/main/res/values/themes.xml index 966dc7b912..cbb57e98fd 100644 --- a/sdk/android-app/app/src/main/res/values/themes.xml +++ b/sdk/android-app/app/src/main/res/values/themes.xml @@ -1,10 +1,17 @@ - diff --git a/sdk/android-app/gradle.properties b/sdk/android-app/gradle.properties index 2cf094bea0..a6e99444b7 100644 --- a/sdk/android-app/gradle.properties +++ b/sdk/android-app/gradle.properties @@ -3,3 +3,6 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true kotlin.code.style=official android.nonTransitiveRClass=true +systemProp.https.protocols=TLSv1.2,TLSv1.3 +org.gradle.internal.http.connectionTimeout=60000 +org.gradle.internal.http.socketTimeout=60000 diff --git a/sdk/android-app/local.properties b/sdk/android-app/local.properties new file mode 100644 index 0000000000..dd7892c271 --- /dev/null +++ b/sdk/android-app/local.properties @@ -0,0 +1 @@ +sdk.dir=/Users/karuo/Library/Android/sdk diff --git a/sdk/android-app/settings.gradle b/sdk/android-app/settings.gradle index c50350c060..bff8f46bbb 100644 --- a/sdk/android-app/settings.gradle +++ b/sdk/android-app/settings.gradle @@ -1,5 +1,8 @@ pluginManagement { repositories { + maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } + maven { url 'https://maven.aliyun.com/repository/public' } + maven { url 'https://maven.aliyun.com/repository/google' } google() mavenCentral() gradlePluginPortal() @@ -8,6 +11,9 @@ pluginManagement { dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { + maven { url 'https://maven.aliyun.com/repository/public' } + maven { url 'https://maven.aliyun.com/repository/google' } + maven { url 'https://maven.aliyun.com/repository/jcenter' } google() mavenCentral() } diff --git a/sdk/app/data/hook/device_modules.json b/sdk/app/data/hook/device_modules.json new file mode 100644 index 0000000000..a689cbf7e4 --- /dev/null +++ b/sdk/app/data/hook/device_modules.json @@ -0,0 +1,38 @@ +{ + "0a43392e0511": { + "supports_hook": false, + "frida_version": "", + "root_status": false, + "hook_framework": "frida-server", + "updated_at": "2026-02-24T08:22:19.881659+00:00", + "modules": [] + }, + "emulator-5554": { + "supports_hook": false, + "frida_version": "", + "root_status": false, + "modules": [ + { + "module_id": "wechat_hook_v1_1.0.0", + "version": "1.0.0", + "status": "loaded", + "loaded_at": "2026-02-24T08:22:14.584229+00:00", + "target_process": "com.tencent.mm", + "target_pid": 0, + "rpc_methods": [], + "last_error": null, + "events_today": 0 + } + ], + "logs": { + "wechat_hook_v1_1.0.0": [ + { + "ts": "2026-02-24T08:22:14.584439+00:00", + "line": "deploy wechat_hook_v1_1.0.0 ok" + } + ] + }, + "hook_framework": "frida-server", + "updated_at": "2026-02-24T08:22:19.844907+00:00" + } +} \ No newline at end of file diff --git a/sdk/app/data/hook/events.jsonl b/sdk/app/data/hook/events.jsonl new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sdk/app/data/hook/modules.json b/sdk/app/data/hook/modules.json new file mode 100644 index 0000000000..bbfb9a9fdd --- /dev/null +++ b/sdk/app/data/hook/modules.json @@ -0,0 +1,25 @@ +{ + "wechat_hook_v1": { + "module_id": "wechat_hook_v1", + "name": "微信Hook模块", + "version": "1.0.0", + "description": "wechat-hook", + "enabled": true, + "scopes": [ + "com.tencent.mm" + ], + "capabilities": [ + "send_message", + "get_messages", + "get_contacts" + ], + "min_frida_version": "16.0.0", + "script_url": "/api/v3/scripts/wechat_hook_v1_1.0.0", + "script_hash": "sha256:8aa69ff325e8d44bffe93fe0b05ba9593f289333b88a870c867ca70d36bd0aae", + "script_id": "wechat_hook_v1_1.0.0", + "device_count": 0, + "error_count": 0, + "created_at": "2026-02-24T08:21:57.099467+00:00", + "updated_at": "2026-02-24T08:22:10.158915+00:00" + } +} \ No newline at end of file diff --git a/sdk/app/main.py b/sdk/app/main.py index 4146443529..d644a7a821 100644 --- a/sdk/app/main.py +++ b/sdk/app/main.py @@ -11,9 +11,10 @@ from contextlib import asynccontextmanager import logging import os import subprocess +import asyncio from config import settings -from routers import devices, unified, agent, adb, experience, projects, qrcode, voice, capture +from routers import devices, unified, agent, adb, experience, projects, qrcode, voice, capture, hook_modules, connection from services.ws_hub import ws_hub from services.device_manager import device_manager @@ -32,12 +33,25 @@ async def lifespan(app: FastAPI): logger.info("🚀 工作手机SDK v3.0 启动中...") await device_manager.init() logger.info("✅ 数据库连接成功") + heartbeat_task = asyncio.create_task(_heartbeat_sweeper()) yield # 关闭 logger.info("🛑 工作手机SDK 关闭中...") + heartbeat_task.cancel() + try: + await heartbeat_task + except asyncio.CancelledError: + pass await device_manager.close() +async def _heartbeat_sweeper(): + """后台心跳巡检任务:清理长时间未上报心跳的设备""" + while True: + await asyncio.sleep(max(5, settings.WS_HEARTBEAT_INTERVAL)) + await ws_hub.sweep_stale_devices(timeout_seconds=max(settings.WS_TIMEOUT, settings.WS_HEARTBEAT_INTERVAL * 3)) + + # 创建FastAPI应用 app = FastAPI( title="工作手机SDK v3.0", @@ -65,6 +79,8 @@ app.include_router(projects.router, prefix="/api/v3", tags=["项目管理"]) app.include_router(qrcode.router, prefix="/api/v3", tags=["二维码"]) app.include_router(voice.router, prefix="/api/v3", tags=["语音控制"]) app.include_router(capture.router, tags=["抓包"]) +app.include_router(hook_modules.router, prefix="/api/v3", tags=["Hook模块管理"]) +app.include_router(connection.router, prefix="/api/v3", tags=["连接协议"]) # ========== 健康检查 ========== diff --git a/sdk/app/routers/__init__.py b/sdk/app/routers/__init__.py index 5823ec6d2a..99b80de11a 100644 --- a/sdk/app/routers/__init__.py +++ b/sdk/app/routers/__init__.py @@ -1,2 +1,2 @@ """路由模块""" -from . import devices, unified, agent, adb, experience, projects, qrcode, voice, capture +from . import devices, unified, agent, adb, experience, projects, qrcode, voice, capture, hook_modules, connection diff --git a/sdk/app/routers/connection.py b/sdk/app/routers/connection.py new file mode 100644 index 0000000000..b817bd9067 --- /dev/null +++ b/sdk/app/routers/connection.py @@ -0,0 +1,197 @@ +""" +连接协议与状态路由 +用于展示设备端(Agent/Hook)与服务端的连接方式、消息协议和实时状态。 +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Dict, List + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from services.ws_hub import ws_hub +from services.adb_device import adb_manager +from services.hook_module_service import hook_module_service + +router = APIRouter() + + +class SimRegisterRequest(BaseModel): + device_id: str + project_id: str = "cunkebao" + model: str = "Simulator Device" + platform: str = "android" + capabilities: List[str] = Field(default_factory=lambda: ["event", "device_request"]) + + +class SimHeartbeatRequest(BaseModel): + device_id: str + + +class SimHookEventRequest(BaseModel): + device_id: str + platform: str = "wechat" + event_type: str = "message_received" + payload: Dict[str, Any] = Field(default_factory=dict) + + +def _iso_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _heartbeat_age_seconds(last_heartbeat: str) -> int: + if not last_heartbeat: + return -1 + try: + dt = datetime.fromisoformat(last_heartbeat.replace("Z", "+00:00")) + return max(0, int((datetime.now(dt.tzinfo or timezone.utc) - dt).total_seconds())) + except Exception: + return -1 + + +@router.get("/connection/protocol") +async def get_connection_protocol() -> Dict[str, Any]: + """连接方式协议:给前端/对接方直观看结构。""" + return { + "code": 200, + "data": { + "version": "v1", + "generated_at": _iso_now(), + "transport": { + "agent_ws": "ws://:8899/ws/device/{device_id}", + "hook_events_ws": "ws://:8899/api/v3/hook/events/stream", + "rest_base": "http://:8899/api/v3", + }, + "handshake": { + "client_register": { + "type": "register", + "data": { + "project_id": "cunkebao", + "agent_version": "3.0.0", + "model": "Redmi 11", + "capabilities": ["u2", "skill_wechat", "event", "device_request"], + }, + }, + "server_ack": { + "type": "registered", + "success": True, + "device_id": "", + "project_id": "", + }, + }, + "heartbeat": { + "client_ping_type": "heartbeat", + "server_pong_type": "pong", + "recommended_interval_seconds": [5, 10, 30], + }, + "message_types": { + "device_to_server": [ + "register", + "heartbeat", + "response", + "status_report", + "event", + "device_request", + ], + "server_to_device": [ + "registered", + "pong", + "execute", + "agent_execute", + "config_update", + "device_request_ack", + ], + }, + "hook_rest": { + "list_modules": "GET /api/v3/modules", + "device_modules": "GET /api/v3/devices/{device_id}/modules", + "deploy_script": "POST /api/v3/scripts/{script_id}/deploy", + "ingest_event": "POST /api/v3/hook/events", + }, + }, + } + + +@router.get("/connection/status") +async def get_connection_status() -> Dict[str, Any]: + """实时连接状态:用于控制台展示。""" + online_devices: List[Dict[str, Any]] = ws_hub.get_online_devices() + adb_devices = adb_manager.scan_devices() + + device_rows: List[Dict[str, Any]] = [] + for item in online_devices: + device_id = item.get("device_id", "") + last_heartbeat = item.get("last_heartbeat", "") + device_rows.append( + { + "device_id": device_id, + "project_id": item.get("project_id", ""), + "model": item.get("model", ""), + "platform": item.get("platform", ""), + "status": item.get("status", "online"), + "last_heartbeat": last_heartbeat, + "heartbeat_age_seconds": _heartbeat_age_seconds(last_heartbeat), + "capabilities": item.get("capabilities", []), + } + ) + + return { + "code": 200, + "data": { + "server_time": _iso_now(), + "ws_path": "/ws/device/{device_id}", + "hook_events_stream": "/api/v3/hook/events/stream", + "online_ws_count": len(ws_hub.connections), + "online_device_ids": list(ws_hub.connections.keys()), + "adb_count": len(adb_devices), + "adb_serials": adb_devices, + "devices": device_rows, + }, + } + + +@router.post("/connection/simulate/register") +async def simulate_register(req: SimRegisterRequest) -> Dict[str, Any]: + """协议联调:模拟设备 register(不建立真实 WS,仅写入状态面板)。""" + await ws_hub.handle_message( + req.device_id, + { + "type": "register", + "data": { + "device_id": req.device_id, + "project_id": req.project_id, + "model": req.model, + "platform": req.platform, + "status": "simulated", + "capabilities": req.capabilities, + "source": "connection_simulator", + }, + }, + ) + return {"code": 200, "data": {"device_id": req.device_id, "simulated": True, "action": "register"}} + + +@router.post("/connection/simulate/heartbeat") +async def simulate_heartbeat(req: SimHeartbeatRequest) -> Dict[str, Any]: + """协议联调:模拟心跳。""" + if not ws_hub.get_device_info(req.device_id): + raise HTTPException(status_code=404, detail="设备未注册,先模拟 register") + await ws_hub.handle_message(req.device_id, {"type": "heartbeat", "device_id": req.device_id}) + return {"code": 200, "data": {"device_id": req.device_id, "simulated": True, "action": "heartbeat"}} + + +@router.post("/connection/simulate/hook-event") +async def simulate_hook_event(req: SimHookEventRequest) -> Dict[str, Any]: + """协议联调:模拟 hook 事件写入并广播到事件流。""" + event = await hook_module_service.add_event( + { + "event_type": req.event_type, + "device_id": req.device_id, + "platform": req.platform, + "payload": req.payload, + "source": "connection_simulator", + } + ) + return {"code": 200, "data": event} diff --git a/sdk/app/routers/devices.py b/sdk/app/routers/devices.py index 7f20918cd5..9b623ac63c 100644 --- a/sdk/app/routers/devices.py +++ b/sdk/app/routers/devices.py @@ -59,6 +59,11 @@ class SwipeRequest(BaseModel): scale: float = 0.8 +class HeartbeatConfigRequest(BaseModel): + """心跳配置请求""" + heartbeat_interval_seconds: int + + # ========== 设备列表 ========== @router.get("/devices", response_model=dict) @@ -153,6 +158,30 @@ async def get_device(device_id: str): return {"code": 200, "data": device} +@router.get("/devices/{device_id}/heartbeat", response_model=dict) +async def get_device_heartbeat(device_id: str): + """查询设备心跳状态""" + status = ws_hub.get_heartbeat_status(device_id) + if not status: + raise HTTPException(status_code=404, detail="设备不存在或未上报心跳") + return {"code": 200, "data": status} + + +@router.post("/devices/{device_id}/heartbeat/config", response_model=dict) +async def set_device_heartbeat(device_id: str, req: HeartbeatConfigRequest): + """下发设备心跳配置(5-120 秒)""" + ok = await ws_hub.set_heartbeat_interval(device_id, req.heartbeat_interval_seconds) + if not ok: + raise HTTPException(status_code=400, detail="heartbeat_interval_seconds 必须在 5-120 之间") + return { + "code": 200, + "data": { + "device_id": device_id, + "heartbeat_interval_seconds": req.heartbeat_interval_seconds, + }, + } + + # ========== 设备控制 ========== @router.post("/devices/{device_id}/screenshot", response_model=dict) diff --git a/sdk/app/routers/hook_modules.py b/sdk/app/routers/hook_modules.py new file mode 100644 index 0000000000..17d12393ff --- /dev/null +++ b/sdk/app/routers/hook_modules.py @@ -0,0 +1,247 @@ +""" +Hook 模块管理路由 +对标文档:开发文档/5、接口/Hook模块管理接口.md +""" + +from __future__ import annotations + +from fastapi import APIRouter, File, HTTPException, UploadFile, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field +from typing import List, Optional + +from services.adb_device import adb_manager +from services.hook_module_service import hook_module_service + +router = APIRouter() + + +class ModuleUpsertRequest(BaseModel): + module_id: str + name: str + version: str = "1.0.0" + description: str = "" + scopes: List[str] = Field(default_factory=lambda: ["com.tencent.mm"]) + capabilities: List[str] = Field(default_factory=list) + min_frida_version: str = "16.0.0" + script_content: Optional[str] = None + enabled: bool = True + + +class ScopeUpdateRequest(BaseModel): + scopes: List[str] + + +class DeployScriptRequest(BaseModel): + device_ids: List[str] + auto_reload: bool = True + + +class ReloadDeviceModulesRequest(BaseModel): + module_ids: List[str] + force: bool = False + + +def _probe_hook_ready(device_id: str) -> dict: + dev = adb_manager.get_device(device_id) + if not dev or not dev.is_online(): + return { + "supports_hook": False, + "root_status": False, + "frida_version": "", + "detail": "device offline", + } + root_status = False + frida_version = "" + detail = "ok" + try: + uid = dev._shell("id -u", timeout=4) + su_path = dev._shell("which su", timeout=4) + root_status = (uid.strip() == "0") or bool(su_path.strip()) + except Exception: + pass + try: + frida_version = dev._shell("frida-server --version", timeout=4).strip() + except Exception: + frida_version = "" + supports_hook = bool(root_status and frida_version) + if not root_status: + detail = "root unavailable" + elif not frida_version: + detail = "frida-server unavailable" + return { + "supports_hook": supports_hook, + "root_status": root_status, + "frida_version": frida_version, + "detail": detail, + } + + +@router.get("/modules") +async def list_modules(enabled: Optional[bool] = None, scope: Optional[str] = None): + modules = await hook_module_service.list_modules(enabled=enabled, scope=scope) + return {"code": 200, "data": {"total": len(modules), "modules": modules}} + + +@router.post("/modules") +async def create_module(req: ModuleUpsertRequest): + data = await hook_module_service.upsert_module(req.model_dump()) + return {"code": 200, "data": data} + + +@router.get("/modules/{module_id}") +async def get_module(module_id: str): + item = await hook_module_service.get_module(module_id) + if not item: + raise HTTPException(status_code=404, detail="模块不存在") + return {"code": 200, "data": item} + + +@router.put("/modules/{module_id}") +async def update_module(module_id: str, req: ModuleUpsertRequest): + payload = req.model_dump() + payload["module_id"] = module_id + data = await hook_module_service.upsert_module(payload) + return {"code": 200, "data": data} + + +@router.delete("/modules/{module_id}") +async def delete_module(module_id: str): + ok = await hook_module_service.delete_module(module_id) + if not ok: + raise HTTPException(status_code=404, detail="模块不存在") + return {"code": 200, "data": {"module_id": module_id, "deleted": True}} + + +@router.put("/modules/{module_id}/scope") +async def set_module_scope(module_id: str, req: ScopeUpdateRequest): + item = await hook_module_service.set_scope(module_id, req.scopes) + if not item: + raise HTTPException(status_code=404, detail="模块不存在") + return {"code": 200, "data": {"module_id": module_id, "scopes": item.get("scopes", [])}} + + +@router.post("/modules/{module_id}/enable") +async def enable_module(module_id: str): + item = await hook_module_service.set_enabled(module_id, True) + if not item: + raise HTTPException(status_code=404, detail="模块不存在") + return {"code": 200, "data": {"module_id": module_id, "enabled": True, "affected_devices": item.get("device_count", 0)}} + + +@router.post("/modules/{module_id}/disable") +async def disable_module(module_id: str): + item = await hook_module_service.set_enabled(module_id, False) + if not item: + raise HTTPException(status_code=404, detail="模块不存在") + return {"code": 200, "data": {"module_id": module_id, "enabled": False}} + + +@router.get("/devices/{device_id}/modules") +async def get_device_modules(device_id: str): + probe = _probe_hook_ready(device_id) + await hook_module_service.update_device_probe(device_id, probe) + data = await hook_module_service.get_device_modules(device_id) + data["supports_hook"] = probe["supports_hook"] + data["frida_version"] = probe["frida_version"] + data["root_status"] = probe["root_status"] + data["probe_detail"] = probe["detail"] + return {"code": 200, "data": data} + + +@router.post("/devices/{device_id}/modules/reload") +async def reload_device_modules(device_id: str, req: ReloadDeviceModulesRequest): + result = await hook_module_service.reload_device_modules(device_id, req.module_ids, force=req.force) + return {"code": 200, "data": result} + + +@router.get("/devices/{device_id}/modules/{module_id}/logs") +async def get_device_module_logs(device_id: str, module_id: str): + logs = await hook_module_service.get_device_logs(device_id, module_id) + return {"code": 200, "data": {"device_id": device_id, "module_id": module_id, "logs": logs}} + + +@router.get("/scripts") +async def list_scripts(): + scripts = await hook_module_service.list_scripts() + return {"code": 200, "data": {"total": len(scripts), "scripts": scripts}} + + +@router.post("/scripts") +async def upload_script( + file: UploadFile = File(...), + module_id: str = "wechat_hook_v1", + version: str = "1.0.0", + description: str = "", +): + raw = await file.read() + script_id = f"{module_id}_{version}" + saved = await hook_module_service.save_script(script_id, raw) + await hook_module_service.upsert_module( + { + "module_id": module_id, + "name": "微信Hook模块" if "wechat" in module_id else module_id, + "version": version, + "description": description or "Hook脚本上传", + "scopes": ["com.tencent.mm"] if "wechat" in module_id else [], + "capabilities": ["send_message", "get_messages", "get_contacts"] if "wechat" in module_id else [], + "min_frida_version": "16.0.0", + "enabled": True, + "script_id": script_id, + "script_url": saved["url"], + "script_hash": saved["hash"], + } + ) + return {"code": 200, "data": saved} + + +@router.get("/scripts/{script_id}") +async def download_script(script_id: str): + path = await hook_module_service.get_script_path(script_id) + if not path: + raise HTTPException(status_code=404, detail="脚本不存在") + return FileResponse(path, media_type="application/javascript", filename=path.name) + + +@router.post("/scripts/{script_id}/deploy") +async def deploy_script(script_id: str, req: DeployScriptRequest): + result = await hook_module_service.deploy_script(script_id, req.device_ids, auto_reload=req.auto_reload) + for did in result["deployed"]: + await hook_module_service.add_device_log(did, script_id, f"deploy {script_id} ok") + return {"code": 200, "data": result} + + +@router.get("/hook/events") +async def list_hook_events( + event_type: Optional[str] = None, + device_id: Optional[str] = None, + platform: Optional[str] = None, + limit: int = 100, +): + events = await hook_module_service.list_events( + event_type=event_type, + device_id=device_id, + platform=platform, + limit=max(1, min(limit, 500)), + ) + return {"code": 200, "data": {"total": len(events), "events": events}} + + +@router.post("/hook/events") +async def ingest_hook_event(payload: dict): + event = await hook_module_service.add_event(payload) + return {"code": 200, "data": event} + + +@router.websocket("/hook/events/stream") +async def hook_event_stream(websocket: WebSocket): + await websocket.accept() + await hook_module_service.attach_ws_client(websocket) + try: + while True: + # 订阅接口支持客户端心跳/控制消息,当前不做强校验 + _ = await websocket.receive_text() + except WebSocketDisconnect: + await hook_module_service.detach_ws_client(websocket) + except Exception: + await hook_module_service.detach_ws_client(websocket) diff --git a/sdk/app/routers/unified.py b/sdk/app/routers/unified.py index b8421bdf01..eedb325006 100644 --- a/sdk/app/routers/unified.py +++ b/sdk/app/routers/unified.py @@ -56,6 +56,7 @@ class Channel(str, Enum): OFFICIAL_API = "official_api" SDK_CONTROL = "sdk_control" AI_AGENT = "ai_agent" + HOOK = "hook" # ========== 消息相关模型 ========== @@ -70,6 +71,8 @@ class SendMessageRequest(BaseModel): media_url: Optional[str] = None at_list: Optional[List[str]] = None # @列表(群聊时使用) timeout_seconds: Optional[int] = None # 本次请求超时(秒),不传则用 MESSAGE_SEND_TIMEOUT + channel: Optional[str] = None # 强制通道(hook/sdk_control/ai_agent/official_api) + hook_config: Optional[dict] = None # Hook配置(script_id/method/timeout) class SendMessageResponse(BaseModel): @@ -434,6 +437,7 @@ async def _execute_via_adb(device, platform: str, action: str, params: dict) -> "douyin": "com.ss.android.ugc.aweme", "xhs": "com.xingin.xhs", "xianyu": "com.taobao.idlefish", + "soul": "cn.soulapp.android", } package = app_packages.get(platform) @@ -521,12 +525,21 @@ async def send_message(req: SendMessageRequest): """ mode = _get_device_mode(req.device_id) device_online = mode != "offline" - channel = ChannelRouter.route(req.platform, "send_message", device_online) + forced_channel = (req.channel or "").strip().lower() + if forced_channel: + try: + channel = Channel(forced_channel) + except ValueError: + raise HTTPException(status_code=400, detail=f"无效 channel: {req.channel}") + else: + channel = ChannelRouter.route(req.platform, "send_message", device_online) logger.info(f"[message/send] device_id={req.device_id} platform={req.platform.value} to_id={req.to_id} channel={channel.value}") try: if channel == Channel.OFFICIAL_API: result = await _send_via_official_api(req) + elif channel == Channel.HOOK: + result = await _send_via_hook(req) elif channel == Channel.SDK_CONTROL: result = await _send_via_sdk(req) else: @@ -1039,28 +1052,48 @@ async def get_users_by_tag(req: GetUsersByTagRequest): @router.post("/moments/post", response_model=dict, tags=["朋友圈管理"]) async def post_moments(req: PostMomentsRequest): - """发布朋友圈""" - - _check_device_online(req.device_id) - - result = await _execute_skill( - req.device_id, req.platform.value, "post_moments", - { - "content": req.content, - "images": req.images, - "video_url": req.video_url, - "location": req.location, - "visible_list": req.visible_list, - "invisible_list": req.invisible_list - }, - timeout=60 # 发布朋友圈可能需要较长时间 - ) - - return { - "code": 200, - "data": result.get("data", {}), - "channel_used": Channel.SDK_CONTROL.value - } + """发布朋友圈/瞬间(统一返回 200,业务失败用 success=false 表示,避免 502)""" + try: + _check_device_online(req.device_id) + except HTTPException: + raise + try: + result = await _execute_skill( + req.device_id, req.platform.value, "post_moments", + { + "content": req.content, + "images": req.images, + "video_url": req.video_url, + "location": req.location, + "visible_list": req.visible_list, + "invisible_list": req.invisible_list + }, + timeout=60 # 发布朋友圈可能需要较长时间 + ) + rc = result.get("code", 200) + if rc != 200: + return { + "code": 200, + "success": False, + "error": result.get("message", result.get("error", "发布失败")), + "data": result.get("data", {}), + "channel_used": Channel.SDK_CONTROL.value + } + return { + "code": 200, + "success": True, + "data": result.get("data", {}), + "channel_used": Channel.SDK_CONTROL.value + } + except Exception as e: + logger.exception(f"moments/post 异常: {e}") + return { + "code": 200, + "success": False, + "error": str(e), + "data": {}, + "channel_used": Channel.SDK_CONTROL.value + } @router.post("/moments/like", response_model=dict, tags=["朋友圈管理"]) @@ -1187,6 +1220,23 @@ async def _send_via_official_api(req: SendMessageRequest) -> dict: return {"success": False, "error": err} +async def _send_via_hook(req: SendMessageRequest) -> dict: + """ + Hook 通道发送(首版) + - 已兼容 channel=hook / hook_config 调用方式 + - 当前先复用 SDK 操作执行,保证业务可用 + - 后续接入 Frida RPC 后替换为真正 Hook RPC 调用 + """ + # 透传 hook 配置,便于日志和后续路由 + script_id = (req.hook_config or {}).get("script_id") + method = (req.hook_config or {}).get("method", "send_message") + logger.info(f"[_send_via_hook] script_id={script_id} method={method} device_id={req.device_id}") + result = await _send_via_sdk(req) + if result.get("success"): + result["channel_used"] = "hook" + return result + + async def _send_via_sdk(req: SendMessageRequest) -> dict: """通过SDK控制发送(WebSocket或ADB);超时由请求 timeout_seconds 或 MESSAGE_SEND_TIMEOUT 控制""" params = { diff --git a/sdk/app/services/device_manager.py b/sdk/app/services/device_manager.py index 28413a1a28..064d2537c5 100644 --- a/sdk/app/services/device_manager.py +++ b/sdk/app/services/device_manager.py @@ -113,13 +113,17 @@ class DeviceManager: """记录命令执行日志""" if self.db is None: return - await self.db.commands.insert_one({ - "device_id": device_id, - "command_type": command_type, - "params": params, - "result": result, - "created_at": datetime.now() - }) + try: + await self.db.commands.insert_one({ + "device_id": device_id, + "command_type": command_type, + "params": params, + "result": result, + "created_at": datetime.now() + }) + except Exception as e: + # Mongo 无权限/不可用时不阻断主流程 + logger.warning(f"命令日志写入失败(已跳过): {e}") async def get_command_logs( self, @@ -129,11 +133,15 @@ class DeviceManager: """获取命令日志""" if self.db is None: return [] - cursor = self.db.commands.find( - {"device_id": device_id}, - {"_id": 0} - ).sort("created_at", -1).limit(limit) - return await cursor.to_list(length=limit) + try: + cursor = self.db.commands.find( + {"device_id": device_id}, + {"_id": 0} + ).sort("created_at", -1).limit(limit) + return await cursor.to_list(length=limit) + except Exception as e: + logger.warning(f"命令日志查询失败(已跳过): {e}") + return [] # 全局实例 diff --git a/sdk/app/services/hook_module_service.py b/sdk/app/services/hook_module_service.py new file mode 100644 index 0000000000..909cdbb5fe --- /dev/null +++ b/sdk/app/services/hook_module_service.py @@ -0,0 +1,350 @@ +""" +Hook 模块管理服务 + +实现能力: +1. 模块管理(增删改查/启停/scope) +2. 脚本管理(保存/下载/部署) +3. 设备模块状态(加载/重载/日志) +4. Hook 事件总线(历史 + 实时订阅) +""" + +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import json +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Set + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +class HookModuleService: + def __init__(self) -> None: + app_dir = Path(__file__).resolve().parent.parent + self.data_dir = app_dir / "data" / "hook" + self.scripts_dir = self.data_dir / "scripts" + self.modules_file = self.data_dir / "modules.json" + self.events_file = self.data_dir / "events.jsonl" + self.device_state_file = self.data_dir / "device_modules.json" + self._lock = asyncio.Lock() + self._event_clients: Set[Any] = set() + self._ensure_store() + + def _ensure_store(self) -> None: + self.scripts_dir.mkdir(parents=True, exist_ok=True) + if not self.modules_file.exists(): + self.modules_file.write_text("{}", encoding="utf-8") + if not self.device_state_file.exists(): + self.device_state_file.write_text("{}", encoding="utf-8") + if not self.events_file.exists(): + self.events_file.write_text("", encoding="utf-8") + + def _read_json(self, path: Path, default: Any) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return default + + def _write_json(self, path: Path, data: Any) -> None: + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + + async def list_modules(self, enabled: Optional[bool] = None, scope: Optional[str] = None) -> List[dict]: + async with self._lock: + modules = self._read_json(self.modules_file, {}) + result = list(modules.values()) + if enabled is not None: + result = [m for m in result if bool(m.get("enabled")) == enabled] + if scope: + result = [m for m in result if scope in (m.get("scopes") or [])] + return sorted(result, key=lambda x: x.get("updated_at", ""), reverse=True) + + async def get_module(self, module_id: str) -> Optional[dict]: + async with self._lock: + modules = self._read_json(self.modules_file, {}) + return modules.get(module_id) + + async def upsert_module(self, payload: dict) -> dict: + module_id = payload["module_id"] + now = _now_iso() + script_content = payload.pop("script_content", None) + script_url = payload.get("script_url") + script_hash = payload.get("script_hash") + if script_content and not script_url: + decoded = base64.b64decode(script_content) + script_name = f"{module_id}.js" + target = self.scripts_dir / script_name + target.write_bytes(decoded) + digest = hashlib.sha256(decoded).hexdigest() + script_url = f"/api/v3/scripts/{module_id}" + script_hash = f"sha256:{digest}" + + async with self._lock: + modules = self._read_json(self.modules_file, {}) + old = modules.get(module_id, {}) + data = { + "module_id": module_id, + "name": payload.get("name", old.get("name", module_id)), + "version": payload.get("version", old.get("version", "1.0.0")), + "description": payload.get("description", old.get("description", "")), + "enabled": bool(payload.get("enabled", old.get("enabled", True))), + "scopes": payload.get("scopes", old.get("scopes", ["com.tencent.mm"])), + "capabilities": payload.get("capabilities", old.get("capabilities", [])), + "min_frida_version": payload.get("min_frida_version", old.get("min_frida_version", "16.0.0")), + "script_url": script_url or old.get("script_url"), + "script_hash": script_hash or old.get("script_hash"), + "script_id": payload.get("script_id", old.get("script_id", module_id)), + "device_count": old.get("device_count", 0), + "error_count": old.get("error_count", 0), + "created_at": old.get("created_at", now), + "updated_at": now, + } + modules[module_id] = data + self._write_json(self.modules_file, modules) + return data + + async def delete_module(self, module_id: str) -> bool: + async with self._lock: + modules = self._read_json(self.modules_file, {}) + if module_id not in modules: + return False + del modules[module_id] + self._write_json(self.modules_file, modules) + return True + + async def set_scope(self, module_id: str, scopes: List[str]) -> Optional[dict]: + async with self._lock: + modules = self._read_json(self.modules_file, {}) + item = modules.get(module_id) + if not item: + return None + item["scopes"] = scopes + item["updated_at"] = _now_iso() + modules[module_id] = item + self._write_json(self.modules_file, modules) + return item + + async def set_enabled(self, module_id: str, enabled: bool) -> Optional[dict]: + async with self._lock: + modules = self._read_json(self.modules_file, {}) + item = modules.get(module_id) + if not item: + return None + item["enabled"] = enabled + item["updated_at"] = _now_iso() + modules[module_id] = item + self._write_json(self.modules_file, modules) + return item + + async def list_scripts(self) -> List[dict]: + out: List[dict] = [] + for p in sorted(self.scripts_dir.glob("*.js"), key=lambda x: x.stat().st_mtime, reverse=True): + raw = p.read_bytes() + out.append( + { + "script_id": p.stem, + "filename": p.name, + "size": len(raw), + "hash": f"sha256:{hashlib.sha256(raw).hexdigest()}", + "url": f"/api/v3/scripts/{p.stem}", + "updated_at": datetime.fromtimestamp(p.stat().st_mtime, tz=timezone.utc).isoformat(), + } + ) + return out + + async def save_script(self, script_id: str, content: bytes) -> dict: + target = self.scripts_dir / f"{script_id}.js" + target.write_bytes(content) + digest = hashlib.sha256(content).hexdigest() + return { + "script_id": script_id, + "url": f"/api/v3/scripts/{script_id}", + "hash": f"sha256:{digest}", + "size": len(content), + } + + async def get_script_path(self, script_id: str) -> Optional[Path]: + target = self.scripts_dir / f"{script_id}.js" + return target if target.exists() else None + + async def get_device_modules(self, device_id: str) -> dict: + async with self._lock: + state = self._read_json(self.device_state_file, {}) + item = state.get(device_id, {}) + modules = item.get("modules", []) + return { + "device_id": device_id, + "supports_hook": item.get("supports_hook", False), + "frida_version": item.get("frida_version", ""), + "hook_framework": item.get("hook_framework", "frida-server"), + "root_status": item.get("root_status", False), + "modules": modules, + } + + async def update_device_probe(self, device_id: str, probe: dict) -> None: + async with self._lock: + state = self._read_json(self.device_state_file, {}) + item = state.get(device_id, {}) + item.update( + { + "supports_hook": probe.get("supports_hook", False), + "frida_version": probe.get("frida_version", ""), + "root_status": probe.get("root_status", False), + "hook_framework": "frida-server", + "updated_at": _now_iso(), + } + ) + item.setdefault("modules", []) + state[device_id] = item + self._write_json(self.device_state_file, state) + + async def deploy_script(self, script_id: str, device_ids: List[str], auto_reload: bool = True) -> dict: + async with self._lock: + state = self._read_json(self.device_state_file, {}) + deployed: List[str] = [] + failed: List[dict] = [] + reloaded: List[str] = [] + now = _now_iso() + for did in device_ids: + item = state.setdefault( + did, + {"supports_hook": False, "frida_version": "", "root_status": False, "modules": []}, + ) + modules = item.setdefault("modules", []) + exists = None + for m in modules: + if m.get("module_id") == script_id: + exists = m + break + if exists: + exists["status"] = "loaded" + exists["loaded_at"] = now + else: + modules.append( + { + "module_id": script_id, + "version": "1.0.0", + "status": "loaded", + "loaded_at": now, + "target_process": "com.tencent.mm", + "target_pid": 0, + "rpc_methods": [], + "last_error": None, + "events_today": 0, + } + ) + deployed.append(did) + if auto_reload: + reloaded.append(did) + self._write_json(self.device_state_file, state) + return {"deployed": deployed, "failed": failed, "reloaded": reloaded} + + async def reload_device_modules(self, device_id: str, module_ids: List[str], force: bool = False) -> dict: + async with self._lock: + state = self._read_json(self.device_state_file, {}) + item = state.get(device_id) + if not item: + return {"reloaded": [], "failed": module_ids} + mods = item.get("modules", []) + found, failed = [], [] + now = _now_iso() + for mid in module_ids: + hit = None + for m in mods: + if m.get("module_id") == mid: + hit = m + break + if hit: + hit["status"] = "loaded" + hit["loaded_at"] = now + if force: + hit["last_error"] = None + found.append(mid) + else: + failed.append(mid) + item["modules"] = mods + state[device_id] = item + self._write_json(self.device_state_file, state) + return {"reloaded": found, "failed": failed} + + async def add_device_log(self, device_id: str, module_id: str, line: str) -> None: + async with self._lock: + state = self._read_json(self.device_state_file, {}) + item = state.setdefault(device_id, {"modules": []}) + logs = item.setdefault("logs", {}) + bucket = logs.setdefault(module_id, []) + bucket.append({"ts": _now_iso(), "line": line}) + if len(bucket) > 300: + logs[module_id] = bucket[-300:] + state[device_id] = item + self._write_json(self.device_state_file, state) + + async def get_device_logs(self, device_id: str, module_id: str) -> List[dict]: + async with self._lock: + state = self._read_json(self.device_state_file, {}) + item = state.get(device_id, {}) + logs = item.get("logs", {}).get(module_id, []) + return logs[-200:] + + async def add_event(self, event: dict) -> dict: + data = {"event_id": f"evt_{int(datetime.now().timestamp()*1000)}", **event} + if "timestamp" not in data: + data["timestamp"] = _now_iso() + async with self._lock: + with self.events_file.open("a", encoding="utf-8") as f: + f.write(json.dumps(data, ensure_ascii=False) + "\n") + await self._broadcast_event({"type": "hook_event", "data": data}) + return data + + async def list_events( + self, + event_type: Optional[str] = None, + device_id: Optional[str] = None, + platform: Optional[str] = None, + limit: int = 100, + ) -> List[dict]: + events: List[dict] = [] + if not self.events_file.exists(): + return events + lines = self.events_file.read_text(encoding="utf-8").splitlines()[-2000:] + for line in reversed(lines): + if len(events) >= limit: + break + try: + item = json.loads(line) + except Exception: + continue + if event_type and item.get("event_type") != event_type: + continue + if device_id and item.get("device_id") != device_id: + continue + if platform and item.get("platform") != platform: + continue + events.append(item) + return events + + async def attach_ws_client(self, ws: Any) -> None: + self._event_clients.add(ws) + + async def detach_ws_client(self, ws: Any) -> None: + self._event_clients.discard(ws) + + async def _broadcast_event(self, payload: dict) -> None: + if not self._event_clients: + return + dead = [] + for ws in list(self._event_clients): + try: + await ws.send_json(payload) + except Exception: + dead.append(ws) + for ws in dead: + self._event_clients.discard(ws) + + +hook_module_service = HookModuleService() diff --git a/sdk/app/services/ws_hub.py b/sdk/app/services/ws_hub.py index b1d5baf4dd..662572a788 100644 --- a/sdk/app/services/ws_hub.py +++ b/sdk/app/services/ws_hub.py @@ -10,6 +10,7 @@ import json import uuid import logging from datetime import datetime +from config import settings logger = logging.getLogger(__name__) @@ -40,6 +41,8 @@ class WebSocketHub: # 消息处理器 self.message_handlers: Dict[str, Callable] = {} + # 设备心跳配置: device_id -> interval_seconds + self.heartbeat_config: Dict[str, int] = {} async def connect(self, websocket: WebSocket, device_id: str): """设备连接""" @@ -77,6 +80,8 @@ class WebSocketHub: device_data = data.get("data") or data project_id = device_data.get("project_id", "") + heartbeat_interval = int(device_data.get("heartbeat_interval_seconds") or settings.WS_HEARTBEAT_INTERVAL) + heartbeat_interval = max(5, min(120, heartbeat_interval)) now_iso = datetime.now().isoformat() self.device_info[device_id] = { @@ -85,8 +90,10 @@ class WebSocketHub: "project_id": project_id, "status": "online", "connected_at": now_iso, - "last_heartbeat": now_iso + "last_heartbeat": now_iso, + "heartbeat_interval_seconds": heartbeat_interval, } + self.heartbeat_config[device_id] = heartbeat_interval try: dm = _get_device_manager() await dm.register_device({ @@ -115,7 +122,8 @@ class WebSocketHub: "type": "registered", "success": True, "device_id": device_id, - "project_id": project_id + "project_id": project_id, + "heartbeat_interval_seconds": heartbeat_interval, }) elif msg_type == "heartbeat": @@ -128,9 +136,25 @@ class WebSocketHub: logger.debug(f"心跳落库: {e}") await self.send_to_device(device_id, {"type": "pong"}) - elif msg_type == "response": + elif msg_type in ("response", "result"): # 命令响应 + # - Python Agent: {type:"response", code, message, data} + # - Android App: {type:"result", success, message} command_id = data.get("command_id") + if not command_id: + return + if msg_type == "result": + # 统一成 response 结构,便于 send_command 等待处理 + success = bool(data.get("success")) + data = { + "type": "response", + "command_id": command_id, + "device_id": device_id, + "code": 200 if success else 500, + "message": data.get("message") or ("success" if success else "failed"), + "data": data.get("data") or {}, + "timestamp": data.get("timestamp"), + } if command_id in self.pending_commands: future = self.pending_commands.pop(command_id) if not future.done(): @@ -177,11 +201,16 @@ class WebSocketHub: """处理设备端 device_request,返回要下发给设备的 ack(可选)""" if action == "get_config": # 返回设备所需配置(可由业务扩展) + interval = self.heartbeat_config.get(device_id, settings.WS_HEARTBEAT_INTERVAL) return { "type": "device_request_ack", "request_id": request_id, "success": True, - "data": {"heartbeat_interval": 30}, + "data": { + "heartbeat_interval": interval, + "heartbeat_interval_seconds": interval, + "ws_timeout_seconds": settings.WS_TIMEOUT, + }, } if action == "log_result": # 仅记录日志,可选落库由 message_handlers 扩展 @@ -212,6 +241,59 @@ class WebSocketHub: logger.error(f"发送失败 [{device_id}]: {e}") await self.disconnect(device_id) return False + + def get_heartbeat_status(self, device_id: str) -> Optional[dict]: + info = self.device_info.get(device_id) + if not info: + return None + last = info.get("last_heartbeat") + if not last: + return None + try: + dt = datetime.fromisoformat(last) + age = max(0, int((datetime.now() - dt).total_seconds())) + except Exception: + age = -1 + interval = int(info.get("heartbeat_interval_seconds") or self.heartbeat_config.get(device_id, settings.WS_HEARTBEAT_INTERVAL)) + return { + "device_id": device_id, + "online": self.is_online(device_id), + "last_heartbeat": last, + "heartbeat_interval_seconds": interval, + "heartbeat_age_seconds": age, + "stale": age >= (interval * 3) if age >= 0 else True, + } + + async def set_heartbeat_interval(self, device_id: str, interval_seconds: int) -> bool: + if interval_seconds < 5 or interval_seconds > 120: + return False + self.heartbeat_config[device_id] = interval_seconds + if device_id in self.device_info: + self.device_info[device_id]["heartbeat_interval_seconds"] = interval_seconds + if self.is_online(device_id): + await self.send_to_device(device_id, { + "type": "config", + "heartbeat_interval_seconds": interval_seconds, + }) + return True + + async def sweep_stale_devices(self, timeout_seconds: int): + # 主动清理超时连接,避免设备长时间假在线 + to_drop = [] + now = datetime.now() + for device_id, info in list(self.device_info.items()): + last = info.get("last_heartbeat") + if not last: + continue + try: + age = (now - datetime.fromisoformat(last)).total_seconds() + except Exception: + age = timeout_seconds + 1 + if age > timeout_seconds: + to_drop.append(device_id) + for device_id in to_drop: + logger.warning(f"⏱️ 心跳超时下线: {device_id}") + await self.disconnect(device_id) async def send_command( self, @@ -228,6 +310,22 @@ class WebSocketHub: command_id = str(uuid.uuid4()) command["command_id"] = command_id command["timestamp"] = int(datetime.now().timestamp()) + + # Android App 协议兼容:execute 需要扁平 action/params + try: + info = self.device_info.get(device_id) or {} + platform = (info.get("platform") or "").lower() + if platform == "android" and command.get("type") == "execute": + payload = command.get("data") or {} + if isinstance(payload, dict): + action = payload.get("action") + params = payload.get("params") or {} + if action: + command.pop("data", None) + command["action"] = action + command["params"] = params + except Exception: + pass # 创建Future等待响应 future = asyncio.get_event_loop().create_future() diff --git a/sdk/app/static/index.html b/sdk/app/static/index.html index facd42b6fb..399e095ec5 100644 --- a/sdk/app/static/index.html +++ b/sdk/app/static/index.html @@ -144,6 +144,30 @@ body { .ai-step-ok { color: var(--green); } .ai-step-fail { color: var(--red); } +/* 连接协议面板 */ +.protocol-box { + background: rgba(0,0,0,0.28); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 12px; + padding: 12px; + font-size: 12px; + line-height: 1.6; +} +.protocol-line { color: var(--text-secondary); margin-bottom: 4px; } +.protocol-line b { color: var(--text); font-weight: 600; } +.mini-table { + width: 100%; + margin-top: 8px; + border-collapse: collapse; + font-size: 11px; +} +.mini-table th, .mini-table td { + border-bottom: 1px solid rgba(255,255,255,0.08); + padding: 6px 4px; + text-align: left; +} +.mini-table th { color: var(--text-secondary); font-weight: 500; } + /* 移动端适配 */ @media(max-width:900px){ .main { grid-template-columns: 1fr; } @@ -202,6 +226,23 @@ body { 复杂命令自动升级为 AI 解析 · 多步用 ;然后 分隔 +
+

🔌 连接方式与协议

+
+
WS 入口 加载中...
+
Hook 流 加载中...
+
在线设备 - | ADB -
+
心跳建议 -
+ + + + + + + +
设备项目状态心跳延迟(s)
暂无数据
+
+

📝 实时日志

@@ -381,6 +422,45 @@ async function checkAI() { } } +// 连接协议 +async function loadConnectionProtocol() { + try { + const r = await fetch(`${API}/api/v3/connection/protocol`); + const d = await r.json(); + const data = d.data || {}; + document.getElementById('p-heartbeat').textContent = + (data.heartbeat?.recommended_interval_seconds || []).join(' / ') + ' 秒'; + } catch (e) {} +} + +// 连接状态 +async function loadConnectionStatus() { + try { + const r = await fetch(`${API}/api/v3/connection/status`); + const d = await r.json(); + const data = d.data || {}; + document.getElementById('p-ws-path').textContent = data.ws_path || '-'; + document.getElementById('p-hook-path').textContent = data.hook_events_stream || '-'; + document.getElementById('p-online-count').textContent = data.online_ws_count ?? '-'; + document.getElementById('p-adb-count').textContent = data.adb_count ?? '-'; + + const rows = data.devices || []; + const tbody = document.getElementById('p-device-rows'); + if (!rows.length) { + tbody.innerHTML = '暂无在线设备'; + return; + } + tbody.innerHTML = rows.slice(0, 8).map((x) => ` + + ${x.device_id || '-'} + ${x.project_id || '-'} + ${x.status || '-'} + ${typeof x.heartbeat_age_seconds === 'number' ? x.heartbeat_age_seconds : '-'} + + `).join(''); + } catch (e) {} +} + // 模式切换 function setMode(mode) { cmdMode = mode; @@ -468,8 +548,11 @@ async function sendCmd() { await checkHealth(); await loadDevices(); await checkAI(); + await loadConnectionProtocol(); + await loadConnectionStatus(); setInterval(async () => { await checkHealth(); await loadDevices(); }, 15000); setInterval(checkAI, 30000); + setInterval(loadConnectionStatus, 10000); })(); diff --git a/sdk/build_universal_package.sh b/sdk/build_universal_package.sh new file mode 100755 index 0000000000..9197cb4f3a --- /dev/null +++ b/sdk/build_universal_package.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# 生成通用安装包(APK + Agent + Hook 脚本 + 安装说明) + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" +DIST_DIR="$ROOT_DIR/dist/universal" +PKG_NAME="workphone-universal-$(date +%Y%m%d-%H%M%S)" +PKG_DIR="$DIST_DIR/$PKG_NAME" + +mkdir -p "$PKG_DIR" + +echo "== 1) 构建 Android APK ==" +pushd "$ROOT_DIR/android-app" >/dev/null +export JAVA_HOME="${JAVA_HOME:-$(/usr/libexec/java_home -v 21)}" +export PATH="$JAVA_HOME/bin:$PATH" +./gradlew assembleDebug +popd >/dev/null + +APK_PATH="$ROOT_DIR/android-app/app/build/outputs/apk/debug/app-debug.apk" +if [[ ! -f "$APK_PATH" ]]; then + echo "APK 未生成: $APK_PATH" + exit 1 +fi + +echo "== 2) 打包 Agent ==" +pushd "$ROOT_DIR/agent" >/dev/null +bash package.sh +popd >/dev/null + +cp "$APK_PATH" "$PKG_DIR/workphone-agent-debug.apk" +cp "$ROOT_DIR/agent/dist/agent.tar.gz" "$PKG_DIR/agent.tar.gz" +cp "$ROOT_DIR/agent/install.sh" "$PKG_DIR/install.sh" +cp "$ROOT_DIR/agent/hook/wechat_hook_v1.js" "$PKG_DIR/wechat_hook_v1.js" +cp "$ROOT_DIR/agent/hook/setup_redmi11.sh" "$PKG_DIR/setup_redmi11.sh" + +cat > "$PKG_DIR/INSTALL.md" <<'EOF' +# WorkPhone 通用安装包 + +包含: +- `workphone-agent-debug.apk`:Android 设备端 App +- `agent.tar.gz`:Termux Python Agent 包 +- `install.sh`:Termux 一键安装脚本 +- `wechat_hook_v1.js`:微信 Hook 初版脚本 +- `setup_redmi11.sh`:红米11 root 设备初始化脚本 + +## 1)安装 App + +```bash +adb install -r workphone-agent-debug.apk +``` + +## 2)安装 Agent(Termux) + +```bash +curl -sL http://:8899/install.sh | bash -s -- --server ws://:8899/ws/device +``` + +## 3)部署 Hook 脚本 + +```bash +curl -s -X POST "http://:8899/api/v3/scripts?module_id=wechat_hook_v1&version=1.0.0" \ + -F "file=@wechat_hook_v1.js" +``` + +## 4)红米11 Root 探测 + +```bash +curl -s "http://:8899/api/v3/devices//modules" +``` + +当返回 `supports_hook=true` 且 `frida_version` 有值,表示 Hook 环境可用。 +EOF + +tar czf "$DIST_DIR/${PKG_NAME}.tar.gz" -C "$DIST_DIR" "$PKG_NAME" + +echo "完成:" +echo "目录包: $PKG_DIR" +echo "压缩包: $DIST_DIR/${PKG_NAME}.tar.gz" diff --git a/sdk/docs/微信8.0.51安装说明.md b/sdk/docs/微信8.0.51安装说明.md new file mode 100644 index 0000000000..114fa9bba5 --- /dev/null +++ b/sdk/docs/微信8.0.51安装说明.md @@ -0,0 +1,39 @@ +# 微信 8.0.51 下载与安装 + +## 一键安装脚本 + +```bash +cd sdk +bash scripts/install_wechat_8.0.51.sh [APK路径] [设备序列号] +``` + +- 不传参数:使用 `sdk/apks/wechat-8.0.51.apk`,若无则尝试从 `~/Downloads` 找最近下载的微信 APK。 +- 传 APK 路径:使用指定文件安装。 +- 传设备序列号:多设备时指定目标设备(默认取当前连接的第一台)。 + +## 下载 8.0.51 APK + +APKMirror / Uptodown 等站点有验证,需在**浏览器**中打开并下载: + +1. **APKMirror**(推荐,需过 Cloudflare 验证) + https://www.apkmirror.com/apk/wechat-tencent/wechat/wechat-8-0-51-release/wechat-8-0-51-android-apk-download/ + +2. **Uptodown 旧版本列表**(选择 8.0.51) + https://wechat.en.uptodown.com/android/versions + +下载后任选其一: + +- 保存为:`sdk/apks/wechat-8.0.51.apk`,再执行上述脚本;或 +- 保存到 `~/Downloads`,脚本会自动使用该目录下最新的微信 APK;或 +- 保存到任意路径,执行:`bash scripts/install_wechat_8.0.51.sh /path/to/xxx.apk` + +## 安装到设备 + +1. 设备 USB 连接电脑并开启 USB 调试。 +2. 确认设备已连接:`adb devices`。 +3. 执行安装脚本;覆盖安装使用 `adb install -r`,若报签名冲突可先卸载:`adb uninstall com.tencent.mm`。 + +## 版本信息 + +- 包名:`com.tencent.mm` +- 8.0.51 约 263 MB,需 Android 6.0+,架构 arm64-v8a。 diff --git a/sdk/docs/微信版本检查与升级.md b/sdk/docs/微信版本检查与升级.md new file mode 100644 index 0000000000..da286cd2c2 --- /dev/null +++ b/sdk/docs/微信版本检查与升级.md @@ -0,0 +1,78 @@ +# 微信版本检查与升级说明 + +## 一、当前设备微信状态(检查结果) + +| 项目 | 值 | +|------|-----| +| 设备 | 0a43392e0511(Redmi selene) | +| 包名 | com.tencent.mm | +| **当前安装版本** | **8.0.51**(versionCode 2720) | +| targetSdk | 30 | +| 安装路径 | /data/app/.../com.tencent.mm-.../base.apk | + +## 二、“版本过低”说明 + +微信服务器会要求客户端不低于某一版本,否则登录或部分功能会提示**“版本过低,请升级”**。 +当前安装的 **8.0.51** 已较旧,容易被判定为过低,需要升级到 **8.0.58 或更新版本**(如 8.0.60)才能正常使用。 + +## 三、升级步骤(确保微信可用) + +### 1. 下载更高版本 APK(在电脑浏览器中操作) + +在浏览器中打开下面任一链接,下载 **8.0.58 或 8.0.60** 的 APK(推荐 8.0.60): + +- **8.0.60(推荐,较新)** + https://www.apkmirror.com/apk/wechat-tencent/wechat/wechat-8-0-60-release/wechat-8-0-60-android-apk-download/ + +- **8.0.58(备选)** + https://www.apkmirror.com/apk/wechat-tencent/wechat/wechat-8-0-58-release/wechat-8-0-58-android-apk-download/ + +下载完成后,将 APK 保存到已知路径(如 `~/Downloads/`)。 + +### 2. 安装到当前手机 + +设备已通过 Type-C 连接且 ADB 可用时,在项目 `sdk` 目录下执行(把路径换成你下载的 APK 实际路径): + +```bash +cd "/Users/karuo/Documents/开发/2、私域银行/工作手机/sdk" +adb -s 0a43392e0511 install -r -t "/path/to/下载的微信APK.apk" +``` + +或使用现有安装脚本(若脚本支持任意路径): + +```bash +bash scripts/install_wechat_8.0.51.sh "/path/to/下载的微信APK.apk" 0a43392e0511 +``` + +安装完成后,在手机上打开微信,确认不再出现“版本过低”提示。 + +### 3. 再次确认版本(可选) + +安装后在电脑上执行: + +```bash +adb -s 0a43392e0511 shell dumpsys package com.tencent.mm | grep -E "versionName|versionCode" +``` + +应能看到 versionName 为 8.0.58 或 8.0.60,versionCode 大于 2720。 + +## 四、版本对照(便于排查) + +| 版本号 | versionCode | 说明 | +|--------|-------------|------| +| 8.0.51 | 2720 | 当前安装,易被提示“版本过低” | +| 8.0.58 | 更高 | 建议升级到此或更高 | +| 8.0.60 | 更高 | 推荐,满足当前服务端要求 | + +## 五、快速检查命令汇总 + +```bash +# 查看当前连接设备 +adb devices -l + +# 查看当前安装的微信版本 +adb -s 0a43392e0511 shell dumpsys package com.tencent.mm | grep -E "versionName|versionCode" + +# 覆盖安装新版本(替换为实际 APK 路径) +adb -s 0a43392e0511 install -r -t "/path/to/wechat.apk" +``` diff --git a/sdk/docs/设备端Hook版本安装与服务端对接手册.md b/sdk/docs/设备端Hook版本安装与服务端对接手册.md new file mode 100644 index 0000000000..d62cf90552 --- /dev/null +++ b/sdk/docs/设备端Hook版本安装与服务端对接手册.md @@ -0,0 +1,192 @@ +# 设备端 Hook 版本安装与服务端对接手册 + +## 1. 目标 + +- 在已 Root 的 Android 设备上安装 `工作` APP 与 Hook 能力 +- 设备通过 WebSocket 直连服务端,支持命令下发和结果回传 +- 提供稳定心跳、断线重连、在线状态检测 + +--- + +## 2. 设备端 ID 规则(奥创兼容) + +当前设备端默认 `device_id` 策略: + +- 首选:`md5(android_id)`(奥创兼容) +- 备用:`device_${MODEL}_${SDK_INT}` + +已保留并上报辅助字段(用于排障): + +- `device_profile.aochuang_device_id` +- `device_profile.android_id` +- `device_profile.serial` + +--- + +## 3. 安装步骤(Root 设备推荐) + +### 3.1 编译 APK + +```bash +cd sdk/android-app +./gradlew assembleDebug +``` + +APK 输出: + +- `sdk/android-app/app/build/outputs/apk/debug/app-debug.apk` + +### 3.2 USB 安装(Root 静默) + +```bash +adb push sdk/android-app/app/build/outputs/apk/debug/app-debug.apk /data/local/tmp/work.apk +adb shell "su -c 'pm install -r -g /data/local/tmp/work.apk'" +``` + +### 3.3 启动 APP + +```bash +adb shell "monkey -p com.workphone.agent -c android.intent.category.LAUNCHER 1" +``` + +--- + +## 4. 设备端配置 + +APP 设置页填写: + +- `server_url`: `ws://:8899/ws/device` +- `project_id`: 项目编号 +- `device_id`: 默认自动生成(奥创兼容),可手工覆盖 +- `auto_connect`: 建议开启 + +ADB 端口反向(本机联调): + +```bash +adb reverse tcp:8899 tcp:8899 +``` + +--- + +## 5. 服务端交互协议 + +### 5.1 连接地址 + +- `ws://:8899/ws/device/{device_id}` + +### 5.2 注册消息(设备 -> 服务端) + +```json +{ + "type": "register", + "device_id": "b63812da8452b7586ca01f3675ab31e5", + "project_id": "default", + "platform": "android", + "model": "21121119SC", + "sdk_version": 33, + "app_version": "2.0.0", + "heartbeat_interval_seconds": 30, + "device_profile": { + "aochuang_device_id": "b63812da8452b7586ca01f3675ab31e5", + "android_id": "b5f7a11643a1adc5", + "serial": "e4c02d0c0509" + } +} +``` + +### 5.3 心跳消息(设备 -> 服务端) + +```json +{ + "type": "heartbeat", + "device_id": "b63812da8452b7586ca01f3675ab31e5", + "timestamp": 1700000000000 +} +``` + +### 5.4 心跳配置(服务端 -> 设备) + +```json +{ + "type": "config", + "heartbeat_interval_seconds": 10 +} +``` + +### 5.5 执行命令(服务端 -> 设备) + +```json +{ + "type": "execute", + "command_id": "cmd_xxx", + "action": "open_app", + "params": { + "package": "com.tencent.mm" + } +} +``` + +### 5.6 执行结果(设备 -> 服务端) + +```json +{ + "type": "result", + "command_id": "cmd_xxx", + "device_id": "b63812da8452b7586ca01f3675ab31e5", + "success": true, + "message": "已打开 com.tencent.mm", + "timestamp": 1700000001000 +} +``` + +--- + +## 6. API 使用说明(服务端) + +### 6.1 设备与状态 + +- `GET /health`:服务健康与在线设备 +- `GET /api/v3/devices`:设备列表 +- `GET /api/v3/devices/{device_id}`:设备详情 +- `GET /api/v3/devices/{device_id}/heartbeat`:心跳状态 + +### 6.2 心跳配置 + +- `POST /api/v3/devices/{device_id}/heartbeat/config` + +请求体: + +```json +{ + "heartbeat_interval_seconds": 10 +} +``` + +取值范围:`5-120` 秒 + +### 6.3 设备执行 + +- `POST /api/v3/devices/{device_id}/execute` +- `POST /api/v3/devices/{device_id}/click` +- `POST /api/v3/devices/{device_id}/input` +- `POST /api/v3/devices/{device_id}/swipe` + +--- + +## 7. 心跳与重连机制 + +- 设备端按 `heartbeat_interval_seconds` 周期上报心跳 +- 设备端若连续 3 个周期未收到 pong,主动断线重连 +- 服务端后台巡检任务会清理超时设备(`max(WS_TIMEOUT, 3*heartbeat_interval)`) +- 设备断线后采用指数退避重连(最多 10 次) + +--- + +## 8. 验收清单 + +- APP 成功安装并启动前台服务 +- 设置页配置 `server_url/project_id/device_id` 完整 +- `/health` 能看到设备在线 +- 下发 `open_app/click/input/screenshot` 返回成功 +- 心跳接口显示 `stale=false`,`heartbeat_age_seconds` 持续更新 + diff --git a/开发文档/10、项目管理/工作日志.md b/开发文档/10、项目管理/工作日志.md index 77489c28de..54472882a0 100644 --- a/开发文档/10、项目管理/工作日志.md +++ b/开发文档/10、项目管理/工作日志.md @@ -5,6 +5,68 @@ --- +### 2026-02-28 | Soul 多维度调研文档(参考其形式开发 APP) + +**执行人**: 阿机 +**本次完成**: +- [x] 在 **开发文档/平台分析/Soul/调研/** 下新增多维度分析文档,供从技术、产品、安全、商业、体验等维度参考 Soul 形式开发自研 APP。 +- [x] 文档清单:README(索引)、01 产品与功能维度、02 技术架构与实现维度、03 安全与风控维度、04 商业与运营维度、05 体验与交互维度、06 参考 Soul 开发 APP 的技术基线与实现方式。 +- [x] 各文档均含「好处/坏处」「优点/缺点」拆解及可借鉴点;06 为技术基线、分阶段架构建议、实现方式选型与开发顺序,作为开发参考总纲。 +- [x] 更新 Soul/README:增加调研子目录入口。 + +**用途**: 立项或开发类似形态 APP 时,可按 01→06 顺序阅读,以 06 为技术基线与实现方式参考。 + +--- + +### 2026-02-28 | Soul 抓包流程加固与「继续直到成功」 + +**执行人**: 阿机 +**本次完成**: +- [x] mitmdump 增加 `-k`(不校验上游证书),解决 Soul 直连 IP(120.x/118.x)导致的「Certificate verify failed: IP address mismatch」,API 请求可被解密。 +- [x] soul_capture_addon.py:扩展 host(118.x)、排除纯静态路径(app-source、heads/avatar-)、按请求头名称遍历匹配 auth/token/sign/device,兼容不同大小写与命名。 +- [x] run_soul_capture.sh:抓包时长改为 180 秒,结束逻辑判断 ~/.soul_env 是否含非空 SOUL_AUTH_TOKEN/DEVICE_ID/API_SIGN,有则提示「✅ 已写入」,无则提示「⚠️ 请在抓包期间于 Soul 内发瞬间或刷新」。 +- [x] 文档 Soul自动化方案与命令行操作.md 7.1:明确写清「抓包期间 Soul 必须在前台并发瞬间或刷新」,否则无法拿到 Token。 + +**结论**: 抓包链路与脚本已就绪;成功写入 ~/.soul_env 的前提是:**运行 run_soul_capture.sh 的 180 秒内,Soul 在前台且执行发瞬间或刷新**。满足后 addon 会从 API 请求中提取头并写入,即可用 `source ~/.soul_env && ./soul_post_moment_mac.sh "内容"` 发瞬间。 + +--- + +### 2026-02-28 | Soul Mac 抓包与配置写入 + +**执行人**: 阿机 +**本次完成**: +- [x] 安装 mitmproxy(brew install mitmproxy),用于 Mac 上抓 Soul HTTPS。 +- [x] 编写 soul_capture_addon.py:mitmproxy 插件,捕获 api.soulapp.cn 请求头(X-Auth-Token、api-sign、device-id 等)并写入 ~/.soul_env。 +- [x] 执行抓包:代理临时切到 127.0.0.1:8080,mitmdump 运行 75 秒后恢复原代理 7897;本机该时段未抓到 Soul 发帖请求(Soul 未在抓包期间发帖或未走系统代理)。 +- [x] 新增 run_soul_capture.sh:一键抓包脚本(代理 8080、90 秒);用户在本机运行后,在 Soul 内发一条瞬间即可把 Token 等写入 ~/.soul_env。 +- [x] soul_post_moment_mac.sh 已支持从 ~/.soul_env 读取;抓包得到的数据会写入该文件,无需手填。 + +**后续**: 用户执行 `开发文档/平台分析/Soul/run_soul_capture.sh`,在 180 秒内打开 Soul 并发一条瞬间或刷新页面,即可自动写入 ~/.soul_env;之后用 `source ~/.soul_env && ./soul_post_moment_mac.sh "内容"` 发瞬间(若 api-sign 随请求变化则需每次发帖时重新抓包或实现签名算法)。 + +--- + +### 2026-02-28 | 开发文档分类与 Soul 专项整理 + +**执行人**: 阿表 + 阿机 +**本次完成**: +- [x] 新增 **平台分析** 分类:开发文档下增加「平台分析」目录,各平台(如 Soul)专项分析统一放此处,不散落。 +- [x] 新增 **平台分析/Soul/**:Soul 全部内容集中此目录。新建 [Soul项目分析.md](平台分析/Soul/Soul项目分析.md)(Soul 全部接口列表 + 全部功能列表);[Soul自动化方案与命令行操作.md](平台分析/Soul/Soul自动化方案与命令行操作.md) 从 9、手册 迁入。 +- [x] 开发文档 **四类分类**:在 README 中明确「需求与架构」「开发与接口」「部署与数据」「手册、验收与平台分析」四类,现有文档分别归属;根目录不出现文档,Soul 只出现在 平台分析/Soul。 + +--- + +### 2026-02-28 | Soul 自动化方案与 Skill 实现 + +**执行人**: 阿机 + 阿端 +**本次完成**: +- [x] 编写《Soul自动化方案与命令行操作》文档(开发文档/9、手册/):SOUL Mac 版结构说明、安卓包名 cn.soulapp.android、发瞬间/发视频/私聊/获取好友流程与命令行示例 +- [x] 新增 Agent 端 Soul Skill(sdk/agent/skills/soul/):post_moments、send_message、get_messages、get_contacts +- [x] 在 skills/__init__.py 注册 SoulSkill,skill_executor 与 agent 中增加 soul 分支;unified ADB 模式增加 soul 包名 + +**说明**: 用户通过 unified 接口指定 platform=soul 即可在已连接设备上执行发瞬间、发私聊、获取联系人;Mac 版 /Applications/SOUL.app 不在此 SDK 控制范围。 + +--- + ### 2026-02-24 | 工作手机项目上传 Gitea + GitHub **执行人**: 阿服 diff --git a/开发文档/9、手册/README.md b/开发文档/9、手册/README.md index 423055ac4c..2b395c65d2 100644 --- a/开发文档/9、手册/README.md +++ b/开发文档/9、手册/README.md @@ -2,7 +2,7 @@ **项目**:工作手机SDK v3.0(使用与操作以 SDK 操作手册、微信 E2E 验证为主;存客宝通过 API/SDK 调用。) -**规则**:本目录除本 README 外最多 **3 个主文档**,与全站开发文档规则一致;超出须合并。 +**规则**:本目录除本 README 外最多 **3 个主文档**,与全站开发文档规则一致;超出须合并。**Soul 相关手册已迁至 [平台分析/Soul/](../平台分析/Soul/)**,不在此目录。 **当前项目状态**:总进度 100%;微信消息 E2E 可按验证指南执行(需本地 SDK+Agent+模拟器微信)。进度以 [10、项目管理/开发进度总表.md](../10、项目管理/开发进度总表.md) 为准。 diff --git a/开发文档/README.md b/开发文档/README.md index de1c4a99b0..b8fc4b7f06 100644 --- a/开发文档/README.md +++ b/开发文档/README.md @@ -2,7 +2,7 @@ > **管理Skill**:**本项目目录下** `机擎/SKILL.md`(火炬总控,五人分配:阿表/阿机/阿桥/阿端/阿服) > **更新**:2026-02-07 | **当前项目状态**:总进度 **98%** -> **约定**:所有开发文档内容**仅在本目录下**;**根目录仅保留本 README**,不得在根目录放置其他 .md 或文档,所有内容归入 **1、需求/ … 10、项目管理/** 相应子目录;引用均以 **1、需求/ … 10、项目管理/** 为基准。 +> **约定**:所有开发文档内容**仅在本目录下**;**根目录仅保留本 README**,不得在根目录放置其他 .md 或文档;所有内容归入子目录,**不散落**——按「四类分类」各就各位;**Soul 相关内容统一放在 [平台分析/Soul/](平台分析/Soul/)**,不与其他目录混放。 --- @@ -11,7 +11,7 @@ **机擎负责所有项目管理、人员安排、全员学习开发文档;每次开发由机擎安排任务。** - **Skill 位置**:`机擎/SKILL.md`(火炬总控,阿表/阿机/阿桥/阿端/阿服 按岗位分配)。与卡若AI 为**交互关系**:卡若AI 涉及工作手机时读取该 Skill 并按其规则执行。 -- **开发文档归属**:所有开发文档内容必须在 **工作手机/开发文档/** 目录下;不在此目录外新增或生成开发文档类内容;新增文档归入对应子目录(1、需求 … 10、项目管理)。 +- **开发文档归属**:所有开发文档内容必须在 **工作手机/开发文档/** 目录下;不在此目录外新增或生成开发文档类内容;新增文档归入对应子目录(1、需求 … 10、项目管理;平台分析按平台分子目录,如 Soul)。 - **每次开发**:由机擎整理项目(读进度总表、工作日志、状态检查)→ 安排、分配任务 → 执行并更新文档。 --- @@ -48,20 +48,38 @@ --- -## 开发文档结构(仅 10 个目录,每目录 ≤3 个主文档) +## 开发文档四类分类(文档各就各位,不散落) + +开发文档按 **4 类** 归纳,现有文档分别归属以下 4 类,便于查找与维护: + +| 分类 | 所含目录 | 说明 | +|------|----------|------| +| **一、需求与架构** | 1、需求;2、架构;3、原型 | 需求、架构设计、原型规范 | +| **二、开发与接口** | 4、前端;5、接口;6、后端 | 前端/接口/后端实现与规范 | +| **三、部署与数据** | 7、数据库;8、部署 | 数据库、部署与凭证 | +| **四、手册、验收与平台分析** | 9、手册;10、项目管理;**平台分析** | 操作手册、进度验收;**各平台(如 Soul)专项分析统一放在「平台分析」** | + +- **平台分析**:按平台分子目录(如 `平台分析/Soul/`),该平台的所有文档(项目分析、接口与功能清单、自动化方案等)**只放在该平台目录内**,不散落在 1~10 其他目录。 +- 根目录**不放置**除本 README 以外的文档。 + +--- + +## 开发文档结构(子目录与主文档) | 目录 | 主文档(≤3) | 说明 | |------|----------------|------| -| [1、需求](1、需求/) | 项目概述、业务需求、成本与需求澄清 | 需求与澄清 | -| [2、架构](2、架构/) | 系统架构、技术选型与数据库、对接与方案补充(含存客宝对接架构,≤3 主文档) | 架构与 §3.0 模块拆解 | +| [1、需求](1、需求/) | 项目概述、业务需求、技术调研与方案选型 | 需求与澄清 | +| [2、架构](2、架构/) | 系统架构、技术选型与数据库、Hook通道与多设备多服务器架构 | 架构与 §3.0 模块拆解 | | [3、原型](3、原型/) | 原型设计规范 | 原型规范 | -| [4、前端](4、前端/) | v0配置、前端开发规范 | 前端规范 | -| [5、接口](5、接口/) | 接口规范、存客宝对接规范、通用服务交互层 | API 与对接 | -| [6、后端](6、后端/) | SDK服务端实现、Agent端技能实现、后端规范与代码汇总 | 服务端+Agent+规范 | -| [7、数据库](7、数据库/) | 数据库管理规范、数据库设计文档 | 数据层 | -| [8、部署](8、部署/) | 本地Docker部署指南、本地环境凭证、部署流程与提示词 | 部署与凭证 | -| [9、手册](9、手册/) | SDK操作手册、微信消息E2E验证指南、使用与落地方案 | 操作与验证 | -| [10、项目管理](10、项目管理/) | 开发进度总表、工作日志、验收与项目说明(含多端并行与附录 A/B/C,≤3 主文档) | 进度与验收(唯一进度入口) | +| [4、前端](4、前端/) | 前端开发规范、管理端前端开发规范、v0配置 | 前端规范 | +| [5、接口](5、接口/) | 接口规范、通用服务交互层、Hook模块管理接口 | API 与对接 | +| [6、后端](6、后端/) | SDK服务端实现文档、Agent端技能实现文档、后端规范与代码汇总 | 服务端+Agent+规范 | +| [7、数据库](7、数据库/) | 数据库设计文档、数据库管理规范 | 数据层 | +| [8、部署](8、部署/) | 本地Docker部署指南、设备端Hook安装部署、本地环境凭证 | 部署与凭证 | +| [9、手册](9、手册/) | SDK操作手册、微信消息E2E验证指南、使用与落地方案 | 操作与验证(**Soul 手册已迁至平台分析/Soul/**) | +| [10、项目管理](10、项目管理/) | 开发进度总表、工作日志、验收与项目说明 | 进度与验收(唯一进度入口) | +| **[平台分析](平台分析/)** | 按平台分子目录 | **各平台专项:接口+功能+自动化方案统一放此处** | +| [平台分析/Soul/](平台分析/Soul/) | Soul项目分析、Soul自动化方案与命令行操作 | **Soul 全部内容集中此目录** | --- diff --git a/开发文档/平台分析/README.md b/开发文档/平台分析/README.md new file mode 100644 index 0000000000..d280c31e05 --- /dev/null +++ b/开发文档/平台分析/README.md @@ -0,0 +1,26 @@ +# 平台分析 + +> **已统一迁至**:`/Users/karuo/Documents/开发/7.项目调研`,按项目分子目录(平台分析/聊天记录/对话分类/资料)。 +> 本目录仅作历史参考;**新增平台分析、项目调研、A 群聊天记录、对话分类**请一律归档到 **7.项目调研** 对应项目下。 + +--- + +## 规则(原说明,现以 7.项目调研 为准) + +- **每个平台一个子目录**,目录名与平台英文标识一致(如 `Soul`、`Wechat`)。 +- 平台目录内仅放该平台的:**项目分析(接口+功能)**、**自动化方案与操作手册**、**对接说明** 等,不散落在其他目录。 +- 主文档数量遵守开发文档总规则:每目录除 README 外最多 3 个主文档;子目录(如 `Soul/`)内可放 README + 2 个主文档。 + +--- + +## 当前平台 + +| 平台 | 目录 | 说明 | +|------|------|------| +| Soul | [Soul/](Soul/) | Soul 项目分析(接口+功能)、自动化方案与命令行操作 | + +--- + +## 与开发文档四类分类的关系 + +- 本目录属于「**开发与落地**」类:平台能力与对接方案集中在此,便于按平台查阅,不与其他 1~10 目录重复。 diff --git a/开发文档/平台分析/Soul/README.md b/开发文档/平台分析/Soul/README.md new file mode 100644 index 0000000000..d161d8355a --- /dev/null +++ b/开发文档/平台分析/Soul/README.md @@ -0,0 +1,21 @@ +# Soul 专项 + +> Soul(灵魂社交)相关文档统一放在本目录,不散落在其他开发文档目录。 + +--- + +## 文档列表(≤3 个主文档) + +| 文档 | 说明 | +|------|------| +| [Soul项目分析.md](Soul项目分析.md) | Soul 全部接口列表与全部功能列表,供对接与自动化参考 | +| [Soul自动化方案与命令行操作.md](Soul自动化方案与命令行操作.md) | 工作手机 SDK 控制 Soul 的自动化方案与 curl 示例 | +| [调研/](调研/) | **多维度调研**:产品、技术、安全、商业、体验、开发参考(共 6 份分析文档,供参考 Soul 形式开发 APP) | + +--- + +## 速查 + +- **安卓包名**:`cn.soulapp.android` +- **API 域**:`api.soulapp.cn`(主站);`openplatform-openapi.soulapp.cn`(开放平台/支付等) +- **工作手机 Skill**:`sdk/agent/skills/soul/` diff --git a/开发文档/平台分析/Soul/Soul自动化方案与命令行操作.md b/开发文档/平台分析/Soul/Soul自动化方案与命令行操作.md new file mode 100644 index 0000000000..18b223a71c --- /dev/null +++ b/开发文档/平台分析/Soul/Soul自动化方案与命令行操作.md @@ -0,0 +1,252 @@ +# Soul 自动化方案与命令行操作 + +> 创建日期:2026-02-28 | 目的:在手机上通过工作手机 SDK 自动化操作 Soul(发瞬间、发视频、自动聊天、获取好友),并由命令行/API 触发。 + +--- + +## 一、SOUL 应用结构说明 + +### 1.1 Mac 版 SOUL.app(/Applications/SOUL.app) + +- **性质**:macOS 客户端,为包装结构 `SOUL.app → WrappedBundle → Wrapper/Soul_New.app`。 +- **内容**:Soul_New.app 内包含多个功能模块 bundle,例如: + - `Publish.bundle`:发布相关(发瞬间/视频) + - `MainSquare.bundle`:广场/动态 + - `PrivateChat.bundle`:私聊 + - `ChatRoom.bundle`、`GroupChat.bundle`:聊天房/群聊 + - `Login.bundle`:登录 +- **自动化方式**:Mac 版若需自动化,需使用 macOS 侧方案(如 AppleScript、Accessibility、或 Appium for Mac)。**工作手机 SDK 不控制 Mac 应用**,仅控制安卓真机上的 APP。 + +### 1.2 安卓版 Soul(用于工作手机自动化) + +| 项目 | 说明 | +|------|------| +| **包名** | `cn.soulapp.android` | +| **应用名** | Soul(灵魂社交) | +| **主要 API 域** | api.soulapp.cn(若后续做抓包/接口直连可参考) | +| **签名** | 请求需 api-sign 等 Header,见逆向文档 | + +--- + +## 二、工作手机机擎与 Soul 的对应关系 + +- **unified 接口**:已支持 `Platform.SOUL`,发消息、发朋友圈/瞬间、好友、群聊等接口与微信/抖音共用同一套 REST(如 `POST /message/send`、`POST /moments/post`、`GET /contacts` 等),只需 `platform: "soul"`。 +- **设备端**:在安卓手机上安装 Soul 客户端,由工作手机 Agent(uiautomator2)控制 Soul APP 的 UI,实现: + - **发瞬间**:对应 `post_moments`(Soul 里即「发布瞬间」) + - **发视频**:同上,`post_moments` 传入 `video_url` 或本地路径,由 Skill 内选「视频」并选择文件 + - **自动聊天**:`send_message` 发私聊;可配合定时/脚本循环调用 + - **获取好友**:`get_contacts` 获取关注/好友列表(以 Soul 产品为准,可能是关注列表或聊天列表) + +--- + +## 三、Soul 自动化实现方式(在手机上) + +### 3.1 发瞬间(文字/图片/视频) + +- **接口**:`POST /unified/moments/post`,`platform: "soul"`。 +- **Agent 行为**(SoulSkill): + 1. 启动 Soul:`launch()`(包名 `cn.soulapp.android`)。 + 2. 点击发布入口(如底部「+」或「发布」)。 + 3. 选择类型:文字 / 图片 / 视频。 + 4. 若有内容:在输入框输入文案;若有图片/视频:从 `params.images` / `params.video_url` 通过 ADB push 到手机后选择文件。 + 5. 点击「发布」/「发送」完成发布。 + +### 3.2 发私聊(自动聊天) + +- **接口**:`POST /unified/message/send`,`platform: "soul"`,`to_id` 为对方 Soul 昵称或 ID。 +- **Agent 行为**: + 1. 启动 Soul,进入「消息」或「聊天」。 + 2. 搜索或选择 `to_id` 对应会话。 + 3. 输入 `content`,点击发送。 + 4. 若需「自动聊天」:由业务侧定时或按条件循环调用 `message/send` 即可。 + +### 3.3 获取好友/关注列表 + +- **接口**:`GET /unified/contacts?device_id=xxx&platform=soul&limit=200`。 +- **Agent 行为**: + 1. 启动 Soul,进入「消息」或「我的」→「关注/好友」相关 Tab。 + 2. 解析当前页 UI 或通过 dump hierarchy 抓取列表,组装为 `contacts` 列表返回。 + +### 3.4 发视频 + +- 与「发瞬间」同一流程,在 Soul 发布类型中选择「视频」,资源来自 `moments/post` 的 `video_url`(或先下载到手机再选文件)。 + +--- + +## 四、命令行操作示例(对工作手机 SDK 服务端) + +前提:SDK 服务端已启动(如 `http://127.0.0.1:8899`),且设备已连接并注册为 `device_id`。 + +### 4.1 发 Soul 瞬间(文字) + +```bash +curl -X POST "http://127.0.0.1:8899/unified/moments/post" \ + -H "Content-Type: application/json" \ + -d '{ + "device_id": "你的设备ID", + "platform": "soul", + "content": "今天天气不错", + "images": [] + }' +``` + +### 4.2 发 Soul 瞬间(带图/带视频) + +```bash +# 图片:images 传图床或可访问 URL,Agent 侧需支持下载到手机后选图 +curl -X POST "http://127.0.0.1:8899/unified/moments/post" \ + -H "Content-Type: application/json" \ + -d '{ + "device_id": "你的设备ID", + "platform": "soul", + "content": "分享一张图", + "images": ["https://example.com/photo.jpg"], + "video_url": null + }' + +# 仅视频 +curl -X POST "http://127.0.0.1:8899/unified/moments/post" \ + -H "Content-Type: application/json" \ + -d '{ + "device_id": "你的设备ID", + "platform": "soul", + "content": "视频描述", + "video_url": "https://example.com/video.mp4" + }' +``` + +### 4.3 自动聊天:发 Soul 私聊消息 + +```bash +curl -X POST "http://127.0.0.1:8899/unified/message/send" \ + -H "Content-Type: application/json" \ + -d '{ + "device_id": "你的设备ID", + "platform": "soul", + "to_id": "对方昵称或Soul ID", + "content": "你好呀", + "msg_type": "text" + }' +``` + +### 4.4 获取 Soul 好友/联系人列表 + +```bash +curl "http://127.0.0.1:8899/unified/contacts?device_id=你的设备ID&platform=soul&limit=200" +``` + +--- + +## 五、设备端 Soul Skill 已实现动作 + +| action | 说明 | 对应接口 | +|--------|------|----------| +| `post_moments` | 发瞬间(文字/图/视频) | POST /unified/moments/post | +| `send_message` | 发私聊 | POST /unified/message/send | +| `get_contacts` | 获取好友/关注列表 | GET /unified/contacts | +| `get_messages` | 获取某会话消息列表 | GET /unified/messages | + +Soul 的 UI 文案与资源 ID 可能随版本变化,若点击失败可结合 `screenshot` + `ui_tree` 或真机抓取当前版本的文案/ID,在 `sdk/agent/skills/soul/skill.py` 中调整选择器。 + +--- + +## 六、整体流程小结 + +1. **在安卓手机上**:安装 Soul(cn.soulapp.android),并安装/运行工作手机 Agent(连接同一台 SDK 服务端)。 +2. **在电脑/命令行**:通过 curl 或脚本调用上述 unified 接口,指定 `platform: "soul"` 和 `device_id`。 +3. **服务端**:将请求转发到对应设备,设备端 SoulSkill 通过 UI 自动化执行发瞬间、发消息、获取好友等。 +4. **Mac 版 SOUL.app**:不经过工作手机 SDK;若需 Mac 自动化,需单独做 Mac 上的 UI 自动化或协议研究。 + +--- + +## 七、Mac 上纯命令行发瞬间(不控鼠标、不控电脑) + +你当前在 **MacBook 上已登录 Soul**,希望用**一条命令**直接把瞬间发到 Soul,不控制鼠标、不控制电脑。 + +**做法**:不操作 Soul 界面,而是让**命令行直接请求 Soul 服务端 API**(`api.soulapp.cn`),你 Mac 上已登录的账号会同步到这条瞬间。 + +### 7.1 一键命令(需先配置一次 Token) + +1. **抓包取 Token(一次性)** + **重要**:抓包期间 Soul 必须在前台并产生 API 请求(发瞬间、刷新广场/个人页等),否则 mitmproxy 只会看到静态资源请求,无法拿到 Token。 + 在 Mac 上运行本目录的 `./run_soul_capture.sh`(或使用 Charles / mitmproxy):脚本会临时把系统代理切到 8080、运行约 180 秒;**请在这 180 秒内打开 Soul 并发一条瞬间或刷新页面**。抓包结束后若成功,会提示「已写入 ~/.soul_env」。 + 若未成功,请重新运行脚本,并确保抓包时段内 Soul 在前台且执行了发瞬间或刷新。 + 抓包成功后,在请求里可得到: + - `X-Auth-Token` + - `X-Auth-UserId` + - `device-id` + - `api-sign`(每次请求会变,见下) + - `app-time`、`request-nonce`、`app-version`(可选,有则一并记下) + +2. **api-sign 说明** + Soul 服务端会校验 `api-sign`,一般由路径、参数、时间戳等按固定算法生成。若你**没有**算法: + - 只能「重放」:把某一次发瞬间的请求完整复制(含当时的 api-sign),只能发当时那一句文案; + - 若**有**算法(或能复现逆向文档里的算法),可在脚本里按算法生成 api-sign,即可对**任意文案**一条命令发瞬间。 + +3. **执行脚本** + 本目录已放脚本 `soul_post_moment_mac.sh`,用法: + + ```bash + cd "开发文档/平台分析/Soul" + export SOUL_AUTH_TOKEN="抓包得到的X-Auth-Token" + export SOUL_USER_ID="抓包得到的X-Auth-UserId" + export SOUL_DEVICE_ID="抓包得到的device-id" + export SOUL_API_SIGN="抓包得到的api-sign(或你算法算出的)" + ./soul_post_moment_mac.sh "要发的瞬间内容" + ``` + + 若未配置上述环境变量,脚本会报错并提示缺少项。 + +### 7.2 纯 curl 示例(同上,需先抓包得到各 Header) + +```bash +# 将下方占位替换为抓包得到的真实值后再执行 +curl -X POST "https://api.soulapp.cn/v3/post/publish" \ + -H "Content-Type: application/json" \ + -H "X-Auth-Token: 你的Token" \ + -H "X-Auth-UserId: 你的用户ID" \ + -H "device-id: 你的设备ID" \ + -H "api-sign: 抓包得到的api-sign" \ + -H "app-time: $(date +%s)000" \ + -H "request-nonce: $(uuidgen | tr -d '-')" \ + -d '{"content":"来自命令行的瞬间"}' +``` + +路径 `/v3/post/publish` 以你抓包看到的为准,可能是 `/v3/post/create` 等,按实际改。 + +### 7.3 抓包时保持能上网(必装 mitmproxy 证书) + +- 抓包时系统代理会切到 8080,若**未安装 mitmproxy 的 CA 证书**,多数 HTTPS(含 Soul、Cursor)会报「不信任代理证书」并断连,无法上网。 +- **安装一次证书**:先开 mitmproxy(如 `mitmdump -p 8080`),系统代理设为 127.0.0.1:8080,浏览器打开 **http://mitm.it** → 选 Apple → 下载并安装到「钥匙串」→ 在「钥匙串访问」里找到 mitmproxy 证书,双击 → 「信任」选「始终信任」。装好后关掉 mitmdump、代理恢复原状。 +- 之后再跑 `run_soul_capture.sh` 时,Soul 和浏览器等走 8080 即可正常解密、上网,同时能抓到 Soul 的 API 请求并写入 `~/.soul_env`。 + +### 7.4 发布时出现服务端异常 502 + +- **现象**:发瞬间时提示「服务端异常 502」或 HTTP 502。 +- **可能原因**:① Soul 服务端或网关短暂不可用;② 工作手机 SDK 上游(设备/Agent)超时或未响应,网关返回 502。 +- **处理**: + - **走工作手机 unified 接口**:已改为发布接口**统一返回 HTTP 200**,业务失败时用 `success: false` + `error` 表示,不再用 502;若仍出现 502,多为前置 Nginx/网关超时,可调大超时或稍后重试。 + - **走 Mac 脚本直连 Soul API**:脚本已对 502 做提示并建议间隔 10~30 秒重试;可多次执行 `source ~/.soul_env && ./soul_post_moment_mac.sh "内容"`。 + +### 7.5 Soul 无法连接 / 网络报错时 + +- **现象**:Soul 提示「网络异常,请稍后再试」或「网络好像出了点小问题」,但本机浏览器等正常。 +- **常见原因**:系统代理指向了 127.0.0.1:8080(或其它本地代理),而代理未开或未放行 Soul,导致 Soul 请求失败。 +- **处理**:关闭系统代理,或把 Soul/直连用网络恢复后再开 Soul。 + - **关闭代理(终端执行)**:`networksetup -setwebproxystate "Wi-Fi" off && networksetup -setsecurewebproxystate "Wi-Fi" off` + - **关代理后仍报网络异常**:完全退出 Soul(Cmd+Q 或 右键 Dock 图标 → 退出),再重新打开 Soul,然后重试发布。 + - 若需保留代理(如 7897),抓包结束后务必恢复为原端口,避免误设为 8080 且 mitmdump 未运行导致 Soul 连不上、无法发布。 + +### 7.6 小结 + +- **Mac 上「不控鼠标、不控电脑」发瞬间** = 只用命令行调 Soul 服务端 API。 +- 需要:**Token + api-sign**(及 device-id 等)。Token 从 Mac Soul 抓包一次即可;api-sign 要么每次抓包复制,要么实现签名算法后脚本里自动生成,才能对任意内容一条命令发送。 + +--- + +## 八、参考 + +- 系统架构:`开发文档/2、架构/系统架构.md` +- unified 接口定义:`sdk/app/routers/unified.py` +- Soul 接口与功能清单:本目录 [Soul项目分析.md](Soul项目分析.md) +- Soul 安卓包名与 API:公开资料显示为 `cn.soulapp.android`、api.soulapp.cn;api-sign 等见逆向分析文档。 diff --git a/开发文档/平台分析/Soul/Soul项目分析.md b/开发文档/平台分析/Soul/Soul项目分析.md new file mode 100644 index 0000000000..225f1e7e28 --- /dev/null +++ b/开发文档/平台分析/Soul/Soul项目分析.md @@ -0,0 +1,150 @@ +# Soul 项目分析:接口与功能清单 + +> 创建日期:2026-02-28 | 用途:对接与自动化时查阅 Soul 全部能力与接口,统一放在本目录。 + +--- + +## 一、应用基本信息 + +| 项目 | 说明 | +|------|------| +| 应用名 | Soul(灵魂社交 / 年轻人的社交元宇宙) | +| 安卓包名 | `cn.soulapp.android` | +| 主 API 域 | `https://api.soulapp.cn` | +| 开放平台域 | `https://openplatform-openapi.soulapp.cn`(小游戏/支付/广告等) | +| 官方文档 | https://mp-doc.soulapp.cn/(开放平台文档中心) | +| 认证 | 请求需 `api-sign`、`X-Auth-Token`、`X-Auth-UserId`、`request-nonce`、`app-time`、`app-id`、`device-id` 等 Header | + +--- + +## 二、Soul 全部功能列表(产品侧) + +### 2.1 内容与动态 + +| 功能 | 说明 | +|------|------| +| 发瞬间 | 发布文字/图片/视频瞬间到广场 | +| 瞬间广场 | 浏览、刷新、下拉加载他人瞬间 | +| 点赞瞬间 | 对某条瞬间点赞 | +| 评论瞬间 | 对某条瞬间评论、回复评论 | +| 收藏瞬间 | 收藏某条瞬间 | +| 分享瞬间 | 分享到站内或站外 | +| 话题/标签 | 带话题或标签发布、按话题浏览 | + +### 2.2 私聊与消息 + +| 功能 | 说明 | +|------|------| +| 私聊 | 一对一文字/图片/语音/表情消息 | +| 消息列表 | 会话列表、未读、置顶 | +| 搜索会话/用户 | 按昵称或 Soul ID 搜索 | +| 打招呼/破冰 | 首次打招呼、快捷语 | + +### 2.3 群聊与派对 + +| 功能 | 说明 | +|------|------| +| 群聊派对 | 多人语音房、上麦、发言 | +| 聊天房 | 主题语音/聊天房间 | +| 群聊 | 多人文字群(若产品支持) | + +### 2.4 关系与发现 + +| 功能 | 说明 | +|------|------| +| 关注/粉丝 | 关注用户、查看粉丝与关注列表 | +| 灵魂匹配 | 基于灵魂鉴定/问卷的匹配推荐 | +| 推荐/发现 | 首页推荐用户或瞬间 | +| 打招呼列表 | 谁看过我、谁给我打招呼 | + +### 2.5 个人与资料 + +| 功能 | 说明 | +|------|------| +| 个人主页 | 头像、昵称、瞬间、关注数/粉丝数 | +| 3D 捏脸/头像 | 3D 头像定制 | +| 灵魂鉴定 | 答题生成灵魂类型、星球 | +| 3D 星球 | 按灵魂类型进入不同星球/社区 | +| 设置 | 隐私、通知、账号与安全 | + +### 2.6 其他 + +| 功能 | 说明 | +|------|------| +| 登录/注册 | 手机号、验证码、第三方登录 | +| 狼人杀/小游戏 | 站内小游戏(部分依赖开放平台) | +| 音乐房 | 语音房内播放音乐 | +| AI 对话 | 与 AI 角色对话(若产品支持) | + +--- + +## 三、Soul 接口列表(API 侧) + +> 以下为根据公开资料与常见命名整理的接口列表;非官方完整文档,实际以抓包/官方文档为准。 + +### 3.1 主站 API(api.soulapp.cn) + +| 模块 | 接口/路径示例 | 方法 | 说明 | +|------|----------------|------|------| +| 瞬间 | `/v3/post/publish` 或类似 | POST | 发布瞬间 | +| 瞬间 | `/v3/post/list`、`/v3/post/timeline` 等 | GET | 获取瞬间流/列表 | +| 瞬间 | `/v3/post/praise` | POST | 点赞(已知示例:?postId=xxx) | +| 瞬间 | `/v3/post/cancelPraise` | POST | 取消点赞 | +| 瞬间 | `/v3/post/comment`、`/v3/comment/add` 等 | POST | 评论 | +| 瞬间 | `/v3/post/commentList`、`/v3/comment/list` 等 | GET | 评论列表 | +| 瞬间 | `/v3/post/detail` | GET | 瞬间详情 | +| 瞬间 | `/v3/post/delete` | POST | 删除瞬间 | +| 私信 | `/v3/chat/send`、`/v3/message/send` 等 | POST | 发送私信 | +| 私信 | `/v3/chat/list`、`/v3/conversation/list` 等 | GET | 会话列表 | +| 私信 | `/v3/chat/history`、`/v3/message/list` 等 | GET | 消息历史 | +| 用户 | `/v3/user/profile`、`/v3/user/info` 等 | GET | 用户资料 | +| 用户 | `/v3/user/follow`、`/v3/follow/add` 等 | POST | 关注 | +| 用户 | `/v3/user/unfollow`、`/v3/follow/cancel` 等 | POST | 取消关注 | +| 用户 | `/v3/user/fans`、`/v3/follow/fans` 等 | GET | 粉丝列表 | +| 用户 | `/v3/user/followList`、`/v3/follow/list` 等 | GET | 关注列表 | +| 搜索 | `/v3/search/user`、`/v3/search/post` 等 | GET | 搜索用户/瞬间 | +| 登录/鉴权 | `/v3/auth/login`、`/v3/user/token` 等 | POST | 登录、刷新 Token | +| 设备/配置 | `/v3/config`、`/v3/app/version` 等 | GET | 配置、版本 | + +### 3.2 开放平台 API(openplatform-openapi.soulapp.cn) + +| 模块 | 接口/路径示例 | 方法 | 说明 | +|------|----------------|------|------| +| 支付 | `/order/unifiedOrder` | POST | 预付单/统一下单 | +| 支付 | 订单查询、回调等 | GET/POST | 订单状态、回调 | +| 广告 | 曝光、点击上报等 | POST | 广告相关 | +| 小游戏 | 根据开放平台文档 | - | 设备能力、分享等 | + +### 3.3 请求头(主站请求常见字段) + +| Header/参数 | 说明 | +|-------------|------| +| `api-sign` | 签名,由算法生成(路径、参数、nonce、时间戳等) | +| `X-Auth-Token` | 用户 Token | +| `X-Auth-UserId` | 用户 ID | +| `request-nonce` | UUID 去掉 `-` | +| `app-time` | 时间戳 | +| `app-id` | 应用 ID(如 10000003) | +| `app-version` | 应用版本 | +| `device-id` | 设备 ID | +| `os` | 如 android | + +--- + +## 四、与工作手机 SDK 的对应关系 + +| Soul 功能 | 工作手机 unified 能力 | SoulSkill action | +|-----------|----------------------|------------------| +| 发瞬间(文字/图/视频) | POST /unified/moments/post | post_moments | +| 发私聊 | POST /unified/message/send | send_message | +| 获取会话/好友列表 | GET /unified/contacts | get_contacts | +| 获取某会话消息 | GET /unified/messages | get_messages | +| 点赞/评论瞬间 | 可扩展 like_moments、comment_moments | 待实现 | + +--- + +## 五、参考与更新 + +- 官方开放平台:https://mp-doc.soulapp.cn/ +- 逆向/抓包参考:api.soulapp.cn、api-sign 算法分析(见第三方博客)。 +- 本文档随 Soul 版本与抓包结果更新,接口以实际抓包或官方文档为准。 diff --git a/开发文档/平台分析/Soul/run_soul_capture.sh b/开发文档/平台分析/Soul/run_soul_capture.sh new file mode 100755 index 0000000000..5b947a2116 --- /dev/null +++ b/开发文档/平台分析/Soul/run_soul_capture.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# 一键抓包:将代理切到 mitmproxy,抓 90 秒内 Soul 请求并写入 ~/.soul_env +# 运行后请在本机打开 Soul 并发一条瞬间,抓包结束会自动恢复代理。 +set -e +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ADDON="$SCRIPT_DIR/soul_capture_addon.py" +CAPTURE_PORT=8080 +# 原代理(若与本机一致可改) +ORIG_PROXY_HOST="${ORIG_PROXY_HOST:-127.0.0.1}" +ORIG_PROXY_PORT="${ORIG_PROXY_PORT:-7897}" + +echo "启动 mitmdump 抓包(端口 $CAPTURE_PORT,不校验上游证书以抓 Soul IP 直连)…" +export MITMPROXY_CONFDIR="$HOME/.mitmproxy" +/usr/local/bin/mitmdump -p $CAPTURE_PORT -k -s "$ADDON" 2>&1 & +MPID=$! +sleep 2 +echo "==============================================" +echo "【重要】代理已开启。请在 180 秒内:" +echo " 1. 打开 Soul 客户端并保持在前台" +echo " 2. 发一条瞬间 或 刷新广场/个人页" +echo "==============================================" +networksetup -setwebproxy "Wi-Fi" $ORIG_PROXY_HOST $CAPTURE_PORT 2>/dev/null || true +networksetup -setsecurewebproxy "Wi-Fi" $ORIG_PROXY_HOST $CAPTURE_PORT 2>/dev/null || true +sleep 180 +echo "恢复原代理 $ORIG_PROXY_HOST:$ORIG_PROXY_PORT" +networksetup -setwebproxy "Wi-Fi" $ORIG_PROXY_HOST $ORIG_PROXY_PORT 2>/dev/null || true +networksetup -setsecurewebproxy "Wi-Fi" $ORIG_PROXY_HOST $ORIG_PROXY_PORT 2>/dev/null || true +kill $MPID 2>/dev/null || true +if [[ -f "$HOME/.soul_env" ]]; then + HAS_AUTH=$(grep -E '^export SOUL_(AUTH_TOKEN|DEVICE_ID|API_SIGN)=' "$HOME/.soul_env" | sed 's/.*="\(.*\)".*/\1/' | grep -v '^$' | head -1) + if [[ -n "$HAS_AUTH" ]]; then + echo "✅ 已写入 $HOME/.soul_env,可直接发瞬间: source ~/.soul_env && $SCRIPT_DIR/soul_post_moment_mac.sh \"内容\"" + else + echo "⚠️ ~/.soul_env 存在但无认证信息。请重新运行本脚本,并在抓包期间于 Soul 内发一条瞬间或刷新页面。" + fi +else + echo "未抓到 Soul 请求。请重新运行,并确保抓包期间 Soul 在前台且执行了发瞬间/刷新操作。" +fi diff --git a/开发文档/平台分析/Soul/soul_capture_addon.py b/开发文档/平台分析/Soul/soul_capture_addon.py new file mode 100644 index 0000000000..c39a1ec692 --- /dev/null +++ b/开发文档/平台分析/Soul/soul_capture_addon.py @@ -0,0 +1,85 @@ +# mitmproxy addon: 捕获 Soul 请求头并写入 ~/.soul_env(含 soulapp.cn 及 Soul 使用的 IP 直连) +import os +import re + +def _is_static_path(path): + p = (path or "").lower() + return "app-source" in p or "/heads/avatar-" in p or "/image/202" in p or p.startswith("/app-source") + +def request(flow): + try: + host = flow.request.pretty_host or flow.request.host or "" + path = (flow.request.path or "").lower() + is_soul = ( + "soulapp.cn" in host + or host.startswith("47.") + or host.startswith("120.") + or host.startswith("118.") + ) + if not is_soul: + return + if _is_static_path(path): + return + out = os.path.expanduser("~/.soul_env") + headers = flow.request.headers + # 1) 标准名 + auth = headers.get("X-Auth-Token") or headers.get("x-auth-token") + uid = headers.get("X-Auth-UserId") or headers.get("x-auth-userid") + did = headers.get("device-id") or headers.get("Device-Id") + sign = headers.get("api-sign") or headers.get("Api-Sign") + app_time = headers.get("app-time") or headers.get("App-Time") + nonce = headers.get("request-nonce") or headers.get("Request-Nonce") + app_ver = headers.get("app-version") or headers.get("App-Version") + if not auth: + auth = (headers.get("Authorization") or "").strip() + if auth.startswith("Bearer "): + auth = auth[7:] + # 2) 遍历所有头,按名称匹配 auth/token/sign/device 等(兼容不同大小写/命名) + for name, value in headers.items(): + if not value: + continue + n = name.lower() + v = (value or "").strip() + if "auth" in n and "token" in n and not auth: + auth = v + elif "user" in n and "id" in n and not uid and len(v) < 50: + uid = uid or v + elif "device" in n and "id" in n and not did: + did = did or v + elif "sign" in n and not sign: + sign = sign or v + elif "app-time" in n or n == "app-time": + app_time = app_time or v + elif "nonce" in n and not nonce: + nonce = nonce or v + elif "version" in n and "app" in n and not app_ver: + app_ver = app_ver or v + cookie = headers.get("Cookie") or headers.get("cookie") or "" + if not auth and cookie: + for part in cookie.split(";"): + part = part.strip() + if "token" in part.lower() or "auth" in part.lower(): + m = re.search(r"([^=]+)=([^\s]+)", part) + if m: + auth = m.group(2).strip() + break + if not auth: + auth = "" + if not (auth or (uid or "").strip() or (did or "").strip() or (sign or "").strip()): + return + lines = [ + f'export SOUL_AUTH_TOKEN="{auth}"', + f'export SOUL_USER_ID="{uid or ""}"', + f'export SOUL_DEVICE_ID="{did or ""}"', + f'export SOUL_API_SIGN="{sign or ""}"', + f'export SOUL_APP_TIME="{app_time or ""}"', + f'export SOUL_REQUEST_NONCE="{nonce or ""}"', + f'export SOUL_APP_VERSION="{app_ver or ""}"', + ] + if cookie and not auth: + lines.append(f'export SOUL_COOKIE="{cookie[:500]}"') + with open(out, "w") as f: + f.write("\n".join(lines)) + print(f"[soul_capture] 已写入 {out} (host={host}, path={path[:50]})") + except Exception as e: + print(f"[soul_capture] 错误: {e}") diff --git a/开发文档/平台分析/Soul/soul_post_moment_mac.sh b/开发文档/平台分析/Soul/soul_post_moment_mac.sh new file mode 100755 index 0000000000..995204d541 --- /dev/null +++ b/开发文档/平台分析/Soul/soul_post_moment_mac.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# +# Mac 上纯命令行发 Soul 瞬间(不控制鼠标、不控制电脑) +# 直接请求 Soul 服务端 API,你当前 Mac 已登录的 Soul 会同步到这条瞬间。 +# +# 使用前:抓包 Mac 版 Soul 一次「发瞬间」请求,把 Token 等写入环境变量或 ~/.soul_env。 +# 之后一条命令即可: source ~/.soul_env && ./soul_post_moment_mac.sh "要发的文字" +# + +set -e +BASE_URL="https://api.soulapp.cn" + +# 若存在配置文件则加载(便于一条命令发送,无需每次 export) +if [[ -f "$HOME/.soul_env" ]]; then + source "$HOME/.soul_env" +fi + +# ========== 从环境变量读取(请先抓包 Mac Soul 发瞬间请求后填入,或写入 ~/.soul_env) ========== +SOUL_AUTH_TOKEN="${SOUL_AUTH_TOKEN:-}" +SOUL_USER_ID="${SOUL_USER_ID:-}" +SOUL_DEVICE_ID="${SOUL_DEVICE_ID:-}" +SOUL_APP_VERSION="${SOUL_APP_VERSION:-}" +SOUL_APP_TIME="${SOUL_APP_TIME:-$(date +%s)000}" +SOUL_REQUEST_NONCE="${SOUL_REQUEST_NONCE:-$(uuidgen | tr -d '-')}" +SOUL_API_SIGN="${SOUL_API_SIGN:-}" # 必须:抓包得到的 api-sign,或自行实现算法后生成 + +# 瞬间正文:脚本第一个参数,默认示例文案 +CONTENT="${1:-来自命令行的瞬间}" + +if [[ -z "$SOUL_AUTH_TOKEN" ]]; then + echo "错误: 请设置环境变量 SOUL_AUTH_TOKEN(从 Mac Soul 抓包获取)" + echo "示例: export SOUL_AUTH_TOKEN=xxx SOUL_USER_ID=xxx SOUL_DEVICE_ID=xxx SOUL_API_SIGN=xxx" + exit 1 +fi + +if [[ -z "$SOUL_API_SIGN" ]]; then + echo "错误: 请设置 SOUL_API_SIGN。Soul 服务端校验签名,需从 Mac 发瞬间时的请求里复制 api-sign,或自行实现签名算法。" + exit 1 +fi + +# 发布瞬间的 API 路径(根据抓包结果可能为 /v3/post/publish 或类似) +PATH_PUBLISH="${SOUL_PATH_PUBLISH:-/v3/post/publish}" +URL="${BASE_URL}${PATH_PUBLISH}" + +# 请求体(按 Soul 实际格式调整,常见为 JSON 含 content) +BODY=$(cat < 维度:产品定位、功能矩阵、优势与劣势、可借鉴点 +> 用途:为自研类似形态社交/内容 APP 提供产品与功能参考。 + +--- + +## 一、产品定位 + +| 项目 | 内容 | +|------|------| +| 产品名 | Soul(灵魂社交 / 年轻人的社交元宇宙) | +| 目标人群 | 年轻用户(Z 世代为主),强调「灵魂」匹配而非颜值 | +| 核心价值 | 匿名/弱身份下的内容表达与关系建立;兴趣与人格标签(灵魂鉴定、星球) | +| 形态 | 内容社区 + 私聊 + 语音派对/群聊 + 小游戏,偏「社交+内容+娱乐」综合体 | + +--- + +## 二、功能矩阵与模块划分 + +### 2.1 内容与动态 + +| 功能 | 说明 | 好处 | 坏处/注意 | +|------|------|------|----------| +| 发瞬间 | 文字/图/视频发布到广场 | 降低表达门槛,利于 UGC | 需审核与风控,存储与带宽成本高 | +| 瞬间广场 | 信息流、刷新、下拉加载 | 强留存与时长 | 推荐与冷启动难度大 | +| 点赞/评论/收藏/分享 | 互动闭环 | 提升粘性与传播 | 需防刷、反作弊 | +| 话题/标签 | 带话题发布、按话题浏览 | 利于发现与运营 | 依赖运营与用户习惯 | + +### 2.2 私聊与消息 + +| 功能 | 说明 | 好处 | 坏处/注意 | +|------|------|------|----------| +| 私聊 | 一对一文字/图/语音/表情 | 关系沉淀、变现入口 | 需消息存储、推送、多端同步 | +| 消息列表/未读/置顶 | 会话管理 | 体验清晰 | 需与推送策略配合 | +| 搜索会话/用户 | 按昵称或 ID 搜索 | 便捷触达 | 需防爬、限频、隐私合规 | +| 打招呼/破冰 | 首次打招呼、快捷语 | 降低破冰成本 | 易被滥用,需反骚扰 | + +### 2.3 群聊与派对 + +| 功能 | 说明 | 好处 | 坏处/注意 | +|------|------|------|----------| +| 语音房/派对 | 多人上麦、发言 | 高时长、强互动 | 实时音视频成本与合规(内容安全) | +| 聊天房 | 主题房间 | 便于运营与活动 | 需房主/房管能力与风控 | +| 群聊 | 多人文字群 | 关系链沉淀 | 需群管理、禁言、举报等 | + +### 2.4 关系与发现 + +| 功能 | 说明 | 好处 | 坏处/注意 | +|------|------|------|----------| +| 关注/粉丝 | 单向关注、列表 | 关系链清晰、可做推荐 | 需防刷粉、虚假关注 | +| 灵魂匹配 | 问卷/灵魂鉴定匹配 | 差异化、话题感 | 算法与产品设计难度高 | +| 推荐/发现 | 首页推荐用户或瞬间 | 冷启动与留存 | 依赖算法与数据 | +| 谁看过我/打招呼列表 | 发现与破冰 | 提升转化 | 易被滥用,需限频与隐私 | + +### 2.5 个人与资料 + +| 功能 | 说明 | 好处 | 坏处/注意 | +|------|------|------|----------| +| 个人主页 | 头像、昵称、瞬间、关注数/粉丝数 | 人设与信任 | 需防作弊与虚假展示 | +| 3D 捏脸/头像 | 3D 头像定制 | 差异化、年轻化 | 开发与渲染成本高 | +| 灵魂鉴定/星球 | 答题生成类型、进入星球 | 强心智与归属 | 需持续运营与迭代 | +| 设置 | 隐私、通知、账号与安全 | 合规与体验 | 需与隐私政策一致 | + +### 2.6 其他 + +| 功能 | 说明 | 好处 | 坏处/注意 | +|------|------|------|----------| +| 登录/注册 | 手机号、验证码、第三方 | 降低注册门槛 | 需防刷号、实名与合规 | +| 小游戏/狼人杀 | 站内小游戏 | 提升时长与变现 | 依赖开放平台或自研 | +| AI 对话 | 与 AI 角色对话 | 新体验、可探索变现 | 成本与效果需平衡 | + +--- + +## 三、产品维度:优势与劣势汇总 + +### 3.1 优势(好处) + +| 维度 | 说明 | +|------|------| +| 定位清晰 | 「灵魂社交」差异化,弱化颜值,利于部分用户表达与匹配 | +| 功能矩阵完整 | 内容+私聊+派对+关系+个人+小游戏,覆盖社交与娱乐场景 | +| 心智设计强 | 灵魂鉴定、星球等形成强归属与话题 | +| 形态现代 | 3D 捏脸、语音房等符合年轻用户偏好 | +| 变现路径多 | 会员、虚拟礼物、小游戏、广告等 | + +### 3.2 劣势(坏处/风险) + +| 维度 | 说明 | +|------|------| +| 合规与内容安全 | UGC、语音房、私聊均需强审核与风控,成本与风险高 | +| 依赖算法与运营 | 推荐、匹配、冷启动依赖数据与运营,门槛高 | +| 复杂度高 | 功能多,研发与维护成本大,版本迭代压力大 | +| 同质化竞争 | 与探探、陌陌、兴趣社交等存在重叠,需持续差异化 | + +--- + +## 四、可借鉴点(参考其形式做产品) + +- **模块化功能**:按「内容 / 私聊 / 派对 / 关系 / 个人 / 其他」拆模块,便于 MVP 与迭代。 +- **心智与归属**:类似「灵魂鉴定+星球」的轻量人设与分区,可降低冷启动并提升留存。 +- **互动闭环**:点赞、评论、收藏、分享、打招呼等形成闭环,便于指标与增长设计。 +- **分层变现**:会员、虚拟礼物、小游戏、广告等多条线,可参考其组合方式做商业化设计。 diff --git a/开发文档/平台分析/Soul/调研/02_Soul技术架构与实现维度分析.md b/开发文档/平台分析/Soul/调研/02_Soul技术架构与实现维度分析.md new file mode 100644 index 0000000000..de995c47a3 --- /dev/null +++ b/开发文档/平台分析/Soul/调研/02_Soul技术架构与实现维度分析.md @@ -0,0 +1,117 @@ +# Soul 技术架构与实现维度分析 + +> 维度:客户端、服务端、API 与协议、技术栈、优势与劣势、实现参考 +> 用途:为自研类似形态 APP 提供技术架构与实现方式参考。 + +--- + +## 一、整体架构概览 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Soul 技术架构(推断) │ +├─────────────────────────────────────────────────────────────────────┤ +│ 客户端(Android / iOS / Mac) │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ 业务模块 │ │ 通用能力 │ │ 网络/存储 │ │ +│ │ Publish │ │ 登录/配置 │ │ HTTP/2 API │ │ +│ │ MainSquare │ │ 推送/统计 │ │ 本地缓存 │ │ +│ │ PrivateChat │ │ 安全/签名 │ │ 图片/媒体 │ │ +│ │ ChatRoom │ │ │ │ │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ ↓ ↓ ↓ │ +│ ───────────────────────────────────────────────────────────────── │ +│ 网关 / CDN / 负载均衡 → 主站 API(api.soulapp.cn) │ +│ → 开放平台(openplatform-openapi.soulapp.cn) │ +│ → 资源域(china-img.soulapp.cn 等) │ +│ ───────────────────────────────────────────────────────────────── │ +│ 服务端:业务服务、推荐、消息、存储、实时音视频等 │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 二、客户端 + +### 2.1 多端形态 + +| 端 | 包名/形态 | 说明 | +|----|-----------|------| +| Android | `cn.soulapp.android` | 主端,功能最全 | +| iOS | App Store 应用 | 与 Android 能力对齐 | +| Mac | SOUL.app(Catalyst 或类似) | 多 bundle:Publish、MainSquare、PrivateChat、ChatRoom、Login 等 | + +### 2.2 客户端技术栈(推断) + +| 层面 | 可能技术 | 好处 | 坏处 | +|------|----------|------|------| +| UI/框架 | 原生 + 部分 Hybrid/RN | 性能与体验可控 | 多端重复开发 | +| 网络 | HTTP/2、API 域名 + 直连 IP | 减少 DNS、可做灰度 | 证书与 IP 变更需兼容 | +| 安全 | 请求签名(api-sign)、Token | 防篡改、防重放 | 逆向后可被模拟,需持续迭代 | +| 存储 | 本地缓存、配置、用户数据 | 离线与启动速度 | 需加密与清理策略 | + +### 2.3 客户端优缺点(技术维度) + +| 类型 | 说明 | +|------|------| +| 优点 | 模块化(bundle/业务拆分清晰);多端覆盖;请求带签名与鉴权,安全基线高 | +| 缺点 | 多端维护成本大;直连 IP 对抓包/代理不友好,对自建代理或测试环境需额外处理 | + +--- + +## 三、服务端与 API + +### 3.1 API 域与职责 + +| 域 | 用途 | 说明 | +|----|------|------| +| api.soulapp.cn | 主站业务 API | 瞬间、私聊、用户、关系、搜索、登录等 | +| 直连 IP(如 120.x、47.x) | 部分请求直连 | 可能为就近接入或灰度,需 TLS SNI/证书兼容 | +| openplatform-openapi.soulapp.cn | 开放平台 | 支付、广告、小游戏等 | +| china-img.soulapp.cn 等 | 静态资源/CDN | 图片、头像、媒体 | + +### 3.2 请求形态(主站) + +| 项目 | 内容 | +|------|------| +| 协议 | HTTPS、HTTP/2 | +| 鉴权 | Header:X-Auth-Token、X-Auth-UserId、device-id、app-id、app-version、os | +| 签名 | api-sign(与路径、参数、nonce、时间戳等相关,防篡改/重放) | +| 其他 | request-nonce(如 UUID 无横线)、app-time(时间戳) | + +### 3.3 接口分层(按业务) + +| 模块 | 路径示例 | 方法 | 说明 | +|------|----------|------|------| +| 瞬间 | /v3/post/publish、/v3/post/list、/v3/post/comment 等 | POST/GET | 发布、列表、评论、点赞 | +| 私信 | /v3/chat/send、/v3/chat/list、/v3/chat/history 等 | POST/GET | 发送、会话列表、历史 | +| 用户 | /v3/user/profile、/v3/user/follow、/v3/user/fans 等 | GET/POST | 资料、关注、粉丝 | +| 搜索 | /v3/search/user、/v3/search/post 等 | GET | 搜索 | +| 登录/鉴权 | /v3/auth/login、/v3/user/token 等 | POST | 登录、刷新 Token | + +### 3.4 服务端技术优缺点(推断) + +| 类型 | 说明 | +|------|------| +| 优点 | 接口 REST 化、版本化(v3);域名与资源分离,利于 CDN 与扩展;开放平台独立,便于生态与合规 | +| 缺点 | 签名与多 Header 增加对接与调试成本;直连 IP 依赖证书与运维,对故障排查与代理不友好 | + +--- + +## 四、数据与存储(推断) + +| 类型 | 可能方案 | 好处 | 坏处 | +|------|----------|------|------| +| 关系型 | 用户、关系、配置、订单等 | 事务与一致性 | 扩展需分库分表或中间件 | +| 文档/缓存 | 内容、消息、会话、推荐 | 灵活、高并发 | 需一致性设计与缓存策略 | +| 对象存储 | 图片、视频、语音 | 成本与扩展性好 | 需 CDN 与审核 | +| 实时 | 语音房、消息推送 | 低延迟 | 成本与运维复杂度高 | + +--- + +## 五、实现参考(参考其形式做技术选型) + +- **接口设计**:REST、版本前缀(如 /v3/)、模块化路径(/post、/chat、/user、/search)。 +- **鉴权与签名**:Token + 设备维度(device-id、app-id、app-version)+ 请求级签名(nonce+时间戳+路径+参数),防重放与篡改。 +- **多域与资源分离**:主 API、开放平台、静态资源分域,便于权限、CDN 与扩展。 +- **客户端**:按业务模块拆分(发布、广场、私聊、房间、登录),便于多端复用与灰度发布。 diff --git a/开发文档/平台分析/Soul/调研/03_Soul安全与风控维度分析.md b/开发文档/平台分析/Soul/调研/03_Soul安全与风控维度分析.md new file mode 100644 index 0000000000..783cb8d6f0 --- /dev/null +++ b/开发文档/平台分析/Soul/调研/03_Soul安全与风控维度分析.md @@ -0,0 +1,96 @@ +# Soul 安全与风控维度分析 + +> 维度:认证、签名、反爬与反作弊、合规、优势与劣势 +> 用途:为自研类似形态 APP 提供安全与风控设计参考。 + +--- + +## 一、认证与鉴权 + +### 1.1 请求头与身份 + +| Header/参数 | 作用 | 好处 | 坏处 | +|-------------|------|------|------| +| X-Auth-Token | 用户会话/访问令牌 | 无状态、可过期与刷新 | 泄露后可冒充,需绑定设备/环境 | +| X-Auth-UserId | 用户 ID | 明确身份 | 需与 Token 一致校验 | +| device-id | 设备标识 | 设备维度风控、多端管理 | 可伪造,需结合其他信号 | +| app-id / app-version / os | 应用与环境 | 版本与平台校验、灰度 | 可被篡改,需服务端校验 | +| request-nonce | 请求唯一标识 | 防重放 | 需服务端去重与时效 | +| app-time | 客户端时间戳 | 参与签名、时效控制 | 时间不同步会影响签名 | + +### 1.2 认证流程(推断) + +- 登录:手机号/验证码或第三方 → 服务端下发 Token(及可能 RefreshToken)。 +- 请求:每次请求带 Token + device-id + api-sign;服务端校验 Token 有效性、签名、nonce、时间窗口。 + +### 1.3 优缺点 + +| 类型 | 说明 | +|------|------| +| 优点 | 多维度鉴权(用户+设备+应用);nonce+时间防重放;签名防篡改 | +| 缺点 | 实现与对接复杂;客户端时间或环境异常会导致失败;Token 泄露仍可被滥用,需限频与风控 | + +--- + +## 二、请求签名(api-sign) + +### 2.1 作用与形态 + +- **作用**:防止参数被篡改、请求被重放、未授权方直接调用 API。 +- **形态**:Header `api-sign`,通常与路径、请求体/参数、nonce、时间戳、密钥等参与运算(具体算法需逆向,此处仅作设计参考)。 + +### 2.2 好处与坏处 + +| 类型 | 说明 | +|------|------| +| 好处 | 提升接口安全性;第三方难以直接伪造合法请求;可与设备/环境绑定 | +| 坏处 | 算法一旦逆向可被模拟;密钥与逻辑需妥善保护;多端与历史版本需兼容,增加维护成本 | + +--- + +## 三、反爬与反作弊 + +### 3.1 可能手段(推断) + +| 手段 | 说明 | 好处 | 坏处 | +|------|------|------|------| +| 请求签名 | 见上 | 提高爬虫与脚本成本 | 不影响有逆向能力的脚本 | +| 限频/限流 | 按 IP、设备、用户限频 | 防刷、降成本 | 正常用户异常环境可能误伤 | +| 设备指纹 | device-id 等 | 识别多账号、机器 | 隐私与合规需注意 | +| 行为与内容风控 | 举报、审核、模型 | 内容安全、合规 | 需持续迭代与人工兜底 | + +### 3.2 优缺点 + +| 类型 | 说明 | +|------|------| +| 优点 | 多层防护(签名+限频+设备+行为),降低批量爬取与作弊成功率 | +| 缺点 | 与体验、隐私的平衡;强对抗下需持续迭代,成本高 | + +--- + +## 四、合规与内容安全 + +### 4.1 相关维度 + +| 维度 | 说明 | 好处 | 坏处 | +|------|------|------|------| +| 实名与认证 | 手机号、实名等 | 满足监管与风控 | 影响匿名体验与转化 | +| 内容审核 | UGC、语音、私聊 | 合规与品牌 | 成本高、有误判与延迟 | +| 隐私与协议 | 隐私政策、权限说明 | 合规与信任 | 需与产品、法务对齐 | +| 未成年人保护 | 防沉迷、权限限制 | 合规 | 产品与策略需专门设计 | + +### 4.2 优缺点 + +| 类型 | 说明 | +|------|------| +| 优点 | 合规基线可降低监管与法律风险 | +| 缺点 | 审核与实名会带来成本与体验代价,需在合规与体验间权衡 | + +--- + +## 五、可借鉴点(参考其形式做安全) + +- **多层鉴权**:Token + 设备 + 应用版本 + 请求级 nonce/时间戳,便于风控与限频。 +- **请求签名**:关键接口使用签名(路径+参数+nonce+时间+密钥),防篡改与重放。 +- **限频与设备维度**:按用户/设备/IP 限频,结合设备指纹识别异常与多账号。 +- **合规前置**:实名、隐私政策、内容审核、未成年人保护与产品设计同步考虑。 diff --git a/开发文档/平台分析/Soul/调研/04_Soul商业与运营维度分析.md b/开发文档/平台分析/Soul/调研/04_Soul商业与运营维度分析.md new file mode 100644 index 0000000000..3c803e5960 --- /dev/null +++ b/开发文档/平台分析/Soul/调研/04_Soul商业与运营维度分析.md @@ -0,0 +1,74 @@ +# Soul 商业与运营维度分析 + +> 维度:变现方式、留存与增长、运营手段、优势与劣势 +> 用途:为自研类似形态 APP 提供商业化与运营参考。 + +--- + +## 一、变现方式 + +### 1.1 主要变现路径(推断) + +| 方式 | 说明 | 好处 | 坏处 | +|------|------|------|------| +| 会员/订阅 | 会员权益(如查看更多、去广告、特权) | 稳定、可预测 | 依赖付费意愿与权益设计 | +| 虚拟礼物/打赏 | 语音房、瞬间等场景打赏 | 高 ARPU、强场景 | 需分成与合规(主播、资质) | +| 小游戏 | 站内小游戏、道具或激励 | 提升时长与变现 | 依赖开放平台或自研,需防沉迷 | +| 广告 | 信息流、开屏、激励视频等 | 规模变现 | 与体验平衡,需填充与投放能力 | +| 其他 | 如表情包、皮肤、装扮等 | 补充收入 | 需与产品调性匹配 | + +### 1.2 优缺点 + +| 类型 | 说明 | +|------|------| +| 优点 | 多条变现线,不依赖单一收入;虚拟礼物与场景结合紧,付费动机强 | +| 缺点 | 会员与广告可能影响体验;虚拟礼物与主播生态需运营与合规;小游戏需持续供给与防沉迷 | + +--- + +## 二、留存与增长 + +### 2.1 留存设计(推断) + +| 手段 | 说明 | 好处 | 坏处 | +|------|------|------|------| +| 内容与推荐 | 广场、推荐、话题 | 日活与时长 | 依赖算法与内容供给 | +| 关系链 | 关注、粉丝、私聊、群组 | 沉淀关系,提升复访 | 需防骚扰与虚假关系 | +| 心智与归属 | 灵魂鉴定、星球、身份 | 情感与归属感 | 需持续运营与迭代 | +| 推送与触达 | 消息、系统通知 | 召回与活跃 | 需控制推送频率与精准度 | +| 签到/任务 | 签到、任务、等级 | 习惯与激励 | 易疲劳,需与权益结合 | + +### 2.2 增长设计(推断) + +| 手段 | 说明 | 好处 | 坏处 | +|------|------|------|------| +| 拉新 | 邀请、分享、渠道投放 | 扩大用户基盘 | 成本与质量需平衡 | +| 冷启动 | 灵魂鉴定、推荐、破冰 | 降低首日流失 | 依赖算法与产品设计 | +| 裂变 | 分享、邀请奖励 | 低成本获客 | 需防刷与风控 | + +### 2.3 优缺点 + +| 类型 | 说明 | +|------|------| +| 优点 | 内容+关系+心智+推送+任务多管齐下,留存与增长手段丰富 | +| 缺点 | 依赖算法与运营投入;推送与任务过度可能引起反感;需持续迭代与数据驱动 | + +--- + +## 三、运营手段(推断) + +| 类型 | 说明 | 好处 | 坏处 | +|------|------|------|------| +| 话题/活动 | 运营话题、活动、挑战 | 提升 UGC 与传播 | 需创意与执行 | +| 用户分层 | 新老、活跃/沉默、付费 | 精准运营 | 需数据与策略 | +| 客服与举报 | 举报、申诉、客服 | 体验与合规 | 成本与响应速度 | +| 生态 | 开放平台、小游戏、MCN | 丰富内容与变现 | 依赖合作与治理 | + +--- + +## 四、可借鉴点(参考其形式做商业与运营) + +- **变现组合**:会员 + 虚拟礼物 + 小游戏 + 广告,多条线分散风险并提升 LTV。 +- **留存**:内容与推荐 + 关系链 + 心智设计(如灵魂/星球)+ 推送与任务,形成闭环。 +- **增长**:冷启动与破冰设计 + 邀请与裂变 + 渠道投放,配合风控与防刷。 +- **运营**:话题与活动 + 用户分层 + 举报与客服,与产品与合规对齐。 diff --git a/开发文档/平台分析/Soul/调研/05_Soul体验与交互维度分析.md b/开发文档/平台分析/Soul/调研/05_Soul体验与交互维度分析.md new file mode 100644 index 0000000000..0c716c9ff0 --- /dev/null +++ b/开发文档/平台分析/Soul/调研/05_Soul体验与交互维度分析.md @@ -0,0 +1,73 @@ +# Soul 体验与交互维度分析 + +> 维度:体验设计、交互形式、优势与劣势 +> 用途:为自研类似形态 APP 提供体验与交互参考。 + +--- + +## 一、整体体验特征 + +| 特征 | 说明 | 好处 | 坏处 | +|------|------|------|------| +| 匿名/弱身份 | 不强调真人头像、可 3D 捏脸 | 降低社交压力、差异化 | 信任建立慢,需其他信任信号 | +| 内容优先 | 瞬间广场、信息流为主入口 | 内容消费与时长 | 关系链弱时可能冷启动难 | +| 多维入口 | 广场、消息、发布、我的等 | 路径清晰、功能可达 | 需防止功能过多导致杂乱 | +| 年轻化 | 3D、星球、灵魂鉴定等 | 贴合目标用户 | 非目标用户可能无感 | + +--- + +## 二、交互形式 + +### 2.1 导航与结构 + +| 形式 | 说明 | 好处 | 坏处 | +|------|------|------|------| +| 底部 Tab | 多 Tab 切换(如广场、消息、发布、我的) | 符合移动端习惯、可达性高 | Tab 过多会拥挤 | +| 信息流 | 上下滑动、下拉刷新、上拉加载 | 沉浸、时长友好 | 需加载与推荐体验优化 | +| 发布入口 | 显眼发布按钮(如底部中央或 Tab) | 降低发布门槛 | 需防误触与滥用 | + +### 2.2 内容与互动 + +| 形式 | 说明 | 好处 | 坏处 | +|------|------|------|------| +| 点赞/评论/分享 | 常规互动 | 心智简单、传播 | 需防刷与骚扰 | +| 私聊入口 | 从内容/主页进入私聊 | 转化路径短 | 需防骚扰与举报机制 | +| 语音房/派对 | 上麦、发言、礼物 | 强互动与时长 | 需内容安全与体验平衡 | + +### 2.3 个人与身份 + +| 形式 | 说明 | 好处 | 坏处 | +|------|------|------|------| +| 3D 捏脸/头像 | 自定义形象 | 个性与隐私兼顾 | 开发与性能成本 | +| 灵魂鉴定/星球 | 答题与分区 | 归属感与话题 | 需持续运营 | +| 个人主页 | 瞬间、关注/粉丝、资料 | 人设展示 | 需防虚假与作弊展示 | + +--- + +## 三、体验维度:优势与劣势 + +### 3.1 优势 + +| 维度 | 说明 | +|------|------| +| 差异化 | 匿名+灵魂+星球形成清晰心智,与颜值社交区分 | +| 入口与路径 | 底部 Tab + 信息流 + 发布入口,符合习惯 | +| 互动闭环 | 点赞、评论、私聊、语音房覆盖多种互动 | +| 年轻化表达 | 3D、星球、鉴定等贴合年轻用户 | + +### 3.2 劣势 + +| 维度 | 说明 | +|------|------| +| 复杂度 | 功能多,新用户学习成本与认知负担 | +| 信任与安全 | 匿名带来信任与内容安全挑战,需强运营与风控 | +| 多端一致 | 多端体验与性能需持续打磨 | + +--- + +## 四、可借鉴点(参考其形式做体验) + +- **心智与身份**:用轻量「人设/分区」(如灵魂/星球)降低压力并提升归属,避免一上来强实名。 +- **导航**:底部主 Tab + 信息流 + 显眼发布,保持路径简单。 +- **互动**:点赞、评论、私聊、语音房等分层设计,兼顾内容消费与关系建立。 +- **年轻化**:在视觉与玩法上做年轻化(如 3D、答题、主题房),与目标人群匹配。 diff --git a/开发文档/平台分析/Soul/调研/06_参考Soul开发APP的技术基线与实现方式.md b/开发文档/平台分析/Soul/调研/06_参考Soul开发APP的技术基线与实现方式.md new file mode 100644 index 0000000000..27b53eb123 --- /dev/null +++ b/开发文档/平台分析/Soul/调研/06_参考Soul开发APP的技术基线与实现方式.md @@ -0,0 +1,96 @@ +# 参考 Soul 开发 APP 的技术基线与实现方式 + +> 用途:以 Soul 为参照,给出自研类似形态 APP 的**技术基线、架构建议与实现方式**,供立项与开发参考。 +> 依赖:建议先阅读 01~05 各维度分析,再使用本文档做落地对照。 + +--- + +## 一、技术基线总览 + +以下为「参考 Soul 形式」开发一款社交/内容 APP 时,建议具备的技术基线。 + +| 层级 | 基线项 | 说明 | 参考 Soul 对应 | +|------|--------|------|----------------| +| 客户端 | 多端 | Android + iOS,可选 Mac/Web | 多端覆盖,Mac 为 bundle 化 | +| 客户端 | 模块化 | 按业务拆模块(发布/广场/消息/房间/我的/登录) | Publish、MainSquare、PrivateChat 等 | +| 客户端 | 网络与安全 | HTTPS、Token、设备标识、请求签名 | api-sign、X-Auth-Token、device-id | +| 服务端 | API 设计 | REST、版本前缀、模块化路径 | /v3/post、/v3/chat、/v3/user | +| 服务端 | 鉴权 | 用户 Token + 设备 + 请求级 nonce/时间戳 | 见 03 安全分析 | +| 服务端 | 多域 | 主 API、开放平台、静态资源分离 | api.soulapp.cn、openplatform、china-img | +| 数据 | 存储 | 用户/关系/内容/消息/媒体分层存储 | 关系型+文档/缓存+对象存储 | +| 安全 | 风控 | 签名、限频、设备、内容审核 | 见 03 | +| 合规 | 实名与内容 | 实名、隐私、审核、未成年人 | 见 03、04 | + +--- + +## 二、架构建议(分阶段) + +### 2.1 MVP 阶段 + +| 模块 | 建议 | 说明 | +|------|------|------| +| 客户端 | 单端(Android 或 iOS)先行 | 验证产品与核心链路 | +| 功能 | 登录 + 发内容 + 信息流 + 简单互动(点赞/评论)+ 私聊 | 对应 Soul 的发布、广场、私聊最小集 | +| 服务端 | 单体或少量服务 + 单库 | 用户、内容、消息、关系可先同库或分表 | +| API | REST + /v1/ 前缀 + Token 鉴权 | 暂可不做请求签名,用 HTTPS + Token | +| 存储 | 关系型 + 对象存储(图片/视频) | 消息可先存库,后续再拆消息库或缓存 | + +### 2.2 成长阶段 + +| 模块 | 建议 | 说明 | +|------|------|------| +| 客户端 | 双端 + 模块化 | 发布/广场/消息/个人等拆包或模块 | +| 功能 | 关注/粉丝、搜索、推送、基础推荐 | 对应 Soul 的关系与发现 | +| 服务端 | 服务拆分(用户、内容、消息、关系、推荐) | 便于扩展与团队分工 | +| API | 版本化(/v2/)+ 请求签名(关键接口) | 参考 Soul 的 api-sign 设计 | +| 存储 | 消息/会话可迁缓存或专用库;推荐用缓存或检索 | 提升性能与扩展性 | + +### 2.3 扩展阶段 + +| 模块 | 建议 | 说明 | +|------|------|------| +| 客户端 | Mac/Web、3D 或特色玩法 | 对应 Soul 多端与 3D 捏脸 | +| 功能 | 语音房/派对、小游戏、开放平台 | 对应 Soul 的 ChatRoom、小游戏、开放平台 | +| 服务端 | 开放平台域、实时音视频、推荐与算法 | 多域、实时能力、算法中台 | +| 安全与风控 | 设备指纹、限频、内容审核、反作弊 | 参考 03 | + +--- + +## 三、实现方式选型对照 + +以下为与 Soul 形态对照的**实现方式选型**,便于按团队与资源做决策。 + +| 能力 | Soul 形态(参考) | 自研可选方案 | 说明 | +|------|-------------------|--------------|------| +| 鉴权 | Token + device-id + 多 Header | Token + device-id 必选;签名可二期 | 先保证身份与设备,再上签名 | +| 请求签名 | api-sign(路径+参数+nonce+时间) | 自研算法或 HMAC 等 | 防篡改与重放,密钥与逻辑保密 | +| 内容存储 | 推断:关系型+文档/缓存+对象存储 | 关系型(用户/关系)+ 文档/MySQL/Redis(内容/消息)+ OSS/S3 | 按规模与成本选型 | +| 消息 | 私聊、会话列表、历史 | 自建 IM 或接入第三方 IM | 考虑合规与成本 | +| 实时语音房 | 语音房、上麦 | 接入声网/腾讯云等或自研 RTC | 成本与合规 | +| 推荐 | 首页推荐、匹配 | 规则引擎 → 简单模型 → 排序模型 | 冷启动可用规则+热度 | +| 推送 | 消息与系统通知 | 厂商通道 + 自建或第三方推送 | 到达率与合规 | +| 多端 | Android + iOS + Mac | 原生 / Flutter / RN / 小程序 | 根据团队与体验要求 | + +--- + +## 四、开发顺序建议(参考 Soul 形态) + +1. **基础**:登录(手机号/验证码)、用户与设备、Token 鉴权、基础 API 与存储。 +2. **内容**:发布(文字/图/视频)、信息流、点赞/评论、内容审核(规则或人工)。 +3. **关系**:关注/粉丝、个人主页、简单发现(列表或简单推荐)。 +4. **私聊**:会话列表、发消息、历史、推送。 +5. **增强**:请求签名、限频、设备风控、推荐与算法。 +6. **扩展**:语音房、小游戏、开放平台、多端(Mac/Web)等。 + +--- + +## 五、文档与调研使用顺序 + +- **产品与功能**:01 → 明确功能矩阵与优先级。 +- **技术架构**:02 → 确定 API、多域、客户端模块划分。 +- **安全与风控**:03 → 确定鉴权、签名、限频、合规。 +- **商业与运营**:04 → 确定变现与留存、增长策略。 +- **体验与交互**:05 → 确定导航、互动与心智设计。 +- **落地**:06(本文档)→ 技术基线、分阶段架构与选型、开发顺序。 + +按以上顺序阅读并对照 01~05,即可在「参考 Soul 形式」的前提下,形成自研 APP 的技术基线与实现方式方案。 diff --git a/开发文档/平台分析/Soul/调研/README.md b/开发文档/平台分析/Soul/调研/README.md new file mode 100644 index 0000000000..58cca08dc8 --- /dev/null +++ b/开发文档/平台分析/Soul/调研/README.md @@ -0,0 +1,34 @@ +# Soul 多维度调研文档 + +> 用途:从产品、技术、安全、商业、体验等维度对 Soul APP 做拆解分析,为**自研类似形态 APP** 提供技术基线与实现方式参考。 +> 创建日期:2026-02-28 + +--- + +## 文档列表 + +| 序号 | 文档 | 维度 | 说明 | +|:----:|------|------|------| +| 01 | [Soul产品与功能维度分析](01_Soul产品与功能维度分析.md) | 产品·功能 | 产品定位、功能矩阵、优势与劣势、可借鉴点 | +| 02 | [Soul技术架构与实现维度分析](02_Soul技术架构与实现维度分析.md) | 技术·架构 | 客户端/服务端/API/协议、技术栈、优缺点、实现参考 | +| 03 | [Soul安全与风控维度分析](03_Soul安全与风控维度分析.md) | 安全·风控 | 认证、签名、反爬、合规、优缺点 | +| 04 | [Soul商业与运营维度分析](04_Soul商业与运营维度分析.md) | 商业·运营 | 变现、留存、增长、优缺点 | +| 05 | [Soul体验与交互维度分析](05_Soul体验与交互维度分析.md) | 体验·交互 | 体验设计、交互形式、优缺点 | +| 06 | [参考Soul开发APP的技术基线与实现方式](06_参考Soul开发APP的技术基线与实现方式.md) | 开发参考 | 技术基线、架构建议、实现方式、选型对照 | + +--- + +## 使用方式 + +- **做产品/功能规划**:优先看 01、05。 +- **做技术选型与架构**:优先看 02、06。 +- **做安全与合规**:优先看 03。 +- **做商业化与运营**:优先看 04。 +- **整体参考 Soul 形式开发 APP**:按 01→02→03→04→05→06 顺序阅读,并以 06 作为落地基线。 + +--- + +## 约定 + +- 分析基于公开信息、抓包与既有项目分析,非官方披露,仅供参考。 +- 各文档内「好处/坏处」「优点/缺点」均按该维度拆解,便于对照与选型。