267 lines
9.1 KiB
Python
267 lines
9.1 KiB
Python
"""
|
||
设备统一传输层 — 机擎默认强制规则:WebSocket 无线主控优先。
|
||
|
||
所有设备控制(hook/execute、message、probe、Skill)须经本模块选路与下发。
|
||
禁止在业务路由中绕过本层直接调用 ADB / 主机 frida-ps / Mac ADB forward Agent。
|
||
|
||
优先级(WORKPHONE_WS_FIRST=1,默认):
|
||
1. WebSocket Agent + 设备本机 Frida(Hook)
|
||
2. ADB 静默(仅运维兜底或显式 u2 动作)
|
||
3. scrcpy / AI Agent 编排(依赖底层通道)
|
||
|
||
真源文档:开发文档/5、接口/02-业务对接/WebSocket无线主控口径.md
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from enum import Enum
|
||
from typing import Any, Dict, Optional
|
||
|
||
from fastapi import HTTPException
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class TransportChannel(str, Enum):
|
||
WEBSOCKET = "websocket"
|
||
HOOK = "hook"
|
||
ADB = "adb"
|
||
OFFLINE = "offline"
|
||
|
||
|
||
# 允许在无 WS 时降级 ADB 的微信动作(客服解封等,见 unified.WECHAT_U2_ONLY_ACTIONS)
|
||
# 登录/退出/注册/切号属纯 UI 流程:Frida RPC 仅广播 intent,设备无接收端时返回
|
||
# no_receiver_registered(真机铁律禁止假成功),必须放行 u2/ADB 真实 UI 兜底,否则功能不可用。
|
||
WECHAT_U2_ONLY_ACTIONS = frozenset({
|
||
"unblock_via_customer_service",
|
||
"unblock_account",
|
||
"unblock_self",
|
||
"unblock_appeal",
|
||
"unblock_with_sms",
|
||
"set_nickname",
|
||
"set_signature",
|
||
"set_avatar",
|
||
"set_sex",
|
||
"set_region",
|
||
"set_what_up",
|
||
"change_password",
|
||
"bind_phone",
|
||
"unbind_phone",
|
||
"get_login_devices",
|
||
"remove_login_device",
|
||
"enable_fingerprint",
|
||
"set_account_protection",
|
||
"login_by_password",
|
||
"login_by_sms",
|
||
"register_account",
|
||
"auto_register",
|
||
"logout",
|
||
"switch_account",
|
||
"ensure_logged_in",
|
||
"send_red_packet",
|
||
"receive_red_packet",
|
||
"send_transfer",
|
||
"receive_transfer",
|
||
"receive_payment",
|
||
"get_wallet_balance",
|
||
"get_transaction_history",
|
||
# UI/展示类素材(Frida 仅 intent 占位,须 u2 真导航+截图)
|
||
"generate_my_qr_code",
|
||
"show_my_qr",
|
||
"generate_group_qr_code",
|
||
"show_payment_code",
|
||
"scan_qr_code",
|
||
"add_friend_by_qr",
|
||
"voice_call",
|
||
"video_call",
|
||
"share_real_time_location",
|
||
"download_file",
|
||
"mass_send",
|
||
"batch_send",
|
||
"add_favorite",
|
||
"delete_favorite",
|
||
"follow_official_account",
|
||
"unfollow_official_account",
|
||
"open_mini_program",
|
||
"get_recent_mini_programs",
|
||
"share_mini_program",
|
||
"send_voice",
|
||
"send_file",
|
||
"send_location",
|
||
"send_card",
|
||
"send_emoji",
|
||
"add_custom_emoji",
|
||
"add_to_float",
|
||
"remove_from_float",
|
||
"set_privacy",
|
||
"set_notification",
|
||
"clear_chat_history",
|
||
"set_chat_background",
|
||
"set_do_not_disturb",
|
||
"pin_chat",
|
||
"clear_cache",
|
||
"check_for_update",
|
||
"delete_moments",
|
||
"browse_channels",
|
||
"like_channel_video",
|
||
"comment_channel_video",
|
||
"follow_channel",
|
||
"unfollow_channel",
|
||
"share_channel_video",
|
||
# forward_message / forward_multiple / revoke_message 已实现真 Frida RPC
|
||
# (文本转发复用 _sendMessageInternal / 撤回 DB 资格预检),走 Frida 主通道,不再强制 u2
|
||
"get_official_account_articles",
|
||
"reply_comment",
|
||
})
|
||
|
||
|
||
class DeviceTransport:
|
||
"""单设备传输策略与 WS 下发封装。"""
|
||
|
||
@staticmethod
|
||
def _settings():
|
||
from config import settings
|
||
return settings
|
||
|
||
def ws_first_enabled(self) -> bool:
|
||
return bool(getattr(self._settings(), "WORKPHONE_WS_FIRST", True))
|
||
|
||
def ws_hook_only(self, platform: str) -> bool:
|
||
if platform != "wechat":
|
||
return self.ws_first_enabled()
|
||
return bool(getattr(self._settings(), "WECHAT_WS_HOOK_ONLY", True))
|
||
|
||
def host_adb_probe_enabled(self) -> bool:
|
||
return bool(getattr(self._settings(), "WORKPHONE_HOST_ADB_PROBE", False))
|
||
|
||
def is_ws_online(self, device_id: str) -> bool:
|
||
from services.ws_hub import ws_hub
|
||
return ws_hub.is_online(device_id)
|
||
|
||
def resolve_mode(self, device_id: str, platform: str = "wechat", action: str = "") -> str:
|
||
"""
|
||
解析当前设备应使用的传输模式。
|
||
无线主控:WS 在线 → websocket;否则 offline(u2 专属动作除外)。
|
||
"""
|
||
if self.is_ws_online(device_id):
|
||
return TransportChannel.WEBSOCKET.value
|
||
|
||
if self.ws_first_enabled():
|
||
if platform == "wechat" and action in WECHAT_U2_ONLY_ACTIONS:
|
||
from services.adb_device import adb_manager
|
||
dev = adb_manager.get_device(device_id)
|
||
if dev and dev.is_online():
|
||
return TransportChannel.ADB.value
|
||
return TransportChannel.OFFLINE.value
|
||
|
||
from services.adb_device import adb_manager
|
||
adb_dev = adb_manager.get_device(device_id)
|
||
if adb_dev and adb_dev.is_online():
|
||
return TransportChannel.ADB.value
|
||
|
||
from services.connection_priority import connection_priority
|
||
ch = connection_priority.choose_execution_channel(device_id)
|
||
if ch == "agent":
|
||
return TransportChannel.WEBSOCKET.value
|
||
if ch and ch != "offline":
|
||
return ch
|
||
return TransportChannel.OFFLINE.value
|
||
|
||
def check_device_online(self, device_id: str, platform: str = "wechat", action: str = "") -> str:
|
||
"""按策略检查设备是否可执行业务;不可用时抛 HTTPException。"""
|
||
mode = self.resolve_mode(device_id, platform, action)
|
||
if mode == TransportChannel.OFFLINE.value:
|
||
detail = (
|
||
f"设备 WebSocket 未连接: {device_id}。"
|
||
"无线主控请在手机 Termux 启动 Agent 连接 ws://<sdk>/ws/device/{device_id}"
|
||
)
|
||
if self.ws_first_enabled():
|
||
raise HTTPException(status_code=503, detail=detail)
|
||
raise HTTPException(status_code=503, detail=f"设备不在线: {device_id}")
|
||
return "agent" if mode == TransportChannel.WEBSOCKET.value else mode
|
||
|
||
def offline_payload(self, device_id: str, reason: str = "") -> Dict[str, Any]:
|
||
msg = reason or (
|
||
"WebSocket Agent 未在线,请启动设备端 Termux Agent "
|
||
f"连接 ws://<sdk>/ws/device/{device_id}"
|
||
)
|
||
return {
|
||
"code": 503,
|
||
"success": False,
|
||
"message": msg,
|
||
"transport": TransportChannel.WEBSOCKET.value,
|
||
"_channel_used": "websocket/offline",
|
||
}
|
||
|
||
def policy_meta(self, device_id: str) -> Dict[str, Any]:
|
||
from services.device_id_util import device_id_md5
|
||
from services.ws_hub import ws_hub
|
||
|
||
info = ws_hub.get_device_info(device_id) or {}
|
||
return {
|
||
"device_id": device_id,
|
||
"device_id_md5": device_id_md5(device_id),
|
||
"transport": TransportChannel.WEBSOCKET.value if self.is_ws_online(device_id) else TransportChannel.OFFLINE.value,
|
||
"ws_first": self.ws_first_enabled(),
|
||
"ws_online": self.is_ws_online(device_id),
|
||
"frida_available": bool(info.get("frida_available")),
|
||
"agent_on_device": bool(info.get("on_device") or info.get("WP_AGENT_ON_DEVICE")),
|
||
}
|
||
|
||
async def execute_via_ws(
|
||
self,
|
||
device_id: str,
|
||
platform: str,
|
||
action: str,
|
||
params: dict,
|
||
*,
|
||
timeout: int = 120,
|
||
hook_only: bool = False,
|
||
) -> Dict[str, Any]:
|
||
"""经 WebSocket 统一下发 execute(唯一推荐业务入口)。"""
|
||
from services.ws_hub import ws_hub
|
||
|
||
# 微信内部动作优先走宝塔侧真实 Frida Session;Android/system/device
|
||
# 仍走手机 WebSocket,避免同一设备 ID 的两个 Agent 抢占连接。
|
||
if platform == "wechat":
|
||
from services.server_frida_bridge import server_frida_bridge
|
||
if server_frida_bridge.enabled_for(device_id):
|
||
return await server_frida_bridge.execute(device_id, action, params)
|
||
|
||
if not self.is_ws_online(device_id):
|
||
return self.offline_payload(device_id)
|
||
|
||
result = await ws_hub.send_command(
|
||
device_id,
|
||
{
|
||
"type": "execute",
|
||
"data": {
|
||
"script": platform,
|
||
"action": action,
|
||
"params": params,
|
||
"hook_only": hook_only,
|
||
},
|
||
},
|
||
timeout=timeout,
|
||
)
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else {}
|
||
agent_channel = (
|
||
result.get("channel")
|
||
or payload.get("channel")
|
||
or ("hook" if hook_only and payload.get("success") else "u2")
|
||
)
|
||
result["_channel_used"] = f"websocket/{agent_channel}"
|
||
result.setdefault("transport", TransportChannel.WEBSOCKET.value)
|
||
return result
|
||
|
||
def should_force_hook_only(self, platform: str, action: str, hook_only: bool) -> bool:
|
||
if hook_only:
|
||
return True
|
||
if platform == "wechat" and getattr(self._settings(), "WECHAT_BACKEND_ONLY", True):
|
||
return action not in WECHAT_U2_ONLY_ACTIONS
|
||
return False
|
||
|
||
|
||
device_transport = DeviceTransport()
|