3768 lines
150 KiB
Python
3768 lines
150 KiB
Python
"""
|
||
工作手机SDK v3.0 - 统一接口路由
|
||
存客宝重点对接的接口
|
||
|
||
功能模块:
|
||
1. 消息管理 - 发送/获取消息
|
||
2. 好友管理 - 添加/通过好友请求
|
||
3. 群聊管理 - 创建群/邀请入群/群发消息
|
||
4. 标签管理 - 添加/删除/查询标签
|
||
5. 朋友圈管理 - 发布/点赞/评论
|
||
6. 联系人管理 - 获取联系人列表
|
||
"""
|
||
|
||
from fastapi import APIRouter, HTTPException, Body
|
||
from typing import Optional, List
|
||
from pydantic import BaseModel, Field
|
||
from enum import Enum
|
||
import asyncio
|
||
import logging
|
||
import re
|
||
import time
|
||
|
||
from services.ws_hub import ws_hub
|
||
from services.device_manager import device_manager
|
||
from services.adb_device import adb_manager
|
||
from services import ai_agent as ai_agent_service
|
||
from services.rate_limiter import rate_limiter, DailyLimitExceeded
|
||
from services.content_guard import diversify_content, filter_sensitive, has_sensitive_words
|
||
from services.account_lifecycle import account_lifecycle
|
||
from config import settings
|
||
from services.device_id_util import device_id_md5
|
||
|
||
router = APIRouter()
|
||
logger = logging.getLogger(__name__)
|
||
|
||
DEFAULT_CONTACT_PULL_LIMIT = 10000
|
||
MAX_CONTACT_PULL_LIMIT = 20000
|
||
DEFAULT_MESSAGE_PULL_LIMIT = 3000
|
||
MAX_MESSAGE_PULL_LIMIT = 5000
|
||
|
||
|
||
# ========== 枚举定义 ==========
|
||
|
||
class Platform(str, Enum):
|
||
"""支持的平台"""
|
||
WECHAT = "wechat"
|
||
DOUYIN = "douyin"
|
||
XHS = "xhs"
|
||
XIANYU = "xianyu"
|
||
SOUL = "soul"
|
||
|
||
|
||
class MessageType(str, Enum):
|
||
"""消息类型"""
|
||
TEXT = "text"
|
||
IMAGE = "image"
|
||
VIDEO = "video"
|
||
VOICE = "voice"
|
||
FILE = "file"
|
||
LINK = "link"
|
||
MINI_PROGRAM = "mini_program"
|
||
|
||
|
||
class Channel(str, Enum):
|
||
"""执行通道"""
|
||
OFFICIAL_API = "official_api"
|
||
SDK_CONTROL = "sdk_control"
|
||
AI_AGENT = "ai_agent"
|
||
HOOK = "hook"
|
||
|
||
|
||
# ========== 消息相关模型 ==========
|
||
|
||
class SendMessageRequest(BaseModel):
|
||
"""发送消息请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
to_id: str
|
||
content: str
|
||
msg_type: MessageType = MessageType.TEXT
|
||
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):
|
||
"""发送消息响应"""
|
||
success: bool
|
||
message_id: Optional[str] = None
|
||
channel_used: Channel
|
||
error: Optional[str] = None
|
||
|
||
|
||
class GetMessagesRequest(BaseModel):
|
||
"""获取消息请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
conversation_id: Optional[str] = None
|
||
limit: int = DEFAULT_MESSAGE_PULL_LIMIT
|
||
offset: int = 0
|
||
since_time: Optional[int] = None
|
||
|
||
|
||
class Message(BaseModel):
|
||
"""消息"""
|
||
message_id: str
|
||
from_id: str
|
||
to_id: str
|
||
content: str
|
||
msg_type: MessageType
|
||
timestamp: int
|
||
is_self: bool
|
||
|
||
|
||
# ========== 好友相关模型 ==========
|
||
|
||
class AddFriendRequest(BaseModel):
|
||
"""添加好友请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
user_id: str
|
||
message: str = ""
|
||
source: Optional[str] = None # 来源说明
|
||
|
||
|
||
class AcceptFriendRequest(BaseModel):
|
||
"""通过好友请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
user_id: str
|
||
|
||
|
||
class SetRemarkRequest(BaseModel):
|
||
"""设置备注请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
user_id: str
|
||
remark: str
|
||
|
||
|
||
class DeleteFriendRequest(BaseModel):
|
||
"""删除好友请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
user_id: str
|
||
|
||
|
||
# ========== 群聊相关模型 ==========
|
||
|
||
class CreateGroupRequest(BaseModel):
|
||
"""创建群聊请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
group_name: str
|
||
member_ids: List[str] # 群成员ID列表
|
||
|
||
|
||
class InviteToGroupRequest(BaseModel):
|
||
"""邀请入群请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
group_id: str # 群ID或群名
|
||
member_ids: List[str] # 邀请的成员ID
|
||
|
||
|
||
class RemoveFromGroupRequest(BaseModel):
|
||
"""移出群聊请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
group_id: str
|
||
member_ids: List[str]
|
||
|
||
|
||
class SetGroupNoticeRequest(BaseModel):
|
||
"""设置群公告请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
group_id: str
|
||
notice: str
|
||
|
||
|
||
class SetGroupNameRequest(BaseModel):
|
||
"""设置群名请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
group_id: str
|
||
group_name: str
|
||
|
||
|
||
class GroupMessageRequest(BaseModel):
|
||
"""群发消息请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
group_id: str
|
||
content: str
|
||
msg_type: MessageType = MessageType.TEXT
|
||
media_url: Optional[str] = None
|
||
at_all: bool = False # 是否@所有人
|
||
at_list: Optional[List[str]] = None
|
||
|
||
|
||
class SetGroupWelcomeRequest(BaseModel):
|
||
"""设置群欢迎语请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
group_id: str
|
||
welcome_text: str
|
||
welcome_image: Optional[str] = None
|
||
|
||
|
||
# ========== 标签相关模型 ==========
|
||
|
||
class AddTagRequest(BaseModel):
|
||
"""添加标签请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
user_id: str
|
||
tags: List[str]
|
||
|
||
|
||
class RemoveTagRequest(BaseModel):
|
||
"""移除标签请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
user_id: str
|
||
tags: List[str]
|
||
|
||
|
||
class CreateTagRequest(BaseModel):
|
||
"""创建标签请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
tag_name: str
|
||
|
||
|
||
class DeleteTagRequest(BaseModel):
|
||
"""删除标签请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
tag_name: str
|
||
|
||
|
||
class GetUsersByTagRequest(BaseModel):
|
||
"""根据标签获取用户请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
tag_name: str
|
||
limit: int = 100
|
||
|
||
|
||
# ========== 朋友圈相关模型 ==========
|
||
|
||
class PostMomentsRequest(BaseModel):
|
||
"""发布朋友圈请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
content: str
|
||
images: Optional[List[str]] = None # 图片URL列表
|
||
video_url: Optional[str] = None
|
||
location: Optional[str] = None # 位置
|
||
visible_list: Optional[List[str]] = None # 可见名单
|
||
invisible_list: Optional[List[str]] = None # 不可见名单
|
||
|
||
|
||
class LikeMomentsRequest(BaseModel):
|
||
"""点赞朋友圈请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
user_id: str # 朋友圈所属用户
|
||
post_index: int = 0 # 朋友圈索引(第几条)
|
||
|
||
|
||
class CommentMomentsRequest(BaseModel):
|
||
"""评论朋友圈请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
user_id: str
|
||
post_index: int = 0
|
||
comment: str
|
||
reply_to: Optional[str] = None # 回复某人
|
||
|
||
|
||
class GetMomentsRequest(BaseModel):
|
||
"""获取朋友圈请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
user_id: Optional[str] = None # 不指定则获取自己的
|
||
limit: int = 10
|
||
|
||
|
||
class ReplyCommentRequest(BaseModel):
|
||
"""回复评论请求(抖音/小红书等)"""
|
||
device_id: str
|
||
platform: Platform
|
||
video_id: Optional[str] = None
|
||
comment_id: str
|
||
content: str
|
||
|
||
|
||
# ========== 批量操作模型 ==========
|
||
|
||
class BatchSendMessageRequest(BaseModel):
|
||
"""批量发送消息请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
to_ids: List[str]
|
||
content: str
|
||
msg_type: MessageType = MessageType.TEXT
|
||
media_url: Optional[str] = None
|
||
interval: float = 2.0 # 发送间隔(秒)
|
||
|
||
|
||
class BatchAddFriendRequest(BaseModel):
|
||
"""批量添加好友请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
user_ids: List[str]
|
||
message: str = ""
|
||
interval: float = 5.0 # 添加间隔(秒)
|
||
|
||
|
||
class MessageSyncSinceRequest(BaseModel):
|
||
"""增量同步消息请求"""
|
||
device_id: str
|
||
platform: Platform = Platform.WECHAT
|
||
conversation_id: Optional[str] = None
|
||
since_time: int = 0
|
||
limit: int = DEFAULT_MESSAGE_PULL_LIMIT
|
||
offset: int = 0
|
||
|
||
|
||
# ========== 通道路由器 ==========
|
||
|
||
class ChannelRouter:
|
||
"""通道路由器 - 选择最优执行通道"""
|
||
|
||
# 官方API能力矩阵
|
||
OFFICIAL_API_SUPPORT = {
|
||
Platform.DOUYIN: ["send_message", "get_messages", "get_fans", "reply_comment"],
|
||
Platform.XIANYU: ["send_message", "get_messages"],
|
||
}
|
||
|
||
@staticmethod
|
||
def route(platform: Platform, action: str, device_online: bool) -> Channel:
|
||
"""选择执行通道"""
|
||
# 1. 检查官方API
|
||
if platform in ChannelRouter.OFFICIAL_API_SUPPORT:
|
||
if action in ChannelRouter.OFFICIAL_API_SUPPORT[platform]:
|
||
return Channel.OFFICIAL_API
|
||
|
||
# 2. 微信后端强制 Frida Hook 主控(矩阵 v8.0.56 真机验收口径)
|
||
if platform == Platform.WECHAT and getattr(settings, "WECHAT_BACKEND_ONLY", True):
|
||
if device_online:
|
||
return Channel.HOOK
|
||
|
||
# 3. 设备在线用SDK控制
|
||
if device_online:
|
||
return Channel.SDK_CONTROL
|
||
|
||
# 4. 兜底用AI Agent
|
||
return Channel.AI_AGENT
|
||
|
||
|
||
# ========== 辅助函数 ==========
|
||
|
||
def _check_device_online(device_id: str, platform: str = "wechat", action: str = ""):
|
||
"""检查设备是否在线(经 device_transport 统一策略,无线主控 WS 优先)。"""
|
||
from services.device_transport import device_transport
|
||
return device_transport.check_device_online(device_id, platform, action)
|
||
|
||
|
||
def _get_device_mode(device_id: str, platform: str = "wechat", action: str = "") -> str:
|
||
"""获取设备传输模式(经 device_transport 封装,禁止业务层自行判 ADB)。"""
|
||
from services.device_transport import device_transport
|
||
return device_transport.resolve_mode(device_id, platform, action)
|
||
|
||
|
||
def _payload_of(result: dict) -> dict:
|
||
"""兼容 {data:{...}} 与直接 payload 返回。"""
|
||
if not isinstance(result, dict):
|
||
return {}
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else result
|
||
return payload if isinstance(payload, dict) else {}
|
||
|
||
|
||
def _list_from_payload(payload: dict, *keys: str) -> list:
|
||
"""从不同 Hook/Agent 返回格式里抽取列表。"""
|
||
if not isinstance(payload, dict):
|
||
return []
|
||
for key in keys:
|
||
value = payload.get(key)
|
||
if isinstance(value, list):
|
||
return value
|
||
if isinstance(value, dict):
|
||
for nested in ("items", "list", "data"):
|
||
nested_value = value.get(nested)
|
||
if isinstance(nested_value, list):
|
||
return nested_value
|
||
data = payload.get("data")
|
||
if isinstance(data, dict):
|
||
return _list_from_payload(data, *keys)
|
||
return []
|
||
|
||
|
||
def _message_timestamp(message: dict) -> int:
|
||
"""把常见消息时间字段归一为秒级 int,无法识别时返回 0。"""
|
||
if not isinstance(message, dict):
|
||
return 0
|
||
for key in ("timestamp", "create_time", "createTime", "time", "msg_time", "msgTime"):
|
||
raw = message.get(key)
|
||
if raw is None:
|
||
continue
|
||
try:
|
||
ts = int(float(raw))
|
||
return ts // 1000 if ts > 10_000_000_000 else ts
|
||
except (TypeError, ValueError):
|
||
continue
|
||
return 0
|
||
|
||
|
||
def _matches_contact(contact: dict, keyword: str) -> bool:
|
||
if not keyword:
|
||
return True
|
||
text = " ".join(str(contact.get(k, "")) for k in (
|
||
"nickname", "display_name", "remark", "alias", "wechat_id", "wxid", "user_id", "username"
|
||
))
|
||
return keyword.lower() in text.lower()
|
||
|
||
|
||
def _bounded_limit(value, default: int, maximum: int) -> int:
|
||
try:
|
||
num = int(value)
|
||
except (TypeError, ValueError):
|
||
num = default
|
||
return min(max(num, 1), maximum)
|
||
|
||
|
||
def _bounded_offset(value) -> int:
|
||
try:
|
||
num = int(value)
|
||
except (TypeError, ValueError):
|
||
num = 0
|
||
return max(num, 0)
|
||
|
||
|
||
def _parse_messages_from_xml(xml: str, limit: int) -> list:
|
||
"""从 UI 树 XML 解析 text 节点作为消息列表(ADB 模式用)"""
|
||
out = []
|
||
if not xml:
|
||
return out
|
||
skip = {"搜索", "发送", "输入", "消息", "私信", "通讯录"}
|
||
for i, m in enumerate(re.finditer(r'\btext="([^"]{1,200})"', xml)):
|
||
if i >= limit:
|
||
break
|
||
text = m.group(1).strip()
|
||
if text and text not in skip:
|
||
out.append({
|
||
"message_id": f"adb_ui_{i}",
|
||
"content": text,
|
||
"from_id": "",
|
||
"to_id": "",
|
||
"timestamp": 0,
|
||
"is_self": False,
|
||
})
|
||
return out
|
||
|
||
|
||
def _parse_contacts_from_xml(xml: str, limit: int) -> list:
|
||
"""从 UI 树 XML 解析 text 节点作为联系人列表(ADB 模式用)"""
|
||
out = []
|
||
if not xml:
|
||
return out
|
||
skip = {"通讯录", "搜索", "添加朋友", "新的朋友", "群聊", "标签", "公众号", "微信"}
|
||
for i, m in enumerate(re.finditer(r'\btext="([^"]{1,100})"', xml)):
|
||
if i >= limit:
|
||
break
|
||
text = m.group(1).strip()
|
||
if text and text not in skip:
|
||
out.append({"user_id": f"adb_contact_{i}", "nickname": text, "remark": ""})
|
||
return out
|
||
|
||
|
||
async def _anti_ban_guard(device_id: str, platform: str, action: str, content: Optional[str] = None) -> dict:
|
||
"""
|
||
防封守卫:在执行任何操作前进行防封检查。
|
||
返回 {"pass": True/False, "reason": str, "content": str(处理后的内容)}
|
||
检查链:时段 → 生命周期 → L1全局 → L2设备 → L3动作 → 敏感词 → 内容差异化
|
||
"""
|
||
import os
|
||
if os.environ.get("SDK_MATRIX_VERIFY") == "1":
|
||
return {"pass": True, "reason": "", "content": content}
|
||
|
||
from services.anti_ban_alert import (
|
||
alert_daily_limit, alert_outside_hours, alert_sensitive_content,
|
||
)
|
||
from services.rate_limiter import (
|
||
OutsideOperationHours, LongPauseRequired, HighRiskComboBlocked,
|
||
SilentThrottleCooldown, classify_risk,
|
||
)
|
||
|
||
def _blocked(reason: str) -> dict:
|
||
# AB-06:附带风险分级 risk_action(block/needs_human/log)
|
||
return {"pass": False, "reason": reason, "content": content,
|
||
"risk_action": classify_risk(reason)}
|
||
|
||
try:
|
||
lifecycle_check = await account_lifecycle.check_allowed(device_id, platform, action)
|
||
if not lifecycle_check["allowed"]:
|
||
return _blocked(lifecycle_check["reason"])
|
||
except Exception as e:
|
||
logger.warning(f"[anti_ban] 生命周期检查异常(跳过): {e}")
|
||
|
||
try:
|
||
is_new = await account_lifecycle.is_new_account(device_id, platform)
|
||
wait = await rate_limiter.check_and_wait(device_id, platform, action, is_new_account=is_new)
|
||
if wait > 0:
|
||
logger.info(f"[anti_ban] {device_id}/{platform}.{action} 等待了 {wait:.1f}s")
|
||
except OutsideOperationHours as e:
|
||
asyncio.ensure_future(alert_outside_hours(device_id, action))
|
||
return _blocked(str(e))
|
||
except DailyLimitExceeded as e:
|
||
asyncio.ensure_future(alert_daily_limit(device_id, platform, action, -1, -1))
|
||
return _blocked(str(e))
|
||
except SilentThrottleCooldown as e:
|
||
# AB-02:静默限流熔断期,写类动作 fast-fail(risk=needs_human)
|
||
return _blocked(str(e))
|
||
except (LongPauseRequired, HighRiskComboBlocked) as e:
|
||
# AB-03:长会话中断 / 高危组合互斥,fast-fail 不长 sleep
|
||
return _blocked(str(e))
|
||
except Exception as e:
|
||
logger.warning(f"[anti_ban] 限流检查异常(跳过): {e}")
|
||
|
||
safe_content = content
|
||
if content:
|
||
sensitive = has_sensitive_words(content)
|
||
if sensitive:
|
||
logger.warning(f"[anti_ban] 检测到敏感词: {sensitive}")
|
||
asyncio.ensure_future(alert_sensitive_content(device_id, platform, sensitive))
|
||
safe_content = diversify_content(content, add_invisible=True, add_emoji=False)
|
||
|
||
return {"pass": True, "reason": "", "content": safe_content}
|
||
|
||
|
||
# 必须走 u2 UI 自动化的微信动作 — 真源:services/device_transport.py
|
||
|
||
|
||
async def _execute_skill(
|
||
device_id: str,
|
||
platform: str,
|
||
action: str,
|
||
params: dict,
|
||
timeout: int = 120,
|
||
hook_only: bool = False,
|
||
) -> dict:
|
||
"""执行技能并返回结果(Frida Hook 优先 / WebSocket / ADB),返回值附带 _channel_used。
|
||
|
||
通道优先级(与项目“主控 = Frida,辅助 = ADB”的设计一致):
|
||
1) hook_only=True 或本地能走 Frida → 本地 FridaManager + HookExecutor
|
||
2) WebSocket Agent 在线 → 下发 execute
|
||
3) ADB 在线 → ADB UI 自动化
|
||
4) 其他 → 设备离线
|
||
"""
|
||
from services.device_transport import device_transport
|
||
|
||
mode = _get_device_mode(device_id, platform, action)
|
||
ws_hook_only = device_transport.ws_hook_only(platform)
|
||
hook_only = device_transport.should_force_hook_only(platform, action, hook_only)
|
||
|
||
# 〇-A、WebSocket + Frida 主控(经 device_transport 统一封装,禁止绕过 WS 直连 ADB)
|
||
if ws_hook_only and mode != "adb":
|
||
if not device_transport.is_ws_online(device_id):
|
||
return device_transport.offline_payload(device_id)
|
||
mode = "websocket"
|
||
|
||
# 〇-B、Frida Hook 本地 ADB attach(仅 WECHAT_WS_HOOK_ONLY=false 时)
|
||
if platform == "wechat" and mode == "adb" and not ws_hook_only:
|
||
try:
|
||
adb_device = adb_manager.get_device(device_id)
|
||
if adb_device and adb_device.is_online():
|
||
serial = getattr(adb_device, "serial", device_id)
|
||
hook_result = await asyncio.get_running_loop().run_in_executor(
|
||
None,
|
||
lambda: _try_local_frida_action(serial, action, params),
|
||
)
|
||
if hook_result and hook_result.get("success"):
|
||
hook_result["_channel_used"] = "frida/hook"
|
||
return hook_result
|
||
if hook_only:
|
||
return {
|
||
"code": 503,
|
||
"success": False,
|
||
"message": (hook_result or {}).get("error") or "Frida 主控不可用",
|
||
"_channel_used": "frida/hook(failed)",
|
||
}
|
||
logger.warning(f"[hook] 本地 Frida 失败,降级到 {mode}: {hook_result}")
|
||
elif hook_only:
|
||
return {
|
||
"code": 503,
|
||
"success": False,
|
||
"message": "微信后端模式要求 ADB 在线 + Frida 可用;当前未检测到可用 ADB 设备",
|
||
"_channel_used": "none",
|
||
}
|
||
except Exception as e:
|
||
logger.warning(f"[hook] 本地 Frida 异常:{e}")
|
||
if hook_only:
|
||
return {"code": 503, "success": False, "message": str(e), "_channel_used": "frida/hook(failed)"}
|
||
|
||
if mode == "websocket":
|
||
return await device_transport.execute_via_ws(
|
||
device_id, platform, action, params, timeout=timeout, hook_only=hook_only,
|
||
)
|
||
|
||
elif mode == "adb":
|
||
if hook_only:
|
||
return {
|
||
"code": 503,
|
||
"message": "hook_only 需 Frida Hook 主控可用(已尝试本地 Frida 仍失败)",
|
||
"success": False,
|
||
"_channel_used": "none",
|
||
}
|
||
adb_device = adb_manager.get_device(device_id)
|
||
if not adb_device:
|
||
return {"success": False, "error": "ADB设备不可用", "_channel_used": "none"}
|
||
|
||
adb_result = await _execute_via_adb(adb_device, platform, action, params)
|
||
adb_result["_channel_used"] = "adb/sdk_control"
|
||
return adb_result
|
||
|
||
else:
|
||
return {"success": False, "error": "设备离线", "_channel_used": "offline"}
|
||
|
||
|
||
async def _execute_via_adb(device, platform: str, action: str, params: dict, timeout: int = 30) -> dict:
|
||
"""通过ADB执行技能操作 — 微信走专用引擎,其他平台走通用 UI 自动化。超时返回 error 而非无限挂起。"""
|
||
try:
|
||
if platform == "wechat":
|
||
from services.wechat_adb_engine import WeChatADBEngine
|
||
engine = WeChatADBEngine(device)
|
||
loop = asyncio.get_running_loop()
|
||
adb_timeout = 90 if action == "send_message" else timeout
|
||
result = await asyncio.wait_for(
|
||
loop.run_in_executor(None, lambda: engine.execute(action, params)),
|
||
timeout=adb_timeout
|
||
)
|
||
return result
|
||
|
||
app_packages = {
|
||
"douyin": "com.ss.android.ugc.aweme",
|
||
"xhs": "com.xingin.xhs",
|
||
"xianyu": "com.taobao.idlefish",
|
||
"soul": "cn.soulapp.android",
|
||
}
|
||
package = app_packages.get(platform)
|
||
|
||
if action == "screenshot":
|
||
return device.screenshot()
|
||
|
||
if action == "send_message":
|
||
import time as _time
|
||
to_id = params.get("to_id", "")
|
||
content = params.get("content", "")
|
||
if package:
|
||
device.start_app(package)
|
||
_time.sleep(2)
|
||
device.click_text("搜索")
|
||
_time.sleep(0.5)
|
||
device.input_text(to_id)
|
||
_time.sleep(1)
|
||
device.click_text(to_id)
|
||
_time.sleep(1)
|
||
device.input_text(content, clear=False)
|
||
_time.sleep(0.3)
|
||
device.click_text("发送")
|
||
return {"success": True, "message_id": f"adb_{int(_time.time()*1000)}", "mode": "adb"}
|
||
|
||
logger.info(f"ADB通用执行: {platform}.{action} params={params}")
|
||
return {"success": True, "action": action, "params": params, "mode": "adb",
|
||
"note": "通过ADB UI自动化执行"}
|
||
except asyncio.TimeoutError:
|
||
logger.error(f"ADB执行超时: {platform}.{action} timeout={timeout}s")
|
||
return {"success": False, "error": f"操作超时({timeout}s)", "mode": "adb"}
|
||
except Exception as e:
|
||
logger.error(f"ADB执行失败: {e}")
|
||
return {"success": False, "error": str(e), "mode": "adb"}
|
||
|
||
|
||
# =============================================================================
|
||
# 〇、Hawk Hook 统一执行入口(丝滑控制)
|
||
# =============================================================================
|
||
|
||
class HookExecuteRequest(BaseModel):
|
||
"""Hawk Hook 格式 — 单端点执行任意操作,任何应用可直连"""
|
||
device_id: str
|
||
platform: Platform = Platform.WECHAT
|
||
action: str = Field(..., description="操作名:send_message / get_contacts / get_profile / ... 共 107 个")
|
||
params: dict = Field(default_factory=dict, description="action 所需参数(键值对)")
|
||
hook_only: bool = Field(
|
||
default=False,
|
||
description="为 True 时仅走 Frida Hook,失败不降级 u2/ADB(纯 HWK 联调)",
|
||
)
|
||
|
||
|
||
HOOK_ALL_ACTIONS = {
|
||
"消息发送": ["send_message", "send_group_message"],
|
||
"消息获取": ["get_messages", "get_recent_messages", "search_messages"],
|
||
"联系人": ["get_contacts", "get_contact_info", "search_contacts"],
|
||
"好友管理": ["add_friend", "accept_friend", "delete_friend", "set_friend_remark", "get_friend_requests"],
|
||
"群管理": ["get_groups", "get_group_info", "get_group_members", "create_group", "invite_to_group", "remove_from_group", "set_group_announcement", "set_group_name", "quit_group"],
|
||
"朋友圈": ["post_moments", "get_moments", "like_moments", "comment_moments", "delete_moments"],
|
||
"账号管理": ["get_profile", "check_account_status", "set_nickname", "set_signature", "set_avatar", "set_sex", "set_region", "set_whats_up"],
|
||
"账号安全": ["unblock_self", "change_password", "bind_phone", "unbind_phone", "get_login_devices", "remove_login_device", "enable_fingerprint", "set_account_protection"],
|
||
"支付": ["send_red_packet", "receive_red_packet", "send_transfer", "receive_transfer", "get_wallet_balance", "get_transaction_history"],
|
||
"二维码": ["scan_qr_code", "generate_my_qr_code", "generate_group_qr_code", "add_friend_by_qr"],
|
||
"视频号": ["browse_channels", "like_channel_video", "comment_channel_video", "follow_channel", "unfollow_channel", "share_channel_video"],
|
||
"标签": ["get_labels", "create_label", "delete_label", "set_contact_label", "get_contacts_by_label"],
|
||
"收藏": ["get_favorites", "add_favorite", "delete_favorite"],
|
||
"设置": ["set_privacy", "set_notification", "clear_chat_history", "set_chat_background", "set_do_not_disturb", "pin_chat"],
|
||
"搜索": ["global_search"],
|
||
"小程序": ["open_mini_program", "get_recent_mini_programs", "share_mini_program"],
|
||
"文件传输": ["send_image", "send_video", "send_file", "send_voice", "send_location", "send_card", "send_link"],
|
||
"消息转发": ["forward_message", "forward_multiple", "revoke_message"],
|
||
"注册/登录": ["register_account", "login_by_password", "login_by_sms", "logout", "switch_account", "auto_register", "check_login_state", "get_sim_phone"],
|
||
"公众号": ["get_official_accounts", "follow_official_account", "unfollow_official_account", "get_official_account_articles"],
|
||
"表情": ["send_emoji", "add_custom_emoji"],
|
||
"浮窗": ["add_to_float", "remove_from_float"],
|
||
"设备信息": ["get_device_info", "get_storage_info", "get_network_info"],
|
||
"系统": ["get_hook_status", "get_process_info", "get_wechat_version", "batch_execute"],
|
||
}
|
||
|
||
|
||
@router.get("/antiban/status", response_model=dict, tags=["防封风控"])
|
||
async def antiban_status(device_id: Optional[str] = None):
|
||
"""AB-05 防封看板数据源:聚合限流配置 + 操作时段 + 设备 guard 红灯 / RiskSentinel。
|
||
|
||
- 无 device_id:返回全局限流策略与当前操作时段。
|
||
- 带 device_id:附加该设备 register/心跳上报的 anti_ban(device_guard.redlight、RiskSentinel)。
|
||
"""
|
||
from services.rate_limiter import PLATFORM_LIMITS, OPERATION_HOURS
|
||
|
||
data = {
|
||
"operation_hours": {
|
||
"range": list(OPERATION_HOURS),
|
||
"in_window": rate_limiter.check_operation_hours(),
|
||
"tz": "Asia/Shanghai",
|
||
},
|
||
"platform_limits": PLATFORM_LIMITS,
|
||
"long_pause": {"every_actions": "8-12", "pause_seconds": "600-1800"},
|
||
"high_risk_exclusive": [
|
||
"add_friend", "batch_add_friend", "batch_send",
|
||
"mass_send", "create_group", "invite_to_group",
|
||
],
|
||
}
|
||
if device_id:
|
||
info = ws_hub.get_device_info(device_id) or {}
|
||
anti = info.get("anti_ban") or {}
|
||
guard = anti.get("guard") or {}
|
||
data["device"] = {
|
||
"device_id": device_id,
|
||
"online": ws_hub.is_online(device_id),
|
||
"redlight": guard.get("redlight"),
|
||
"guard_warnings": guard.get("warnings"),
|
||
"sentinel": anti.get("sentinel"),
|
||
"nurture": anti.get("nurture"),
|
||
# AB-02/03 运行态:熔断 / 长暂停 / 动作节拍 / 最近高危动作
|
||
"runtime": rate_limiter.get_runtime_state(device_id),
|
||
}
|
||
return {"code": 200, "data": data}
|
||
|
||
|
||
@router.get("/hook/actions", response_model=dict, tags=["Hook模块管理"])
|
||
async def hook_actions():
|
||
"""获取 Hook 支持的全部操作清单 — 107 个操作 / 24 个模块"""
|
||
total = sum(len(v) for v in HOOK_ALL_ACTIONS.values())
|
||
return {
|
||
"code": 200,
|
||
"total_actions": total,
|
||
"total_modules": len(HOOK_ALL_ACTIONS),
|
||
"modules": HOOK_ALL_ACTIONS,
|
||
}
|
||
|
||
|
||
@router.get("/hook/probe/{device_id}", response_model=dict, tags=["Hook模块管理"])
|
||
async def hook_probe(device_id: str):
|
||
"""
|
||
探测设备 Frida Hook 能力 — 连接 → ping → 版本 → 更新 device_modules
|
||
|
||
无线主控(WORKPHONE_WS_FIRST):Agent WebSocket 在线时经 WS 探测,不依赖主机 ADB。
|
||
"""
|
||
import asyncio
|
||
|
||
ws_first = getattr(settings, "WORKPHONE_WS_FIRST", True)
|
||
ws_hook_only = getattr(settings, "WECHAT_WS_HOOK_ONLY", True)
|
||
host_adb_probe = getattr(settings, "WORKPHONE_HOST_ADB_PROBE", False)
|
||
|
||
if (ws_first or ws_hook_only) and ws_hub.is_online(device_id):
|
||
result = await _probe_device_via_ws(device_id)
|
||
elif host_adb_probe:
|
||
adb_dev = adb_manager.get_device(device_id)
|
||
serial = adb_dev.serial if adb_dev else device_id
|
||
result = await asyncio.get_running_loop().run_in_executor(
|
||
None, lambda: _probe_device_frida(serial),
|
||
)
|
||
else:
|
||
result = {
|
||
"supports_hook": False,
|
||
"frida_version": "",
|
||
"root_status": False,
|
||
"wechat_version": "",
|
||
"profile": {},
|
||
"hook_tests": {"connect": "websocket-only: agent offline or frida not ready"},
|
||
"transport": "websocket",
|
||
"probe_detail": "无线主控:需设备端 Termux Agent + 本机 Frida,无需主机 ADB",
|
||
}
|
||
|
||
from services.hook_module_service import hook_module_service
|
||
await hook_module_service.update_device_probe(device_id, {
|
||
"supports_hook": result.get("supports_hook", False),
|
||
"frida_version": result.get("frida_version", ""),
|
||
"root_status": result.get("root_status", False),
|
||
})
|
||
|
||
return {"code": 200, "device_id": device_id, "device_id_md5": device_id_md5(device_id), **result}
|
||
|
||
|
||
async def _probe_device_via_ws(device_id: str) -> dict:
|
||
"""经 WebSocket Agent 探测 Frida(无线主控,无需主机 ADB)。"""
|
||
info = ws_hub.get_device_info(device_id) or {}
|
||
capabilities = set(info.get("capabilities") or [])
|
||
reported_ready = bool(info.get("frida_available")) or bool(
|
||
{"frida", "hook", "frida_rpc"} & capabilities
|
||
)
|
||
probe = {
|
||
"supports_hook": reported_ready,
|
||
"frida_version": "",
|
||
"root_status": bool(info.get("root_status")),
|
||
"wechat_version": "",
|
||
"profile": {},
|
||
"hook_tests": {},
|
||
"transport": "websocket",
|
||
"frida_available_reported": reported_ready,
|
||
}
|
||
|
||
ping = await _execute_skill(device_id, "wechat", "ping", {}, timeout=45, hook_only=True)
|
||
probe["hook_tests"]["ping"] = str(ping)[:300]
|
||
pdata = ping.get("data") if isinstance(ping.get("data"), dict) else {}
|
||
frida_ok = (
|
||
reported_ready
|
||
or bool(pdata.get("frida_connected"))
|
||
or bool(ping.get("success"))
|
||
or "pong" in str(ping).lower()
|
||
)
|
||
probe["supports_hook"] = frida_ok
|
||
probe["hook_tests"]["connect"] = "ok" if frida_ok else "failed"
|
||
|
||
if frida_ok:
|
||
# 性能优化:version 与 profile 为独立只读调用,并行执行(墙钟取最大值而非求和),
|
||
# 显著缩短 probe 总耗时(原串行 ping+ver+profile ~38s)。
|
||
ver, prof = await asyncio.gather(
|
||
_execute_skill(device_id, "wechat", "get_wechat_version", {}, timeout=30, hook_only=True),
|
||
_execute_skill(device_id, "wechat", "get_profile", {}, timeout=45, hook_only=True),
|
||
)
|
||
vdata = ver.get("data") if isinstance(ver.get("data"), dict) else {}
|
||
probe["wechat_version"] = str(vdata.get("version") or vdata.get("wechat_version") or ver.get("wechat_version") or "")
|
||
prdata = prof.get("data") if isinstance(prof.get("data"), dict) else {}
|
||
if isinstance(prdata.get("data"), dict):
|
||
probe["profile"] = prdata["data"]
|
||
elif prdata:
|
||
probe["profile"] = prdata
|
||
|
||
if not probe["supports_hook"]:
|
||
probe["probe_detail"] = (
|
||
"Agent WS 在线但 Frida 未 attach;请在手机 Termux 运行 Agent(127.0.0.1 frida-server),无需 Mac ADB"
|
||
)
|
||
return probe
|
||
|
||
|
||
@router.get("/stability/watch", response_model=dict, tags=["设备稳定性"])
|
||
async def stability_watch(device_id: str, samples: int = 1, interval_seconds: float = 0):
|
||
"""
|
||
设备稳定性采样入口:WS/Hook/ADB/耗时统一返回,供 24h 长稳脚本循环调用。
|
||
"""
|
||
samples = min(max(int(samples or 1), 1), 120)
|
||
interval_seconds = min(max(float(interval_seconds or 0), 0), 60)
|
||
rows = []
|
||
for index in range(samples):
|
||
started = time.time()
|
||
online = ws_hub.is_online(device_id)
|
||
info = ws_hub.get_device_info(device_id) or {}
|
||
adb_dev = adb_manager.get_device(device_id)
|
||
try:
|
||
probe = await _probe_device_via_ws(device_id) if online else {
|
||
"supports_hook": False,
|
||
"transport": "websocket",
|
||
"hook_tests": {"connect": "agent_offline"},
|
||
}
|
||
hook_ok = bool(probe.get("supports_hook") or probe.get("hook_tests", {}).get("connect") == "ok")
|
||
error = ""
|
||
except Exception as exc:
|
||
probe = {}
|
||
hook_ok = False
|
||
error = str(exc)[:200]
|
||
elapsed_ms = int((time.time() - started) * 1000)
|
||
rows.append({
|
||
"index": index + 1,
|
||
"ts": int(time.time()),
|
||
"device_id": device_id,
|
||
"device_id_md5": device_id_md5(device_id),
|
||
"ws_online": online,
|
||
"adb_online": bool(adb_dev),
|
||
"adb_serial": getattr(adb_dev, "serial", ""),
|
||
"hook_ok": hook_ok,
|
||
"wechat_version": probe.get("wechat_version", ""),
|
||
"transport": probe.get("transport") or info.get("transport") or "websocket",
|
||
"latency_ms": elapsed_ms,
|
||
"error": error,
|
||
})
|
||
if index < samples - 1 and interval_seconds:
|
||
await asyncio.sleep(interval_seconds)
|
||
|
||
ok_count = sum(1 for row in rows if row["ws_online"] and row["hook_ok"])
|
||
return {
|
||
"code": 200,
|
||
"data": {
|
||
"device_id": device_id,
|
||
"device_id_md5": device_id_md5(device_id),
|
||
"samples": rows,
|
||
"summary": {
|
||
"total": len(rows),
|
||
"ok": ok_count,
|
||
"success_rate": round(ok_count / len(rows), 4) if rows else 0,
|
||
"max_latency_ms": max((row["latency_ms"] for row in rows), default=0),
|
||
},
|
||
},
|
||
}
|
||
|
||
|
||
@router.get("/hook/data/{device_id}", response_model=dict, tags=["Hook模块管理"])
|
||
async def hook_data(
|
||
device_id: str,
|
||
modules: str = "profile,contacts,groups,messages,labels",
|
||
contact_limit: int = DEFAULT_CONTACT_PULL_LIMIT,
|
||
message_limit: int = DEFAULT_MESSAGE_PULL_LIMIT,
|
||
contact_offset: int = 0,
|
||
message_offset: int = 0,
|
||
):
|
||
"""
|
||
一次性获取设备的 Hook 数据(联系人/消息/群/标签/资料等)
|
||
|
||
modules 参数用逗号分隔,可选值:
|
||
profile, contacts, groups, messages, labels, moments, accounts, hook_status, device_info
|
||
"""
|
||
import asyncio
|
||
|
||
requested = [m.strip() for m in modules.split(",") if m.strip()]
|
||
limits = {
|
||
"contact_limit": _bounded_limit(contact_limit, DEFAULT_CONTACT_PULL_LIMIT, MAX_CONTACT_PULL_LIMIT),
|
||
"message_limit": _bounded_limit(message_limit, DEFAULT_MESSAGE_PULL_LIMIT, MAX_MESSAGE_PULL_LIMIT),
|
||
"contact_offset": _bounded_offset(contact_offset),
|
||
"message_offset": _bounded_offset(message_offset),
|
||
}
|
||
|
||
if ws_hub.is_online(device_id):
|
||
result = await _fetch_hook_data_via_ws(device_id, requested, limits)
|
||
return {"code": 200, "device_id": device_id, "device_id_md5": device_id_md5(device_id), **result}
|
||
|
||
adb_dev = adb_manager.get_device(device_id)
|
||
serial = adb_dev.serial if adb_dev else device_id
|
||
result = await asyncio.get_running_loop().run_in_executor(
|
||
None, lambda: _fetch_hook_data(serial, requested, limits),
|
||
)
|
||
return {"code": 200, "device_id": device_id, "device_id_md5": device_id_md5(device_id), **result}
|
||
|
||
|
||
@router.post("/hook/execute", response_model=dict, tags=["Hook模块管理"])
|
||
async def hook_execute(req: HookExecuteRequest):
|
||
"""
|
||
Hawk Hook 统一执行 — 单接口控制整台手机
|
||
|
||
任意应用只需调此端点,传 action + params 即可执行 107 种操作。
|
||
通道优先级:Frida Hook → WebSocket Agent → ADB UI自动化。
|
||
|
||
示例:
|
||
```json
|
||
{"device_id":"dc9c23e00510","platform":"wechat","action":"send_message","params":{"to_id":"阿猫","content":"你好"}}
|
||
{"device_id":"dc9c23e00510","platform":"wechat","action":"get_profile","params":{},"hook_only":true}
|
||
{"device_id":"dc9c23e00510","platform":"wechat","action":"register_account","params":{"phone":"13800138000","nickname":"卡若AI"}}
|
||
```
|
||
`hook_only:true` 时仅 Frida RPC,失败不降级 u2(需 Agent 已连上且 Frida 附着微信)。
|
||
"""
|
||
_check_device_online(req.device_id)
|
||
# 易阻塞动作快速失败(如 check_login_state 的 WCDB 加密读会挂 ~58s),
|
||
# 缩短超时让其尽快落 u2 兜底而非拖垮通道。
|
||
_SLOW_ACTION_TIMEOUTS = {"check_login_state": 20}
|
||
_action_timeout = _SLOW_ACTION_TIMEOUTS.get(req.action, 120)
|
||
result = await _execute_skill(
|
||
req.device_id,
|
||
req.platform.value,
|
||
req.action,
|
||
req.params or {},
|
||
timeout=_action_timeout,
|
||
hook_only=req.hook_only,
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
channel = result.get("_channel_used") or result.get("channel") or result.get("mode") or "sdk_control"
|
||
# 真机铁律 #3/#14:诚实传播内层状态,禁止把失败/未验证(503/success:False)伪装成 200
|
||
inner_code = result.get("code")
|
||
inner_success = result.get("success")
|
||
if isinstance(payload, dict) and "success" in payload:
|
||
inner_success = payload.get("success")
|
||
code = inner_code if isinstance(inner_code, int) else 200
|
||
if code == 200 and inner_success is False:
|
||
code = 503 # hook 未真实成功(如 no_receiver_registered / Frida 主控不可用)
|
||
return {"code": code, "data": payload, "action": req.action, "channel_used": channel}
|
||
|
||
|
||
# =============================================================================
|
||
# 一、消息管理接口
|
||
# =============================================================================
|
||
|
||
@router.post("/message/send", response_model=dict, tags=["消息管理"])
|
||
async def send_message(req: SendMessageRequest):
|
||
"""
|
||
发送消息(统一接口)
|
||
|
||
自动选择最优通道:
|
||
1. 有官方API → 用API
|
||
2. 设备在线 → 用SDK控制
|
||
3. 兜底 → 用AI Agent
|
||
|
||
超时:使用 MESSAGE_SEND_TIMEOUT(默认60s),超时返回 200 + success=false + error=timeout
|
||
"""
|
||
# ---- 防封守卫 ----
|
||
guard = await _anti_ban_guard(req.device_id, req.platform.value, "send_message", req.content)
|
||
if not guard["pass"]:
|
||
return {"code": 200, "data": {"success": False, "error": guard["reason"], "error_code": "anti_ban_blocked", "risk_action": guard.get("risk_action")}, "channel_used": "none"}
|
||
req.content = guard["content"]
|
||
|
||
# to_id 显示名归一(文件传输助手 → filehelper)
|
||
_to = (req.to_id or "").strip()
|
||
if _to in ("文件传输助手", "File Transfer"):
|
||
req.to_id = "filehelper"
|
||
|
||
mode = _get_device_mode(req.device_id)
|
||
device_online = mode != "offline"
|
||
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:
|
||
result = await _send_via_agent(req)
|
||
|
||
logger.info(f"[message/send] result success={result.get('success')} error={result.get('error')}")
|
||
await device_manager.log_command(
|
||
req.device_id,
|
||
f"{req.platform.value}.send_message",
|
||
req.model_dump(),
|
||
result
|
||
)
|
||
err = result.get("error") or ""
|
||
data = {
|
||
"success": result.get("success", False),
|
||
"message_id": result.get("message_id"),
|
||
"error": err or None
|
||
}
|
||
if err and "未找到" in str(err):
|
||
data["error_code"] = "contact_not_found"
|
||
elif err and str(err) == "timeout":
|
||
data["error_code"] = "timeout"
|
||
# AB-02 静默限流识别:success=True 但无 message_id/svr_id 视为可能的静默降权,
|
||
# 触发设备级熔断(写类动作冷却 30-60min,逐次退避升级;不伪造成功)。
|
||
mid = result.get("message_id") or result.get("svr_id") or result.get("msg_id")
|
||
if data["success"] and not mid:
|
||
cooldown = rate_limiter.trip_silent_throttle(req.device_id)
|
||
data["risk"] = "silent_throttle"
|
||
data["risk_detail"] = f"发送返回成功但无 message_id/svr_id,疑似静默限流;已熔断写操作 {cooldown:.0f}s"
|
||
logger.warning(f"[message/send] silent_throttle device_id={req.device_id} to_id={req.to_id} channel={channel.value} cooldown={cooldown:.0f}s")
|
||
elif data["success"] and mid:
|
||
rate_limiter.reset_silent_throttle(req.device_id)
|
||
ch_used = result.get("channel_used") or channel.value
|
||
return {"code": 200, "data": data, "channel_used": ch_used}
|
||
except Exception as e:
|
||
logger.warning(f"[message/send] exception channel={channel.value} e={e}")
|
||
if channel == Channel.SDK_CONTROL:
|
||
try:
|
||
result = await _send_via_agent(req)
|
||
return {
|
||
"code": 200,
|
||
"data": result,
|
||
"channel_used": Channel.AI_AGENT.value
|
||
}
|
||
except Exception:
|
||
pass
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.post("/message/list", response_model=dict, tags=["消息管理"])
|
||
async def get_messages(req: GetMessagesRequest):
|
||
"""获取消息列表"""
|
||
|
||
_check_device_online(req.device_id)
|
||
limit = _bounded_limit(req.limit, DEFAULT_MESSAGE_PULL_LIMIT, MAX_MESSAGE_PULL_LIMIT)
|
||
offset = _bounded_offset(req.offset)
|
||
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "get_messages",
|
||
{"conversation_id": req.conversation_id, "limit": limit, "offset": offset}
|
||
)
|
||
# 兼容 WebSocket 返回 { code, data: { messages } } 与 ADB 返回 { success, messages }
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
messages = payload.get("messages", [])
|
||
return {
|
||
"code": 200,
|
||
"data": {
|
||
"messages": messages,
|
||
"count": len(messages),
|
||
"requested_limit": limit,
|
||
"offset": offset,
|
||
"total_count": payload.get("total_count") or payload.get("total"),
|
||
"has_more": bool(payload.get("has_more")) or len(messages) >= limit,
|
||
},
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
@router.post("/message/sync-since", response_model=dict, tags=["消息管理"])
|
||
async def sync_messages_since(req: MessageSyncSinceRequest):
|
||
"""按时间增量同步消息,供存客宝/超管轮询拉取。"""
|
||
_check_device_online(req.device_id)
|
||
limit = _bounded_limit(req.limit, DEFAULT_MESSAGE_PULL_LIMIT, MAX_MESSAGE_PULL_LIMIT)
|
||
offset = _bounded_offset(req.offset)
|
||
result = await _execute_skill(
|
||
req.device_id,
|
||
req.platform.value,
|
||
"get_messages",
|
||
{"conversation_id": req.conversation_id, "limit": limit, "offset": offset}
|
||
)
|
||
payload = _payload_of(result)
|
||
messages = _list_from_payload(payload, "messages", "items", "list")
|
||
filtered = []
|
||
max_ts = int(req.since_time or 0)
|
||
for message in messages:
|
||
ts = _message_timestamp(message)
|
||
if ts >= int(req.since_time or 0):
|
||
filtered.append(message)
|
||
if ts > max_ts:
|
||
max_ts = ts
|
||
return {
|
||
"code": 200,
|
||
"data": {
|
||
"messages": filtered,
|
||
"count": len(filtered),
|
||
"since_time": int(req.since_time or 0),
|
||
"next_since_time": max_ts,
|
||
"requested_limit": limit,
|
||
"offset": offset,
|
||
"total_count": payload.get("total_count") or payload.get("total"),
|
||
"has_more": len(messages) >= limit and len(filtered) >= limit,
|
||
},
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
@router.post("/message/batch-send", response_model=dict, tags=["消息管理"])
|
||
async def batch_send_message(req: BatchSendMessageRequest):
|
||
"""
|
||
批量发送消息(服务端逐条下发,间隔防风控,单条超时可控)
|
||
"""
|
||
guard = await _anti_ban_guard(req.device_id, req.platform.value, "batch_send", req.content)
|
||
if not guard["pass"]:
|
||
return {"code": 200, "data": {"success": False, "error": guard["reason"], "error_code": "anti_ban_blocked", "risk_action": guard.get("risk_action")}, "channel_used": "none"}
|
||
req.content = guard["content"]
|
||
|
||
_check_device_online(req.device_id)
|
||
per_msg_timeout = min(60, max(15, getattr(settings, "MESSAGE_SEND_TIMEOUT", 60)))
|
||
last_channel = "none"
|
||
sent, failed = [], []
|
||
for i, to_id in enumerate(req.to_ids):
|
||
if i > 0:
|
||
await asyncio.sleep(max(0.5, min(float(req.interval), 30)))
|
||
normalized_to_id = (to_id or "").strip()
|
||
if normalized_to_id in ("文件传输助手", "File Transfer"):
|
||
normalized_to_id = "filehelper"
|
||
send_req = SendMessageRequest(
|
||
device_id=req.device_id,
|
||
platform=req.platform,
|
||
to_id=normalized_to_id,
|
||
content=req.content,
|
||
msg_type=req.msg_type,
|
||
media_url=req.media_url,
|
||
timeout_seconds=per_msg_timeout,
|
||
)
|
||
result = await _send_via_hook(send_req)
|
||
last_channel = result.get("channel_used") or last_channel
|
||
if result.get("success"):
|
||
sent.append({"to_id": normalized_to_id, "message_id": result.get("message_id")})
|
||
else:
|
||
failed.append({"to_id": normalized_to_id, "error": result.get("error") or "unknown"})
|
||
return {
|
||
"code": 200,
|
||
"data": {"sent": sent, "failed": failed, "total": len(req.to_ids)},
|
||
"channel_used": last_channel
|
||
}
|
||
|
||
|
||
@router.post("/comment/reply", response_model=dict, tags=["消息管理"])
|
||
async def reply_comment(req: ReplyCommentRequest):
|
||
"""回复评论(抖音/小红书等)"""
|
||
_check_device_online(req.device_id)
|
||
guard = await _anti_ban_guard(req.device_id, req.platform.value, "comment", req.content)
|
||
if not guard["pass"]:
|
||
return {"code": 200, "success": False, "error": guard["reason"], "error_code": "anti_ban_blocked", "risk_action": guard.get("risk_action")}
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "reply_comment",
|
||
{"video_id": req.video_id, "comment_id": req.comment_id, "content": guard.get("content", req.content)}
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {
|
||
"code": 200,
|
||
"data": payload,
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# 二、好友管理接口
|
||
# =============================================================================
|
||
|
||
@router.post("/friend/add", response_model=dict, tags=["好友管理"])
|
||
async def add_friend(req: AddFriendRequest):
|
||
"""添加好友"""
|
||
guard = await _anti_ban_guard(req.device_id, req.platform.value, "add_friend", req.message)
|
||
if not guard["pass"]:
|
||
return {"code": 200, "data": {"success": False, "error": guard["reason"], "error_code": "anti_ban_blocked", "risk_action": guard.get("risk_action")}, "channel_used": "none"}
|
||
req.message = guard["content"] or req.message
|
||
|
||
_check_device_online(req.device_id)
|
||
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "add_friend",
|
||
{"user_id": req.user_id, "message": req.message, "source": req.source}
|
||
)
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": result.get("data", {}),
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
@router.post("/friend/accept", response_model=dict, tags=["好友管理"])
|
||
async def accept_friend(req: AcceptFriendRequest):
|
||
"""通过好友请求"""
|
||
|
||
_check_device_online(req.device_id)
|
||
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "accept_friend",
|
||
{"user_id": req.user_id}
|
||
)
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": result.get("data", {}),
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
@router.post("/friend/set-remark", response_model=dict, tags=["好友管理"])
|
||
async def set_friend_remark(req: SetRemarkRequest):
|
||
"""设置好友备注"""
|
||
|
||
_check_device_online(req.device_id)
|
||
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "set_remark",
|
||
{"user_id": req.user_id, "remark": req.remark}
|
||
)
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": result.get("data", {}),
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
@router.post("/friend/delete", response_model=dict, tags=["好友管理"])
|
||
async def delete_friend(req: DeleteFriendRequest):
|
||
"""删除好友"""
|
||
|
||
_check_device_online(req.device_id)
|
||
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "delete_friend",
|
||
{"user_id": req.user_id}
|
||
)
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": result.get("data", {}),
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
@router.post("/friend/batch-add", response_model=dict, tags=["好友管理"])
|
||
async def batch_add_friend(req: BatchAddFriendRequest):
|
||
"""批量添加好友"""
|
||
_check_device_online(req.device_id)
|
||
guard = await _anti_ban_guard(req.device_id, req.platform.value, "add_friend", req.message)
|
||
if not guard["pass"]:
|
||
return {"code": 200, "success": False, "error": guard["reason"], "error_code": "anti_ban_blocked", "risk_action": guard.get("risk_action")}
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "batch_add_friend",
|
||
{
|
||
"user_ids": req.user_ids,
|
||
"message": guard.get("content", req.message),
|
||
"interval": req.interval
|
||
},
|
||
timeout=len(req.user_ids) * 15
|
||
)
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": result.get("data", {}),
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
@router.get("/contacts", response_model=dict, tags=["好友管理"])
|
||
async def get_contacts(device_id: str, platform: Platform, limit: int = DEFAULT_CONTACT_PULL_LIMIT, offset: int = 0):
|
||
"""获取联系人列表(存客宝字段完整:display_name / wechat_id / tags 等)"""
|
||
from services.wechat_contact_normalizer import normalize_wechat_contacts
|
||
|
||
_check_device_online(device_id)
|
||
limit = _bounded_limit(limit, DEFAULT_CONTACT_PULL_LIMIT, MAX_CONTACT_PULL_LIMIT)
|
||
offset = _bounded_offset(offset)
|
||
result = await _execute_skill(
|
||
device_id, platform.value, "get_contacts",
|
||
{"limit": limit, "offset": offset}
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
raw = payload.get("contacts", [])
|
||
contacts = normalize_wechat_contacts(raw) if platform == Platform.WECHAT else raw
|
||
return {
|
||
"code": 200,
|
||
"data": {
|
||
"contacts": contacts,
|
||
"count": len(contacts),
|
||
"requested_limit": limit,
|
||
"offset": offset,
|
||
"total_count": payload.get("total_count") or payload.get("total") or (payload.get("_diag") or {}).get("filtered_total"),
|
||
"raw_total_count": (payload.get("_diag") or {}).get("rcontact_total"),
|
||
"has_more": bool(payload.get("has_more")) or len(contacts) >= limit,
|
||
},
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# 三、群聊管理接口
|
||
# =============================================================================
|
||
|
||
@router.post("/group/create", response_model=dict, tags=["群聊管理"])
|
||
async def create_group(req: CreateGroupRequest):
|
||
"""创建群聊"""
|
||
|
||
_check_device_online(req.device_id)
|
||
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "create_group",
|
||
{"group_name": req.group_name, "member_ids": req.member_ids}
|
||
)
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": result.get("data", {}),
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
@router.post("/group/invite", response_model=dict, tags=["群聊管理"])
|
||
async def invite_to_group(req: InviteToGroupRequest):
|
||
"""邀请入群"""
|
||
|
||
_check_device_online(req.device_id)
|
||
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "invite_to_group",
|
||
{"group_id": req.group_id, "member_ids": req.member_ids}
|
||
)
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": result.get("data", {}),
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
@router.post("/group/remove", response_model=dict, tags=["群聊管理"])
|
||
async def remove_from_group(req: RemoveFromGroupRequest):
|
||
"""移出群聊"""
|
||
|
||
_check_device_online(req.device_id)
|
||
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "remove_from_group",
|
||
{"group_id": req.group_id, "member_ids": req.member_ids}
|
||
)
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": result.get("data", {}),
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
@router.post("/group/set-notice", response_model=dict, tags=["群聊管理"])
|
||
async def set_group_notice(req: SetGroupNoticeRequest):
|
||
"""设置群公告"""
|
||
|
||
_check_device_online(req.device_id)
|
||
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "set_group_notice",
|
||
{"group_id": req.group_id, "notice": req.notice}
|
||
)
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": result.get("data", {}),
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
@router.post("/group/set-name", response_model=dict, tags=["群聊管理"])
|
||
async def set_group_name(req: SetGroupNameRequest):
|
||
"""设置群名"""
|
||
|
||
_check_device_online(req.device_id)
|
||
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "set_group_name",
|
||
{"group_id": req.group_id, "group_name": req.group_name}
|
||
)
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": result.get("data", {}),
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
@router.post("/group/send-message", response_model=dict, tags=["群聊管理"])
|
||
async def send_group_message(req: GroupMessageRequest):
|
||
"""发送群消息"""
|
||
_check_device_online(req.device_id)
|
||
guard = await _anti_ban_guard(req.device_id, req.platform.value, "send_group_message", req.content)
|
||
if not guard["pass"]:
|
||
return {"code": 200, "success": False, "error": guard["reason"], "error_code": "anti_ban_blocked", "risk_action": guard.get("risk_action")}
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "send_group_message",
|
||
{
|
||
"group_id": req.group_id,
|
||
"content": guard.get("content", req.content),
|
||
"msg_type": req.msg_type.value,
|
||
"media_url": req.media_url,
|
||
"at_all": req.at_all,
|
||
"at_list": req.at_list
|
||
}
|
||
)
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": result.get("data", {}),
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
@router.post("/group/set-welcome", response_model=dict, tags=["群聊管理"])
|
||
async def set_group_welcome(req: SetGroupWelcomeRequest):
|
||
"""设置群欢迎语"""
|
||
|
||
_check_device_online(req.device_id)
|
||
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "set_group_welcome",
|
||
{
|
||
"group_id": req.group_id,
|
||
"welcome_text": req.welcome_text,
|
||
"welcome_image": req.welcome_image
|
||
}
|
||
)
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": result.get("data", {}),
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
@router.get("/group/list", response_model=dict, tags=["群聊管理"])
|
||
async def get_group_list(device_id: str, platform: Platform = Platform.WECHAT, limit: int = 100, offset: int = 0):
|
||
"""获取群聊列表"""
|
||
|
||
_check_device_online(device_id)
|
||
limit = _bounded_limit(limit, 100, 500)
|
||
offset = _bounded_offset(offset)
|
||
|
||
result = await _execute_skill(
|
||
device_id, platform.value, "get_groups",
|
||
{"limit": limit, "offset": offset}
|
||
)
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": result.get("data", {}),
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
@router.get("/group/members", response_model=dict, tags=["群聊管理"])
|
||
async def get_group_members(device_id: str, group_id: str, platform: Platform = Platform.WECHAT):
|
||
"""获取群成员列表"""
|
||
|
||
_check_device_online(device_id)
|
||
|
||
result = await _execute_skill(
|
||
device_id, platform.value, "get_group_members",
|
||
{"group_id": group_id}
|
||
)
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": result.get("data", {}),
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# 四、标签管理接口
|
||
# =============================================================================
|
||
|
||
@router.post("/tag/add", response_model=dict, tags=["标签管理"])
|
||
async def add_tag(req: AddTagRequest):
|
||
"""给好友添加标签"""
|
||
|
||
_check_device_online(req.device_id)
|
||
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "add_tag",
|
||
{"user_id": req.user_id, "tags": req.tags}
|
||
)
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": result.get("data", {}),
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
@router.post("/tag/remove", response_model=dict, tags=["标签管理"])
|
||
async def remove_tag(req: RemoveTagRequest):
|
||
"""移除好友标签"""
|
||
|
||
_check_device_online(req.device_id)
|
||
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "remove_tag",
|
||
{"user_id": req.user_id, "tags": req.tags}
|
||
)
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": result.get("data", {}),
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
@router.post("/tag/create", response_model=dict, tags=["标签管理"])
|
||
async def create_tag(req: CreateTagRequest):
|
||
"""创建标签"""
|
||
|
||
_check_device_online(req.device_id)
|
||
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "create_tag",
|
||
{"tag_name": req.tag_name}
|
||
)
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": result.get("data", {}),
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
@router.post("/tag/delete", response_model=dict, tags=["标签管理"])
|
||
async def delete_tag(req: DeleteTagRequest):
|
||
"""删除标签"""
|
||
|
||
_check_device_online(req.device_id)
|
||
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "delete_tag",
|
||
{"tag_name": req.tag_name}
|
||
)
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": result.get("data", {}),
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
@router.get("/tag/list", response_model=dict, tags=["标签管理"])
|
||
async def get_tag_list(device_id: str, platform: Platform, limit: int = 100, offset: int = 0):
|
||
"""获取标签列表"""
|
||
|
||
_check_device_online(device_id)
|
||
limit = _bounded_limit(limit, 100, 500)
|
||
offset = _bounded_offset(offset)
|
||
|
||
result = await _execute_skill(
|
||
device_id, platform.value, "get_tags", {"limit": limit, "offset": offset}
|
||
)
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": result.get("data", {}),
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
@router.post("/tag/users", response_model=dict, tags=["标签管理"])
|
||
async def get_users_by_tag(req: GetUsersByTagRequest):
|
||
"""根据标签获取好友列表"""
|
||
|
||
_check_device_online(req.device_id)
|
||
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "get_users_by_tag",
|
||
{"tag_name": req.tag_name, "limit": req.limit}
|
||
)
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": result.get("data", {}),
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# 五、朋友圈管理接口
|
||
# =============================================================================
|
||
|
||
@router.post("/moments/post", response_model=dict, tags=["朋友圈管理"])
|
||
async def post_moments(req: PostMomentsRequest):
|
||
"""发布朋友圈/瞬间(统一返回 200,业务失败用 success=false 表示,避免 502)"""
|
||
guard = await _anti_ban_guard(req.device_id, req.platform.value, "post_moments", req.content)
|
||
if not guard["pass"]:
|
||
return {"code": 200, "success": False, "error": guard["reason"], "risk_action": guard.get("risk_action"), "data": {}, "channel_used": "none"}
|
||
req.content = guard["content"]
|
||
|
||
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": result.get("_channel_used", "sdk_control")
|
||
}
|
||
return {
|
||
"code": 200,
|
||
"success": True,
|
||
"data": result.get("data", {}),
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
except Exception as e:
|
||
logger.exception(f"moments/post 异常: {e}")
|
||
return {
|
||
"code": 200,
|
||
"success": False,
|
||
"error": str(e),
|
||
"data": {},
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
@router.post("/moments/like", response_model=dict, tags=["朋友圈管理"])
|
||
async def like_moments(req: LikeMomentsRequest):
|
||
"""点赞朋友圈"""
|
||
_check_device_online(req.device_id)
|
||
guard = await _anti_ban_guard(req.device_id, req.platform.value, "like_moments")
|
||
if not guard["pass"]:
|
||
return {"code": 200, "success": False, "error": guard["reason"], "error_code": "anti_ban_blocked", "risk_action": guard.get("risk_action")}
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "like_moments",
|
||
{"user_id": req.user_id, "post_index": req.post_index}
|
||
)
|
||
return {
|
||
"code": 200,
|
||
"data": result.get("data", {}),
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
@router.post("/moments/comment", response_model=dict, tags=["朋友圈管理"])
|
||
async def comment_moments(req: CommentMomentsRequest):
|
||
"""评论朋友圈"""
|
||
_check_device_online(req.device_id)
|
||
guard = await _anti_ban_guard(req.device_id, req.platform.value, "comment_moments", req.comment)
|
||
if not guard["pass"]:
|
||
return {"code": 200, "success": False, "error": guard["reason"], "error_code": "anti_ban_blocked", "risk_action": guard.get("risk_action")}
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "comment_moments",
|
||
{
|
||
"user_id": req.user_id,
|
||
"post_index": req.post_index,
|
||
"comment": guard.get("content", req.comment),
|
||
"reply_to": req.reply_to
|
||
}
|
||
)
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": result.get("data", {}),
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
@router.post("/moments/list", response_model=dict, tags=["朋友圈管理"])
|
||
async def get_moments(req: GetMomentsRequest):
|
||
"""获取朋友圈列表"""
|
||
|
||
_check_device_online(req.device_id)
|
||
try:
|
||
result = await asyncio.wait_for(
|
||
_execute_skill(
|
||
req.device_id, req.platform.value, "get_moments",
|
||
{"user_id": req.user_id, "limit": req.limit},
|
||
timeout=25,
|
||
hook_only=req.platform == Platform.WECHAT,
|
||
),
|
||
timeout=30,
|
||
)
|
||
except asyncio.TimeoutError:
|
||
result = {
|
||
"code": 503,
|
||
"data": {
|
||
"success": False,
|
||
"error": "朋友圈列表读取超时:SnsMicroMsg.db/WCDB2 适配待完成",
|
||
"blocked_reason": "sns_wcdb2_timeout",
|
||
"moments": [],
|
||
"count": 0,
|
||
},
|
||
"_channel_used": "websocket/hook(timeout)",
|
||
}
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": result.get("data", {}),
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# 六、消息高级操作
|
||
# =============================================================================
|
||
|
||
class ForwardMessageRequest(BaseModel):
|
||
device_id: str
|
||
platform: Platform
|
||
to_id: str
|
||
content: str = ""
|
||
msg_svr_id: Optional[str] = None
|
||
|
||
class SendCardRequest(BaseModel):
|
||
device_id: str
|
||
platform: Platform
|
||
to_id: str
|
||
card_wxid: str
|
||
|
||
@router.post("/message/forward", response_model=dict, tags=["消息管理"])
|
||
async def forward_message(req: ForwardMessageRequest):
|
||
"""转发消息"""
|
||
_check_device_online(req.device_id)
|
||
params = {"to_id": req.to_id, "content": req.content}
|
||
if req.msg_svr_id:
|
||
params["msg_svr_id"] = req.msg_svr_id
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "forward_message", params
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
class RecallMessageRequest(BaseModel):
|
||
device_id: str
|
||
platform: Platform = Platform.WECHAT
|
||
msg_svr_id: Optional[str] = None
|
||
|
||
@router.post("/message/recall", response_model=dict, tags=["消息管理"])
|
||
async def recall_message(req: RecallMessageRequest):
|
||
"""撤回最近一条消息"""
|
||
_check_device_online(req.device_id)
|
||
params = {}
|
||
if req.msg_svr_id:
|
||
params["msg_svr_id"] = req.msg_svr_id
|
||
result = await _execute_skill(req.device_id, req.platform.value, "recall_message", params)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/message/send-card", response_model=dict, tags=["消息管理"])
|
||
async def send_card(req: SendCardRequest):
|
||
"""发送名片"""
|
||
_check_device_online(req.device_id)
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "send_card",
|
||
{"to_id": req.to_id, "card_wxid": req.card_wxid}
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# =============================================================================
|
||
# 七、个人设置接口
|
||
# =============================================================================
|
||
|
||
class SetNicknameRequest(BaseModel):
|
||
device_id: str
|
||
platform: Platform
|
||
nickname: str
|
||
|
||
class SetSignatureRequest(BaseModel):
|
||
device_id: str
|
||
platform: Platform
|
||
signature: str
|
||
|
||
class SetAvatarRequest(BaseModel):
|
||
device_id: str
|
||
platform: Platform
|
||
image_path: Optional[str] = None
|
||
|
||
class SetGenderRequest(BaseModel):
|
||
device_id: str
|
||
platform: Platform
|
||
gender: str
|
||
|
||
class SetRegionRequest(BaseModel):
|
||
device_id: str
|
||
platform: Platform
|
||
region: str
|
||
|
||
@router.get("/profile/get", response_model=dict, tags=["个人设置"])
|
||
async def get_profile(device_id: str, platform: Platform = Platform.WECHAT):
|
||
"""获取当前微信账号资料"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "get_profile", {})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/profile/set-nickname", response_model=dict, tags=["个人设置"])
|
||
async def set_nickname(req: SetNicknameRequest):
|
||
"""修改微信昵称"""
|
||
_check_device_online(req.device_id)
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "set_nickname",
|
||
{"nickname": req.nickname}
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/profile/set-signature", response_model=dict, tags=["个人设置"])
|
||
async def set_signature(req: SetSignatureRequest):
|
||
"""修改个性签名"""
|
||
_check_device_online(req.device_id)
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "set_signature",
|
||
{"signature": req.signature}
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/profile/set-avatar", response_model=dict, tags=["个人设置"])
|
||
async def set_avatar(req: SetAvatarRequest):
|
||
"""修改头像"""
|
||
_check_device_online(req.device_id)
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "set_avatar",
|
||
{"image_path": req.image_path}
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/profile/set-gender", response_model=dict, tags=["个人设置"])
|
||
async def set_gender(req: SetGenderRequest):
|
||
"""设置性别"""
|
||
_check_device_online(req.device_id)
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "set_gender",
|
||
{"gender": req.gender}
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/profile/set-region", response_model=dict, tags=["个人设置"])
|
||
async def set_region(req: SetRegionRequest):
|
||
"""设置地区"""
|
||
_check_device_online(req.device_id)
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "set_region",
|
||
{"region": req.region}
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# =============================================================================
|
||
# 八、账号安全接口
|
||
# =============================================================================
|
||
|
||
class UnblockAccountRequest(BaseModel):
|
||
device_id: str
|
||
platform: Platform
|
||
helper_wxid: Optional[str] = None
|
||
|
||
class ChangePasswordRequest(BaseModel):
|
||
device_id: str
|
||
platform: Platform
|
||
old_pwd: str
|
||
new_pwd: str
|
||
|
||
@router.get("/account/status", response_model=dict, tags=["账号安全"])
|
||
async def check_account_status(device_id: str, platform: Platform = Platform.WECHAT):
|
||
"""检查账号状态"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "check_account_status", {})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/account/unblock", response_model=dict, tags=["账号安全"])
|
||
async def unblock_account(req: UnblockAccountRequest):
|
||
"""微信解封"""
|
||
_check_device_online(req.device_id)
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "unblock_account",
|
||
{"helper_wxid": req.helper_wxid}
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.get("/account/safety-center", response_model=dict, tags=["账号安全"])
|
||
async def safety_center(device_id: str, platform: Platform):
|
||
"""打开微信安全中心"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "safety_center", {})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/account/change-password", response_model=dict, tags=["账号安全"])
|
||
async def change_password(req: ChangePasswordRequest):
|
||
"""修改微信密码"""
|
||
_check_device_online(req.device_id)
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "change_password",
|
||
{"old_pwd": req.old_pwd, "new_pwd": req.new_pwd}
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# =============================================================================
|
||
# 九、收藏管理接口
|
||
# =============================================================================
|
||
|
||
class AddFavoriteRequest(BaseModel):
|
||
device_id: str
|
||
platform: Platform = Platform.WECHAT
|
||
content_desc: Optional[str] = None
|
||
content: Optional[str] = None
|
||
content_type: Optional[str] = "text"
|
||
msg_svr_id: Optional[str] = None
|
||
|
||
@router.post("/favorites/add", response_model=dict, tags=["收藏管理"])
|
||
async def add_favorite(req: AddFavoriteRequest):
|
||
"""收藏消息"""
|
||
_check_device_online(req.device_id)
|
||
params = {"type": req.content_type or "text"}
|
||
if req.msg_svr_id:
|
||
params["msg_svr_id"] = req.msg_svr_id
|
||
if req.content:
|
||
params["content"] = req.content
|
||
if req.content_desc:
|
||
params["content_desc"] = req.content_desc
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "add_favorite", params
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.get("/favorites/list", response_model=dict, tags=["收藏管理"])
|
||
async def get_favorites(device_id: str, platform: Platform = Platform.WECHAT, limit: int = 20):
|
||
"""获取收藏列表"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "get_favorites", {"limit": limit})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# =============================================================================
|
||
# 十、聊天设置接口
|
||
# =============================================================================
|
||
|
||
class ChatSettingRequest(BaseModel):
|
||
device_id: str
|
||
platform: Platform
|
||
user_id: str
|
||
enable: bool = True
|
||
|
||
@router.post("/chat/set-top", response_model=dict, tags=["聊天设置"])
|
||
async def set_chat_top(req: ChatSettingRequest):
|
||
"""置顶/取消置顶聊天"""
|
||
_check_device_online(req.device_id)
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "set_chat_top",
|
||
{"user_id": req.user_id, "enable": req.enable}
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/chat/set-mute", response_model=dict, tags=["聊天设置"])
|
||
async def set_mute_chat(req: ChatSettingRequest):
|
||
"""消息免打扰"""
|
||
_check_device_online(req.device_id)
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "set_mute_chat",
|
||
{"user_id": req.user_id, "enable": req.enable}
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/chat/clear-history", response_model=dict, tags=["聊天设置"])
|
||
async def clear_chat_history(device_id: str, platform: Platform, user_id: str):
|
||
"""清空聊天记录"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(
|
||
device_id, platform.value, "clear_chat_history",
|
||
{"user_id": user_id}
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# =============================================================================
|
||
# 十一、联系人搜索接口
|
||
# =============================================================================
|
||
|
||
@router.get("/contacts/search", response_model=dict, tags=["好友管理"])
|
||
async def search_contacts(device_id: str, keyword: str, platform: Platform = Platform.WECHAT):
|
||
"""搜索联系人"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(
|
||
device_id, platform.value, "search_contact",
|
||
{"keyword": keyword}
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
@router.get("/customer/profile-bundle", response_model=dict, tags=["存客宝对接"])
|
||
async def get_customer_profile_bundle(
|
||
device_id: str,
|
||
platform: Platform = Platform.WECHAT,
|
||
keyword: str = "",
|
||
user_id: str = "",
|
||
limit: int = 50,
|
||
contact_limit: int = DEFAULT_CONTACT_PULL_LIMIT,
|
||
message_limit: int = DEFAULT_MESSAGE_PULL_LIMIT,
|
||
group_limit: int = 100,
|
||
tag_limit: int = 100,
|
||
contact_offset: int = 0,
|
||
message_offset: int = 0,
|
||
group_offset: int = 0,
|
||
tag_offset: int = 0,
|
||
):
|
||
"""
|
||
客户画像聚合包:资料、联系人、标签、群、最近消息一次返回。
|
||
|
||
面向存客宝/超管 BFF,减少多接口拼装成本;底层仍走真机 Hook/Agent。
|
||
"""
|
||
from services.wechat_contact_normalizer import normalize_wechat_contacts
|
||
|
||
_check_device_online(device_id)
|
||
limit = _bounded_limit(limit, 50, 500)
|
||
contact_limit = _bounded_limit(contact_limit, DEFAULT_CONTACT_PULL_LIMIT, MAX_CONTACT_PULL_LIMIT)
|
||
message_limit = _bounded_limit(message_limit, DEFAULT_MESSAGE_PULL_LIMIT, MAX_MESSAGE_PULL_LIMIT)
|
||
group_limit = _bounded_limit(group_limit, 100, 500)
|
||
tag_limit = _bounded_limit(tag_limit, 100, 500)
|
||
contact_offset = _bounded_offset(contact_offset)
|
||
message_offset = _bounded_offset(message_offset)
|
||
group_offset = _bounded_offset(group_offset)
|
||
tag_offset = _bounded_offset(tag_offset)
|
||
query = user_id or keyword
|
||
|
||
tasks = {
|
||
"profile": _execute_skill(device_id, platform.value, "get_profile", {}, timeout=45),
|
||
"contacts": _execute_skill(device_id, platform.value, "get_contacts", {"limit": contact_limit, "offset": contact_offset}, timeout=90),
|
||
"groups": _execute_skill(device_id, platform.value, "get_groups", {"limit": group_limit, "offset": group_offset}, timeout=45),
|
||
"tags": _execute_skill(device_id, platform.value, "get_tags", {"limit": tag_limit, "offset": tag_offset}, timeout=45),
|
||
"messages": _execute_skill(device_id, platform.value, "get_messages", {"limit": message_limit, "offset": message_offset}, timeout=90),
|
||
}
|
||
if query:
|
||
tasks["contact_search"] = _execute_skill(
|
||
device_id, platform.value, "search_contact", {"keyword": query}, timeout=45
|
||
)
|
||
if user_id:
|
||
tasks["friend_info"] = _execute_skill(
|
||
device_id, platform.value, "get_friend_info", {"user_id": user_id}, timeout=45
|
||
)
|
||
|
||
names = list(tasks.keys())
|
||
raw_results = await asyncio.gather(*tasks.values(), return_exceptions=True)
|
||
results = {}
|
||
channels = {}
|
||
errors = {}
|
||
for name, result in zip(names, raw_results):
|
||
if isinstance(result, Exception):
|
||
results[name] = {}
|
||
errors[name] = str(result)[:200]
|
||
channels[name] = "error"
|
||
continue
|
||
results[name] = _payload_of(result)
|
||
channels[name] = result.get("_channel_used", "sdk_control") if isinstance(result, dict) else "unknown"
|
||
|
||
contacts = _list_from_payload(results.get("contacts", {}), "contacts")
|
||
contacts = normalize_wechat_contacts(contacts) if platform == Platform.WECHAT else contacts
|
||
matched_contacts = [c for c in contacts if _matches_contact(c, query)][:limit] if query else contacts[:limit]
|
||
groups = _list_from_payload(results.get("groups", {}), "groups", "chatrooms")
|
||
tags = _list_from_payload(results.get("tags", {}), "tags", "labels")
|
||
messages = _list_from_payload(results.get("messages", {}), "messages")[:limit]
|
||
contact_payload = results.get("contacts", {})
|
||
message_payload = results.get("messages", {})
|
||
contact_total = contact_payload.get("total_count") or contact_payload.get("total") or (contact_payload.get("_diag") or {}).get("filtered_total") or len(contacts)
|
||
message_items = _list_from_payload(message_payload, "messages")
|
||
message_total = message_payload.get("total_count") or message_payload.get("total") or len(message_items)
|
||
|
||
return {
|
||
"code": 200,
|
||
"data": {
|
||
"device_id": device_id,
|
||
"device_id_md5": device_id_md5(device_id),
|
||
"query": {
|
||
"keyword": keyword,
|
||
"user_id": user_id,
|
||
"limit": limit,
|
||
"contact_limit": contact_limit,
|
||
"message_limit": message_limit,
|
||
"group_limit": group_limit,
|
||
"tag_limit": tag_limit,
|
||
"contact_offset": contact_offset,
|
||
"message_offset": message_offset,
|
||
"group_offset": group_offset,
|
||
"tag_offset": tag_offset,
|
||
},
|
||
"profile": results.get("profile", {}),
|
||
"contacts": matched_contacts,
|
||
"contact_count": contact_total,
|
||
"returned_contact_count": len(contacts),
|
||
"matched_contact_count": len(matched_contacts),
|
||
"contact_requested_limit": contact_limit,
|
||
"contact_offset": contact_offset,
|
||
"contact_total_count": contact_total,
|
||
"contact_raw_total_count": (contact_payload.get("_diag") or {}).get("rcontact_total"),
|
||
"contacts_has_more": bool(contact_payload.get("has_more")) or len(contacts) >= contact_limit,
|
||
"groups": groups[:limit],
|
||
"group_count": len(groups),
|
||
"group_requested_limit": group_limit,
|
||
"group_offset": group_offset,
|
||
"tags": tags[:limit],
|
||
"tag_count": len(tags),
|
||
"tag_requested_limit": tag_limit,
|
||
"tag_offset": tag_offset,
|
||
"recent_messages": messages,
|
||
"message_count": message_total,
|
||
"returned_message_count": len(message_items),
|
||
"message_requested_limit": message_limit,
|
||
"message_offset": message_offset,
|
||
"message_total_count": message_total,
|
||
"messages_has_more": bool(message_payload.get("has_more")) or len(message_items) >= message_limit,
|
||
"contact_search": results.get("contact_search", {}),
|
||
"friend_info": results.get("friend_info", {}),
|
||
"errors": errors,
|
||
},
|
||
"channel_used": channels,
|
||
}
|
||
|
||
@router.get("/friend/info", response_model=dict, tags=["好友管理"])
|
||
async def get_friend_info(device_id: str, platform: Platform, user_id: str):
|
||
"""获取好友详细资料"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(
|
||
device_id, platform.value, "get_friend_info",
|
||
{"user_id": user_id}
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# =============================================================================
|
||
# 十二、群聊高级操作
|
||
# =============================================================================
|
||
|
||
@router.post("/group/quit", response_model=dict, tags=["群聊管理"])
|
||
async def quit_group(device_id: str, platform: Platform, group_id: str):
|
||
"""退出群聊"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(
|
||
device_id, platform.value, "quit_group",
|
||
{"group_id": group_id}
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# =============================================================================
|
||
# 十三、朋友圈高级操作
|
||
# =============================================================================
|
||
|
||
@router.post("/moments/delete", response_model=dict, tags=["朋友圈管理"])
|
||
async def delete_moments(device_id: str, platform: Platform, post_index: int = 0):
|
||
"""删除朋友圈"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(
|
||
device_id, platform.value, "delete_moments",
|
||
{"post_index": post_index}
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# =============================================================================
|
||
# 十四、支付接口
|
||
# =============================================================================
|
||
|
||
class RedPacketRequest(BaseModel):
|
||
device_id: str
|
||
platform: Platform
|
||
to_id: str
|
||
amount: str
|
||
message: str = "恭喜发财"
|
||
|
||
class TransferRequest(BaseModel):
|
||
device_id: str
|
||
platform: Platform
|
||
to_id: str
|
||
amount: str
|
||
message: str = ""
|
||
|
||
@router.post("/payment/red-packet", response_model=dict, tags=["支付"])
|
||
async def send_red_packet(req: RedPacketRequest):
|
||
"""发红包"""
|
||
_check_device_online(req.device_id)
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "send_red_packet",
|
||
{"to_id": req.to_id, "amount": req.amount, "message": req.message}
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/payment/transfer", response_model=dict, tags=["支付"])
|
||
async def send_transfer(req: TransferRequest):
|
||
"""转账"""
|
||
_check_device_online(req.device_id)
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "transfer",
|
||
{"to_id": req.to_id, "amount": req.amount, "message": req.message}
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# =============================================================================
|
||
# 十五、小程序 & 公众号
|
||
# =============================================================================
|
||
|
||
@router.post("/miniprogram/open", response_model=dict, tags=["小程序"])
|
||
async def open_mini_program(
|
||
device_id: str = Body(...),
|
||
platform: Platform = Body(Platform.WECHAT),
|
||
name: str = Body(""),
|
||
app_id: str = Body(""),
|
||
):
|
||
"""打开小程序"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(
|
||
device_id, platform.value, "open_mini_program", {"name": name, "app_id": app_id or name}
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/official-account/follow", response_model=dict, tags=["公众号"])
|
||
async def follow_official_account(device_id: str, platform: Platform, account_name: str):
|
||
"""关注公众号"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(
|
||
device_id, platform.value, "follow_official_account",
|
||
{"account_name": account_name}
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# =============================================================================
|
||
# 十六、解封与限制管理
|
||
# =============================================================================
|
||
|
||
@router.post("/account/unblock-self", response_model=dict, tags=["账号安全"])
|
||
async def unblock_self(device_id: str, platform: Platform):
|
||
"""自助解封"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "unblock_self", {})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/account/unblock-appeal", response_model=dict, tags=["账号安全"])
|
||
async def unblock_appeal(device_id: str, platform: Platform):
|
||
"""申诉解封"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "unblock_appeal", {})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/account/unblock-customer-service", response_model=dict, tags=["账号安全"])
|
||
async def unblock_via_customer_service(
|
||
device_id: str,
|
||
platform: Platform,
|
||
reason: str = "",
|
||
phone: str = "",
|
||
wxid: str = "",
|
||
max_rounds: int = 20,
|
||
chat_interval_sec: int = 8,
|
||
use_web_search: bool = True,
|
||
):
|
||
"""
|
||
联系客服解封 — 全自动链路 + AI 对话
|
||
|
||
流程: 设置→账号与安全→微信安全中心→联系客服→客服会话→AI 持续软磨硬泡
|
||
|
||
返回 data 含 `session.turns[]`(每轮客服消息+AI 回复+发送状态)与 `final_status`:
|
||
success / reject / timeout / fallback / error
|
||
"""
|
||
_check_device_online(device_id)
|
||
params = {
|
||
"reason": reason,
|
||
"phone": phone,
|
||
"wxid": wxid,
|
||
"max_rounds": max_rounds,
|
||
"chat_interval_sec": chat_interval_sec,
|
||
"use_web_search": use_web_search,
|
||
}
|
||
run_timeout = max(180, int(max_rounds) * (int(chat_interval_sec) + 12) + 180)
|
||
result = await _execute_skill(
|
||
device_id,
|
||
platform.value,
|
||
"unblock_via_customer_service",
|
||
params,
|
||
timeout=run_timeout,
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.get("/account/restrictions", response_model=dict, tags=["账号安全"])
|
||
async def check_restrictions(device_id: str, platform: Platform):
|
||
"""检查当前功能限制"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "check_restrictions", {})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/account/appeal-restriction", response_model=dict, tags=["账号安全"])
|
||
async def appeal_restriction(device_id: str, platform: Platform):
|
||
"""申诉功能限制"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "appeal_restriction", {})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/account/unblock-sms", response_model=dict, tags=["账号安全"])
|
||
async def unblock_with_sms(device_id: str, platform: Platform, phone: str = ""):
|
||
"""短信验证解封"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "unblock_with_sms", {"phone": phone})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# =============================================================================
|
||
# 十七、视频号
|
||
# =============================================================================
|
||
|
||
@router.get("/video-channel/list", response_model=dict, tags=["视频号"])
|
||
async def get_video_list(device_id: str, platform: Platform = Platform.WECHAT, limit: int = 10):
|
||
"""获取视频号列表"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "get_video_list", {"limit": limit})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
if platform == Platform.WECHAT and isinstance(payload, dict) and payload.get("success") is False:
|
||
# 视频号尚无稳定内部 RPC;设备有 ADB 时使用真实 UI 读取兜底,禁止把广播占位返回成成功。
|
||
adb_device = adb_manager.get_device(device_id)
|
||
if adb_device and adb_device.is_online():
|
||
adb_result = await _execute_via_adb(
|
||
adb_device,
|
||
platform.value,
|
||
"get_video_list",
|
||
{"limit": min(max(limit, 1), 20)},
|
||
timeout=60,
|
||
)
|
||
if adb_result.get("success"):
|
||
return {"code": 200, "data": adb_result, "channel_used": "adb/sdk_control"}
|
||
payload = {
|
||
**payload,
|
||
"adb_fallback": adb_result,
|
||
}
|
||
return {"code": 503, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/video-channel/like", response_model=dict, tags=["视频号"])
|
||
async def like_video(device_id: str, platform: Platform, index: int = 0):
|
||
"""点赞视频"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "like_video", {"index": index})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/video-channel/comment", response_model=dict, tags=["视频号"])
|
||
async def comment_video(device_id: str, platform: Platform, index: int = 0, comment: str = ""):
|
||
"""评论视频"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "comment_video", {"index": index, "comment": comment})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/video-channel/follow", response_model=dict, tags=["视频号"])
|
||
async def follow_video_creator(device_id: str, platform: Platform, index: int = 0):
|
||
"""关注视频号创作者"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "follow_video_creator", {"index": index})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/video-channel/share", response_model=dict, tags=["视频号"])
|
||
async def share_video(device_id: str, platform: Platform, index: int = 0, to_id: str = ""):
|
||
"""分享视频"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "share_video", {"index": index, "to_id": to_id})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# =============================================================================
|
||
# 十八、扫一扫
|
||
# =============================================================================
|
||
|
||
@router.post("/scan/qr-code", response_model=dict, tags=["扫一扫"])
|
||
async def scan_qr_code(device_id: str, platform: Platform):
|
||
"""扫描二维码"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "scan_qr_code", {})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/scan/add-friend", response_model=dict, tags=["扫一扫"])
|
||
async def scan_add_friend(device_id: str, platform: Platform):
|
||
"""扫码加好友"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "scan_add_friend", {})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.get("/scan/my-qr", response_model=dict, tags=["扫一扫"])
|
||
async def show_my_qr(device_id: str, platform: Platform):
|
||
"""显示我的二维码"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "show_my_qr", {})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/scan/extract-qr", response_model=dict, tags=["扫一扫"])
|
||
async def extract_qr_from_image(
|
||
device_id: str = Body(...),
|
||
platform: Platform = Body(Platform.WECHAT),
|
||
image_base64: str = Body("", description="二维码图片 base64(PNG/JPG,可含 data: 前缀)"),
|
||
image_url: str = Body("", description="二维码图片可下载 URL"),
|
||
):
|
||
"""从图片识别二维码(支持传入 image_base64 / image_url,缺省则识别当前相册选图页)"""
|
||
_check_device_online(device_id)
|
||
params = {}
|
||
if image_base64:
|
||
params["image_base64"] = image_base64
|
||
if image_url:
|
||
params["image_url"] = image_url
|
||
result = await _execute_skill(device_id, platform.value, "extract_qr_from_image", params)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
@router.post("/scan/add-friend-from-image", response_model=dict, tags=["扫一扫"])
|
||
async def add_friend_from_image(
|
||
device_id: str = Body(...),
|
||
platform: Platform = Body(Platform.WECHAT),
|
||
image_base64: str = Body("", description="好友二维码图片 base64(PNG/JPG,可含 data: 前缀)"),
|
||
image_url: str = Body("", description="好友二维码图片可下载 URL"),
|
||
verify_message: str = Body("", description="加好友打招呼语(可选)"),
|
||
):
|
||
"""AI/BFF 下发二维码图片 → 真机识别 → 自动加好友(WP-WX-04 04.1 契约层)。
|
||
|
||
执行链(Agent 侧 add_friend_from_image):保存图片到相册 → 微信扫一扫从相册选图
|
||
→ 识别 qr_content → add_friend_by_qr(verify_message)。
|
||
失败诚实返回(识别失败/二维码过期/已是好友/503 Hook 未附着),禁止 mock success。
|
||
"""
|
||
if not image_base64 and not image_url:
|
||
raise HTTPException(status_code=400, detail="必须提供 image_base64 或 image_url 之一")
|
||
_check_device_online(device_id)
|
||
params = {"verify_message": verify_message}
|
||
if image_base64:
|
||
params["image_base64"] = image_base64
|
||
if image_url:
|
||
params["image_url"] = image_url
|
||
result = await _execute_skill(device_id, platform.value, "add_friend_from_image", params)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# =============================================================================
|
||
# 十九、支付增强
|
||
# =============================================================================
|
||
|
||
@router.get("/payment/code", response_model=dict, tags=["支付"])
|
||
async def show_payment_code(device_id: str, platform: Platform):
|
||
"""显示付款码"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "show_payment_code", {})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/payment/receive", response_model=dict, tags=["支付"])
|
||
async def receive_payment(device_id: str, platform: Platform, amount: str = "", desc: str = ""):
|
||
"""收款"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "receive_payment", {"amount": amount, "desc": desc})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.get("/payment/wallet", response_model=dict, tags=["支付"])
|
||
async def view_wallet(device_id: str, platform: Platform):
|
||
"""查看钱包"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "view_wallet", {})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.get("/payment/transactions", response_model=dict, tags=["支付"])
|
||
async def view_transactions(device_id: str, platform: Platform, limit: int = 20):
|
||
"""查看账单"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "view_transactions", {"limit": limit})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/payment/receive-red-packet", response_model=dict, tags=["支付"])
|
||
async def receive_red_packet(device_id: str, platform: Platform, from_id: str = ""):
|
||
"""领取红包"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "receive_red_packet", {"from_id": from_id})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# =============================================================================
|
||
# 二十、通话
|
||
# =============================================================================
|
||
|
||
@router.post("/call/voice", response_model=dict, tags=["通话"])
|
||
async def voice_call(device_id: str, platform: Platform, user_id: str = ""):
|
||
"""语音通话"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "voice_call", {"user_id": user_id})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/call/video", response_model=dict, tags=["通话"])
|
||
async def video_call(device_id: str, platform: Platform, user_id: str = ""):
|
||
"""视频通话"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "video_call", {"user_id": user_id})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# =============================================================================
|
||
# 二十一、群发助手
|
||
# =============================================================================
|
||
|
||
class MassSendRequest(BaseModel):
|
||
device_id: str
|
||
platform: Platform
|
||
content: str
|
||
user_ids: List[str]
|
||
|
||
@router.post("/mass-send", response_model=dict, tags=["群发助手"])
|
||
async def mass_send(req: MassSendRequest):
|
||
"""群发消息"""
|
||
_check_device_online(req.device_id)
|
||
guard = await _anti_ban_guard(req.device_id, req.platform.value, "batch_send", req.content)
|
||
if not guard["pass"]:
|
||
return {"code": 200, "success": False, "error": guard["reason"], "error_code": "anti_ban_blocked", "risk_action": guard.get("risk_action")}
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "mass_send",
|
||
{"content": guard.get("content", req.content), "user_ids": req.user_ids}
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# =============================================================================
|
||
# 二十二、搜一搜/看一看
|
||
# =============================================================================
|
||
|
||
@router.get("/search/wechat", response_model=dict, tags=["搜一搜"])
|
||
async def wechat_search(device_id: str, platform: Platform, keyword: str = ""):
|
||
"""搜一搜"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "wechat_search", {"keyword": keyword})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.get("/discover/top-stories", response_model=dict, tags=["看一看"])
|
||
async def top_stories(device_id: str, platform: Platform):
|
||
"""看一看"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "top_stories", {})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# =============================================================================
|
||
# 二十三、微信运动
|
||
# =============================================================================
|
||
|
||
@router.get("/wechat-sport/steps", response_model=dict, tags=["微信运动"])
|
||
async def get_steps(device_id: str, platform: Platform = Platform.WECHAT):
|
||
"""获取步数"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "get_steps", {})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/wechat-sport/like-steps", response_model=dict, tags=["微信运动"])
|
||
async def like_steps(device_id: str, platform: Platform, user_id: str = ""):
|
||
"""点赞步数"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "like_steps", {"user_id": user_id})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# =============================================================================
|
||
# 二十四、位置分享
|
||
# =============================================================================
|
||
|
||
@router.post("/location/send", response_model=dict, tags=["位置"])
|
||
async def send_location(
|
||
device_id: str = Body(...),
|
||
platform: Platform = Body(Platform.WECHAT),
|
||
to_id: str = Body(""),
|
||
user_id: str = Body(""),
|
||
latitude: float = Body(0),
|
||
longitude: float = Body(0),
|
||
name: str = Body(""),
|
||
label: str = Body(""),
|
||
):
|
||
"""发送位置"""
|
||
_check_device_online(device_id)
|
||
tid = to_id or user_id
|
||
result = await _execute_skill(device_id, platform.value, "send_location", {
|
||
"to_id": tid, "latitude": latitude, "longitude": longitude,
|
||
"label": label or name, "name": name,
|
||
})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/location/share-realtime", response_model=dict, tags=["位置"])
|
||
async def share_real_time_location(device_id: str, platform: Platform, user_id: str = ""):
|
||
"""共享实时位置"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "share_real_time_location", {"user_id": user_id})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# =============================================================================
|
||
# 二十五、表情管理
|
||
# =============================================================================
|
||
|
||
@router.post("/emoji/send", response_model=dict, tags=["表情"])
|
||
async def send_emoji(
|
||
device_id: str = Body(...),
|
||
platform: Platform = Body(Platform.WECHAT),
|
||
to_id: str = Body(""),
|
||
user_id: str = Body(""),
|
||
emoji_name: str = Body(""),
|
||
emoji_md5: str = Body(""),
|
||
):
|
||
"""发送表情"""
|
||
_check_device_online(device_id)
|
||
tid = to_id or user_id
|
||
result = await _execute_skill(device_id, platform.value, "send_emoji", {
|
||
"to_id": tid, "user_id": tid, "emoji_name": emoji_name, "emoji_md5": emoji_md5 or emoji_name,
|
||
})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.get("/emoji/stickers", response_model=dict, tags=["表情"])
|
||
async def get_sticker_list(device_id: str, platform: Platform = Platform.WECHAT):
|
||
"""获取表情包列表"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "get_sticker_list", {})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# =============================================================================
|
||
# 二十六、朋友圈增强
|
||
# =============================================================================
|
||
|
||
@router.post("/moments/set-cover", response_model=dict, tags=["朋友圈管理"])
|
||
async def set_moments_cover(device_id: str, platform: Platform):
|
||
"""设置朋友圈封面"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "set_moments_cover", {})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/moments/set-privacy", response_model=dict, tags=["朋友圈管理"])
|
||
async def set_moments_privacy(
|
||
device_id: str = Body(...),
|
||
platform: Platform = Body(Platform.WECHAT),
|
||
privacy_type: str = Body("all"),
|
||
days: int = Body(180),
|
||
):
|
||
"""设置朋友圈可见天数"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "set_moments_privacy", {
|
||
"privacy_type": privacy_type,
|
||
"days": days,
|
||
})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/moments/share-link", response_model=dict, tags=["朋友圈管理"])
|
||
async def share_link_to_moments(device_id: str, platform: Platform, url: str = "", title: str = ""):
|
||
"""分享链接到朋友圈"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "forward_moments_link", {"url": url, "title": title})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# =============================================================================
|
||
# 二十七、语音消息
|
||
# =============================================================================
|
||
|
||
@router.post("/message/voice", response_model=dict, tags=["消息管理"])
|
||
async def send_voice_message(
|
||
device_id: str = Body(...),
|
||
platform: Platform = Body(Platform.WECHAT),
|
||
to_id: str = Body(""),
|
||
user_id: str = Body(""),
|
||
duration: int = Body(3),
|
||
voice_path: str = Body(""),
|
||
):
|
||
"""发送语音消息"""
|
||
_check_device_online(device_id)
|
||
tid = to_id or user_id
|
||
result = await _execute_skill(device_id, platform.value, "send_voice_message", {
|
||
"to_id": tid, "user_id": tid, "duration": duration, "voice_path": voice_path,
|
||
})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# =============================================================================
|
||
# 二十八、文件管理
|
||
# =============================================================================
|
||
|
||
@router.post("/file/send", response_model=dict, tags=["文件管理"])
|
||
async def send_file(
|
||
device_id: str = Body(...),
|
||
platform: Platform = Body(Platform.WECHAT),
|
||
to_id: str = Body(""),
|
||
user_id: str = Body(""),
|
||
file_path: str = Body(""),
|
||
):
|
||
"""发送文件"""
|
||
_check_device_online(device_id)
|
||
tid = to_id or user_id
|
||
result = await _execute_skill(device_id, platform.value, "send_file", {
|
||
"to_id": tid, "file_path": file_path,
|
||
})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/file/download", response_model=dict, tags=["文件管理"])
|
||
async def download_file(device_id: str, platform: Platform, user_id: str = ""):
|
||
"""下载文件"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "download_file", {"user_id": user_id})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# =============================================================================
|
||
# 二十九、设置管理
|
||
# =============================================================================
|
||
|
||
@router.post("/settings/do-not-disturb", response_model=dict, tags=["设置"])
|
||
async def toggle_dnd(
|
||
device_id: str = Body(...),
|
||
platform: Platform = Body(Platform.WECHAT),
|
||
enable: bool = Body(True),
|
||
):
|
||
"""勿扰模式"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "toggle_do_not_disturb", {"enable": enable})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/settings/clear-cache", response_model=dict, tags=["设置"])
|
||
async def clear_cache(
|
||
device_id: str = Body(...),
|
||
platform: Platform = Body(Platform.WECHAT),
|
||
):
|
||
"""清理缓存"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "clear_cache", {})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/settings/check-update", response_model=dict, tags=["设置"])
|
||
async def check_update(
|
||
device_id: str = Body(...),
|
||
platform: Platform = Body(Platform.WECHAT),
|
||
):
|
||
"""检查更新"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "check_for_update", {})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/settings/logout", response_model=dict, tags=["设置"])
|
||
async def logout(device_id: str, platform: Platform):
|
||
"""退出登录"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "logout", {})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
@router.post("/settings/switch-account", response_model=dict, tags=["设置"])
|
||
async def switch_account(device_id: str, platform: Platform):
|
||
"""切换账号"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "switch_account", {})
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
return {"code": 200, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# =============================================================================
|
||
# 内部实现方法
|
||
# =============================================================================
|
||
|
||
async def _execute_actions_via_adb(device, actions: list) -> tuple:
|
||
"""
|
||
通过 ADB 设备顺序执行 AI 解析出的 action 列表。
|
||
返回 (success: bool, error: Optional[str])
|
||
"""
|
||
import asyncio
|
||
import time
|
||
for item in actions:
|
||
act = item.get("action") or item.get("action_type")
|
||
params = item.get("params") or {}
|
||
try:
|
||
if act == "open_app":
|
||
pkg = params.get("package", "")
|
||
if pkg:
|
||
device.start_app(pkg)
|
||
await asyncio.sleep(2)
|
||
elif act == "click":
|
||
x, y = params.get("x", 0), params.get("y", 0)
|
||
device.click(x, y)
|
||
await asyncio.sleep(0.3)
|
||
elif act == "swipe":
|
||
x1, y1 = params.get("x1", 540), params.get("y1", 1500)
|
||
x2, y2 = params.get("x2", 540), params.get("y2", 500)
|
||
duration = params.get("duration", 300)
|
||
device._shell(f"input swipe {x1} {y1} {x2} {y2} {duration}")
|
||
await asyncio.sleep(0.3)
|
||
elif act == "input_text":
|
||
text = params.get("text", "")
|
||
if text:
|
||
device.input_text(text)
|
||
await asyncio.sleep(0.2)
|
||
elif act == "key_event":
|
||
keycode = params.get("keycode", "KEYCODE_BACK")
|
||
device._shell(f"input keyevent {keycode}")
|
||
await asyncio.sleep(0.2)
|
||
elif act == "back":
|
||
device.press_key("back")
|
||
await asyncio.sleep(0.2)
|
||
elif act == "home":
|
||
device.press_key("home")
|
||
await asyncio.sleep(0.2)
|
||
elif act == "wait":
|
||
sec = params.get("seconds", 1)
|
||
await asyncio.sleep(max(0.5, min(sec, 10)))
|
||
except Exception as e:
|
||
logger.warning(f"ADB 执行 action 失败: {act} {params} -> {e}")
|
||
return False, str(e)
|
||
return True, None
|
||
|
||
|
||
async def _send_via_official_api(req: SendMessageRequest) -> dict:
|
||
"""通过官方API发送(抖音/闲鱼等开放平台 API 对接后启用)"""
|
||
platform_msg = {
|
||
Platform.DOUYIN: "抖音开放平台私信 API 暂未对接",
|
||
Platform.XIANYU: "闲鱼开放平台消息 API 暂未对接",
|
||
}
|
||
err = platform_msg.get(req.platform, "该平台官方API暂未实现")
|
||
logger.info(f"官方API通道: {req.platform.value} -> {err}")
|
||
return {"success": False, "error": err}
|
||
|
||
|
||
async def _send_via_hook(req: SendMessageRequest) -> dict:
|
||
"""
|
||
Hook 通道发送 — 优先走 Frida RPC,失败自动降级到 SDK/ADB 通道
|
||
|
||
路由策略:
|
||
1. 检查设备是否有活跃的 Frida Hook 会话
|
||
2. 有 → 通过 WebSocket 下发 hook_execute 指令(Agent 端 HookExecutor 执行)
|
||
3. 无 → 降级到 _send_via_sdk(ADB/u2 通道)
|
||
"""
|
||
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}")
|
||
|
||
mode = _get_device_mode(req.device_id)
|
||
|
||
if mode == "websocket":
|
||
try:
|
||
hook_params = {
|
||
"to_id": req.to_id,
|
||
"content": req.content,
|
||
"msg_type": req.msg_type.value if hasattr(req.msg_type, "value") else str(req.msg_type),
|
||
}
|
||
timeout = req.timeout_seconds or settings.MESSAGE_SEND_TIMEOUT
|
||
result = await ws_hub.send_command(
|
||
req.device_id,
|
||
{
|
||
"type": "execute",
|
||
"data": {
|
||
"script": req.platform.value,
|
||
"action": "send_message",
|
||
"params": hook_params,
|
||
"hook_only": True,
|
||
},
|
||
},
|
||
timeout=timeout,
|
||
)
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else {}
|
||
ok = result.get("code") == 200 and (
|
||
payload.get("success") is True or result.get("success") is True
|
||
)
|
||
if ok:
|
||
mid = payload.get("message_id") or result.get("message_id")
|
||
return {
|
||
"success": True,
|
||
"message_id": mid,
|
||
"channel_used": "websocket/frida",
|
||
}
|
||
err = payload.get("error") or result.get("message") or "WebSocket Hook 发送失败"
|
||
logger.warning(f"[_send_via_hook] WS Frida 失败: {result}")
|
||
if getattr(settings, "WECHAT_WS_HOOK_ONLY", True):
|
||
if _should_degrade_hook_send(err, result):
|
||
sdk_result = await _send_via_sdk(req)
|
||
sdk_result.setdefault(
|
||
"channel_used",
|
||
"websocket/hook(degraded_to_u2)" if sdk_result.get("success") else "websocket/hook+u2(failed)",
|
||
)
|
||
if not sdk_result.get("success"):
|
||
sdk_result.setdefault("hook_error", err)
|
||
return sdk_result
|
||
return {"success": False, "error": err, "channel_used": "websocket/hook(failed)"}
|
||
except Exception as e:
|
||
logger.warning(f"[_send_via_hook] Hook 通道异常: {e}")
|
||
if getattr(settings, "WECHAT_WS_HOOK_ONLY", True):
|
||
return {"success": False, "error": str(e), "channel_used": "websocket/hook(failed)"}
|
||
|
||
ws_hook_only = getattr(settings, "WECHAT_WS_HOOK_ONLY", True)
|
||
if ws_hook_only:
|
||
if mode != "websocket" or not ws_hub.is_online(req.device_id):
|
||
return {
|
||
"success": False,
|
||
"error": "WebSocket Agent 未在线(WECHAT_WS_HOOK_ONLY)",
|
||
"channel_used": "websocket/offline",
|
||
}
|
||
elif mode in ("adb", "websocket"):
|
||
adb_device = adb_manager.get_device(req.device_id)
|
||
device_serial = getattr(adb_device, "serial", req.device_id) if adb_device else req.device_id
|
||
try:
|
||
hook_result = await asyncio.get_running_loop().run_in_executor(
|
||
None,
|
||
lambda: _try_local_frida_hook(device_serial, req),
|
||
)
|
||
if hook_result and hook_result.get("success"):
|
||
hook_result["channel_used"] = "hook"
|
||
return hook_result
|
||
logger.warning(f"[_send_via_hook] 本地 Frida 失败: {hook_result}")
|
||
if getattr(settings, "WECHAT_BACKEND_ONLY", True):
|
||
err = (hook_result or {}).get("error") or "Frida Hook 发送失败"
|
||
return {"success": False, "error": err, "channel_used": "frida/hook(failed)"}
|
||
except Exception as e:
|
||
logger.warning(f"[_send_via_hook] 本地 Frida 异常: {e}")
|
||
if getattr(settings, "WECHAT_BACKEND_ONLY", True):
|
||
return {"success": False, "error": str(e), "channel_used": "frida/hook(failed)"}
|
||
|
||
if getattr(settings, "WECHAT_BACKEND_ONLY", True) and not ws_hook_only:
|
||
return {"success": False, "error": "微信后端模式要求 Frida Hook 可用", "channel_used": "none"}
|
||
|
||
if ws_hook_only:
|
||
return {
|
||
"success": False,
|
||
"error": "WebSocket Hook 发送失败",
|
||
"channel_used": "websocket/hook(failed)",
|
||
}
|
||
|
||
result = await _send_via_sdk(req)
|
||
if result.get("success"):
|
||
result["channel_used"] = "hook(degraded_to_sdk)"
|
||
return result
|
||
|
||
|
||
def _should_degrade_hook_send(error: str, result: dict) -> bool:
|
||
"""Hook 无法真实发送时,允许转到同一真机 Agent 的 u2 UI 兜底。"""
|
||
text = f"{error or ''} {result or ''}"
|
||
return any(
|
||
token in text
|
||
for token in (
|
||
"no_receiver_registered",
|
||
"接收端不可验证",
|
||
"sendMessage",
|
||
"RPC 返回 success=false",
|
||
)
|
||
)
|
||
|
||
|
||
# Frida 会话池:避免每条 API 反复 attach/detach 导致连跑验收失败
|
||
_frida_session_pool: dict = {}
|
||
|
||
|
||
def _load_phantom_frida_config() -> dict:
|
||
"""读取 anti_detect/phantom_frida_config.json(随机端口反检测 frida-server)"""
|
||
import json
|
||
import os
|
||
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
for rel in (
|
||
os.path.join("scripts", "anti_detect", "phantom_frida_config.json"),
|
||
os.path.join("sdk", "scripts", "anti_detect", "phantom_frida_config.json"),
|
||
):
|
||
path = os.path.join(base_dir, rel)
|
||
if os.path.isfile(path):
|
||
try:
|
||
with open(path, encoding="utf-8") as f:
|
||
cfg = json.load(f)
|
||
if cfg.get("listen_port"):
|
||
return cfg
|
||
except Exception:
|
||
pass
|
||
return {}
|
||
|
||
|
||
def _get_frida_manager(device_serial: str):
|
||
"""获取 FridaManager:优先 phantom/remote 随机端口,否则 usb"""
|
||
import sys, os, subprocess
|
||
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
candidate_dirs = [
|
||
os.path.join(base_dir, "agent"),
|
||
os.path.join(base_dir, "sdk", "agent"),
|
||
]
|
||
for agent_dir in candidate_dirs:
|
||
if os.path.isdir(agent_dir) and agent_dir not in sys.path:
|
||
sys.path.insert(0, agent_dir)
|
||
from hook.frida_manager import FridaManager
|
||
|
||
phantom = _load_phantom_frida_config()
|
||
if phantom.get("listen_port"):
|
||
port = int(phantom["listen_port"])
|
||
adb_serial = phantom.get("device_serial") or device_serial
|
||
try:
|
||
subprocess.run(
|
||
["adb", "-s", adb_serial, "forward", f"tcp:{port}", f"tcp:{port}"],
|
||
capture_output=True, timeout=5, check=False,
|
||
)
|
||
except Exception:
|
||
pass
|
||
return FridaManager(
|
||
device_serial=adb_serial,
|
||
mode="remote",
|
||
gadget_host="127.0.0.1",
|
||
gadget_port=port,
|
||
)
|
||
|
||
return FridaManager(device_serial=device_serial, mode="usb")
|
||
|
||
|
||
def _probe_device_frida(device_serial: str) -> dict:
|
||
"""探测设备 Frida Hook 能力:连接 → ping → 版本 → profile"""
|
||
import subprocess
|
||
probe = {
|
||
"supports_hook": False,
|
||
"frida_version": "",
|
||
"root_status": False,
|
||
"wechat_version": "",
|
||
"profile": {},
|
||
"hook_tests": {},
|
||
}
|
||
|
||
try:
|
||
r = subprocess.run(
|
||
["adb", "-s", device_serial, "shell", "su", "-c", "id"],
|
||
capture_output=True, text=True, timeout=5,
|
||
)
|
||
probe["root_status"] = "uid=0" in (r.stdout or "")
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
mgr = _get_frida_manager(device_serial)
|
||
if not mgr.start():
|
||
probe["hook_tests"]["connect"] = "failed"
|
||
return probe
|
||
|
||
probe["hook_tests"]["connect"] = "ok"
|
||
rpc = mgr.rpc
|
||
|
||
try:
|
||
pong = rpc.ping()
|
||
probe["hook_tests"]["ping"] = str(pong)[:200]
|
||
probe["supports_hook"] = True
|
||
except Exception as e:
|
||
probe["hook_tests"]["ping"] = str(e)[:200]
|
||
|
||
try:
|
||
ver = rpc.get_wechat_version()
|
||
probe["wechat_version"] = str(ver)
|
||
probe["hook_tests"]["wechat_version"] = str(ver)
|
||
except Exception as e:
|
||
probe["hook_tests"]["wechat_version"] = str(e)[:200]
|
||
|
||
try:
|
||
profile = rpc.get_profile({})
|
||
if isinstance(profile, dict):
|
||
probe["profile"] = profile.get("data", profile)
|
||
probe["hook_tests"]["profile"] = "ok"
|
||
except Exception as e:
|
||
probe["hook_tests"]["profile"] = str(e)[:200]
|
||
|
||
try:
|
||
import frida
|
||
probe["frida_version"] = frida.__version__
|
||
except Exception:
|
||
pass
|
||
|
||
mgr.stop()
|
||
except ImportError:
|
||
probe["hook_tests"]["connect"] = "frida not installed"
|
||
except Exception as e:
|
||
probe["hook_tests"]["connect"] = str(e)[:200]
|
||
|
||
return probe
|
||
|
||
|
||
async def _fetch_hook_data_via_ws(device_id: str, modules: list, limits: dict = None) -> dict:
|
||
"""通过在线 Agent WebSocket 批量获取 Hook 数据。"""
|
||
limits = limits or {}
|
||
contact_limit = _bounded_limit(limits.get("contact_limit"), DEFAULT_CONTACT_PULL_LIMIT, MAX_CONTACT_PULL_LIMIT)
|
||
message_limit = _bounded_limit(limits.get("message_limit"), DEFAULT_MESSAGE_PULL_LIMIT, MAX_MESSAGE_PULL_LIMIT)
|
||
contact_offset = _bounded_offset(limits.get("contact_offset"))
|
||
message_offset = _bounded_offset(limits.get("message_offset"))
|
||
module_map = {
|
||
"profile": ("get_profile", {}),
|
||
"contacts": ("get_contacts", {"limit": contact_limit, "offset": contact_offset}),
|
||
"groups": ("get_groups", {}),
|
||
"messages": ("get_messages", {"limit": message_limit, "offset": message_offset}),
|
||
"labels": ("get_tags", {}),
|
||
"tags": ("get_tags", {}),
|
||
"moments": ("get_moments", {"limit": 10}),
|
||
"accounts": ("get_official_accounts", {}),
|
||
"hook_status": ("get_hook_status", {}),
|
||
"device_info": ("get_device_info", {}),
|
||
"wechat_version": ("get_wechat_version", {}),
|
||
"process_info": ("get_process_info", {}),
|
||
"storage_info": ("get_storage_info", {}),
|
||
"network_info": ("get_network_info", {}),
|
||
"login_state": ("check_login_state", {}),
|
||
"favorites": ("get_favorites", {"limit": 20}),
|
||
}
|
||
|
||
async def fetch_one(module: str):
|
||
action_spec = module_map.get(module)
|
||
if not action_spec:
|
||
return module, {"success": False, "error": f"未知模块: {module}"}, False
|
||
action, params = action_spec
|
||
try:
|
||
result = await _execute_skill(
|
||
device_id,
|
||
"wechat",
|
||
action,
|
||
params,
|
||
timeout=45,
|
||
hook_only=True,
|
||
)
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else result
|
||
payload = payload if isinstance(payload, dict) else {"data": payload}
|
||
ok = result.get("code") == 200 and payload.get("success") is not False
|
||
if payload.get("success") is True or result.get("success") is True:
|
||
ok = True
|
||
payload.setdefault("action", action)
|
||
payload.setdefault("channel_used", result.get("_channel_used") or result.get("channel") or "websocket/hook")
|
||
return module, payload, ok
|
||
except Exception as exc:
|
||
return module, {"success": False, "error": str(exc)[:200], "action": action}, False
|
||
|
||
data = {}
|
||
ok_modules = []
|
||
requested = modules or list(module_map.keys())
|
||
results = await asyncio.gather(*(fetch_one(module) for module in requested))
|
||
for module, payload, ok in results:
|
||
data[module] = payload
|
||
if ok:
|
||
ok_modules.append(module)
|
||
|
||
return {
|
||
"success": bool(ok_modules),
|
||
"data": data,
|
||
"modules_fetched": ok_modules,
|
||
"modules_requested": requested,
|
||
"limits": {
|
||
"contact_limit": contact_limit,
|
||
"message_limit": message_limit,
|
||
"contact_offset": contact_offset,
|
||
"message_offset": message_offset,
|
||
},
|
||
"transport": "websocket",
|
||
"channel_used": "websocket/hook",
|
||
"partial_success": 0 < len(ok_modules) < len(requested),
|
||
}
|
||
|
||
|
||
def _fetch_hook_data(device_serial: str, modules: list, limits: dict = None) -> dict:
|
||
"""通过本地 Frida 批量获取 Hook 数据"""
|
||
import re
|
||
def to_snake(name):
|
||
return re.sub(r'(?<=[a-z0-9])([A-Z])', r'_\1', name).lower()
|
||
|
||
limits = limits or {}
|
||
contact_limit = _bounded_limit(limits.get("contact_limit"), DEFAULT_CONTACT_PULL_LIMIT, MAX_CONTACT_PULL_LIMIT)
|
||
message_limit = _bounded_limit(limits.get("message_limit"), DEFAULT_MESSAGE_PULL_LIMIT, MAX_MESSAGE_PULL_LIMIT)
|
||
contact_offset = _bounded_offset(limits.get("contact_offset"))
|
||
message_offset = _bounded_offset(limits.get("message_offset"))
|
||
MODULE_MAP = {
|
||
"profile": ("getProfile", {}),
|
||
"contacts": ("getContacts", {"limit": contact_limit, "offset": contact_offset}),
|
||
"groups": ("getGroups", {}),
|
||
"messages": ("getMessages", {"limit": message_limit, "offset": message_offset}),
|
||
"labels": ("getLabels", {}),
|
||
"moments": ("getMoments", {"limit": 10}),
|
||
"accounts": ("getOfficialAccounts", {}),
|
||
"hook_status": ("getHookStatus", {}),
|
||
"device_info": ("getDeviceInfo", None),
|
||
"wechat_version": ("getWechatVersion", None),
|
||
"process_info": ("getProcessInfo", {}),
|
||
"storage_info": ("getStorageInfo", {}),
|
||
"network_info": ("getNetworkInfo", {}),
|
||
"login_state": ("checkLoginState", {}),
|
||
"favorites": ("getFavorites", {"limit": 20}),
|
||
}
|
||
|
||
data = {}
|
||
try:
|
||
mgr = _get_frida_manager(device_serial)
|
||
if not mgr.start():
|
||
return {"success": False, "error": "Frida 连接失败", "data": {}}
|
||
|
||
rpc = mgr.rpc
|
||
for mod in modules:
|
||
if mod not in MODULE_MAP:
|
||
data[mod] = {"error": f"未知模块: {mod}"}
|
||
continue
|
||
camel_name, params = MODULE_MAP[mod]
|
||
snake_name = to_snake(camel_name)
|
||
try:
|
||
fn = getattr(rpc, snake_name, None)
|
||
if fn is None:
|
||
data[mod] = {"error": f"RPC 方法不存在: {snake_name}"}
|
||
continue
|
||
resp = fn(params) if params is not None else fn()
|
||
data[mod] = resp if isinstance(resp, dict) else {"data": resp}
|
||
except Exception as e:
|
||
data[mod] = {"error": str(e)[:200]}
|
||
|
||
mgr.stop()
|
||
return {
|
||
"success": True,
|
||
"data": data,
|
||
"modules_fetched": list(data.keys()),
|
||
"limits": {
|
||
"contact_limit": contact_limit,
|
||
"message_limit": message_limit,
|
||
"contact_offset": contact_offset,
|
||
"message_offset": message_offset,
|
||
},
|
||
}
|
||
except ImportError:
|
||
return {"success": False, "error": "frida 未安装", "data": {}}
|
||
except Exception as e:
|
||
return {"success": False, "error": str(e)[:200], "data": data}
|
||
|
||
|
||
def _get_frida_session(device_serial: str):
|
||
"""获取或复用已连接的 FridaManager(矩阵/E2E 连跑稳定性)"""
|
||
import time
|
||
mgr = _frida_session_pool.get(device_serial)
|
||
if mgr is not None and getattr(mgr, "connected", False):
|
||
return mgr
|
||
for attempt in range(3):
|
||
mgr = _get_frida_manager(device_serial)
|
||
if mgr.start():
|
||
_frida_session_pool[device_serial] = mgr
|
||
return mgr
|
||
time.sleep(0.8 * (attempt + 1))
|
||
_frida_session_pool.pop(device_serial, None)
|
||
return None
|
||
|
||
|
||
def _try_local_frida_action(device_serial: str, action: str, params: dict) -> dict:
|
||
"""任意 action 走本地 Frida + HookExecutor。action 名与 hook_executor.ACTION_TO_RPC 一致。"""
|
||
try:
|
||
mgr = _get_frida_session(device_serial)
|
||
if not mgr:
|
||
return {"success": False, "error": "Frida 连接失败"}
|
||
import sys, os
|
||
agent_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "agent")
|
||
if agent_dir not in sys.path:
|
||
sys.path.insert(0, agent_dir)
|
||
from hook.hook_executor import HookExecutor
|
||
executor = HookExecutor(mgr)
|
||
norm = dict(params or {})
|
||
if action == "send_message" and "msg_type" not in norm:
|
||
norm["msg_type"] = "text"
|
||
result = executor.execute(action, norm)
|
||
return result
|
||
except ImportError:
|
||
return {"success": False, "error": "frida 未安装"}
|
||
except Exception as e:
|
||
_frida_session_pool.pop(device_serial, None)
|
||
return {"success": False, "error": str(e)}
|
||
|
||
|
||
def _try_local_frida_hook(device_serial: str, req) -> dict:
|
||
"""尝试通过本地 Frida 直连设备执行 Hook(ADB 模式专用)"""
|
||
try:
|
||
mgr = _get_frida_session(device_serial)
|
||
if not mgr:
|
||
return {"success": False, "error": "Frida 连接失败"}
|
||
|
||
import sys, os
|
||
agent_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "agent")
|
||
if agent_dir not in sys.path:
|
||
sys.path.insert(0, agent_dir)
|
||
from hook.hook_executor import HookExecutor
|
||
|
||
executor = HookExecutor(mgr)
|
||
result = executor.execute("send_message", {
|
||
"to_id": req.to_id,
|
||
"content": req.content,
|
||
"msg_type": req.msg_type.value if hasattr(req.msg_type, "value") else str(req.msg_type),
|
||
})
|
||
return result
|
||
except ImportError:
|
||
return {"success": False, "error": "frida 未安装"}
|
||
except Exception as e:
|
||
return {"success": False, "error": str(e)}
|
||
|
||
|
||
async def _send_via_sdk(req: SendMessageRequest) -> dict:
|
||
"""通过SDK控制发送(WebSocket或ADB);超时由请求 timeout_seconds 或 MESSAGE_SEND_TIMEOUT 控制"""
|
||
params = {
|
||
"to_id": req.to_id,
|
||
"content": req.content,
|
||
"msg_type": req.msg_type.value
|
||
}
|
||
if req.media_url:
|
||
params["media_url"] = req.media_url
|
||
if req.at_list:
|
||
params["at_list"] = req.at_list
|
||
|
||
timeout = req.timeout_seconds if req.timeout_seconds is not None and req.timeout_seconds > 0 else (getattr(settings, "MESSAGE_SEND_TIMEOUT", 60) or 60)
|
||
mode = _get_device_mode(req.device_id)
|
||
forced = (req.channel or "").strip().lower()
|
||
# 显式 sdk_control 时优先走 ADB 引擎(避免僵尸 WS 占通道导致 60s 超时)
|
||
if forced == "sdk_control":
|
||
adb_dev = adb_manager.get_device(req.device_id)
|
||
if adb_dev and adb_dev.is_online():
|
||
mode = "adb"
|
||
timeout = max(timeout, 90)
|
||
|
||
if mode == "websocket":
|
||
logger.info(f"[_send_via_sdk] 下发 execute send_message timeout={timeout}s")
|
||
result = await ws_hub.send_command(req.device_id, {
|
||
"type": "execute",
|
||
"data": {
|
||
"script": req.platform.value,
|
||
"action": "send_message",
|
||
"params": params
|
||
}
|
||
}, timeout=timeout)
|
||
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else {}
|
||
success = payload.get("success") is True or result.get("success") is True
|
||
if result.get("code") == 200 and success:
|
||
return {
|
||
"success": True,
|
||
"message_id": payload.get("message_id") or result.get("message_id"),
|
||
"channel_used": f"websocket/{result.get('channel') or payload.get('channel') or 'u2'}",
|
||
}
|
||
err = payload.get("error") or payload.get("frida_error") or result.get("message", "发送失败")
|
||
if result.get("code") == 408:
|
||
err = "timeout"
|
||
logger.warning(f"[_send_via_sdk] 设备响应超时 device_id={req.device_id} to_id={req.to_id}")
|
||
return {
|
||
"success": False,
|
||
"error": err,
|
||
"channel_used": f"websocket/{result.get('channel') or payload.get('channel') or 'failed'}",
|
||
}
|
||
|
||
elif mode == "adb":
|
||
adb_device = adb_manager.get_device(req.device_id)
|
||
if not adb_device:
|
||
return {"success": False, "error": "ADB设备不可用"}
|
||
return await _execute_via_adb(adb_device, req.platform.value, "send_message", params)
|
||
|
||
return {"success": False, "error": "设备不在线"}
|
||
|
||
|
||
async def _send_via_agent(req: SendMessageRequest) -> dict:
|
||
"""通过AI Agent发送:WebSocket 下发 agent_execute;ADB 模式由服务端解析后执行 action 序列"""
|
||
platform_names = {
|
||
Platform.WECHAT: "微信",
|
||
Platform.DOUYIN: "抖音",
|
||
Platform.XHS: "小红书",
|
||
Platform.XIANYU: "闲鱼",
|
||
Platform.SOUL: "Soul"
|
||
}
|
||
task = f"打开{platform_names.get(req.platform, req.platform.value)},找到联系人'{req.to_id}',发送消息:{req.content}"
|
||
mode = _get_device_mode(req.device_id)
|
||
|
||
if mode == "websocket":
|
||
# 设备端已有 agent_execute 处理,直接下发任务
|
||
result = await ws_hub.send_command(req.device_id, {
|
||
"type": "agent_execute",
|
||
"data": {"task": task, "llm_provider": "deepseek", "max_steps": 30}
|
||
}, timeout=120)
|
||
if result.get("code") == 200 and result.get("data"):
|
||
return {
|
||
"success": True,
|
||
"message_id": result.get("data", {}).get("message_id") or f"agent_{int(__import__('time').time() * 1000)}"
|
||
}
|
||
return {"success": False, "error": result.get("message", "AI Agent 执行失败")}
|
||
|
||
if mode == "adb":
|
||
# ADB 模式:服务端解析意图后通过 ADB 执行 action 序列
|
||
parse_result = await ai_agent_service.process_voice_command(task, req.device_id)
|
||
if not parse_result.get("success") or not parse_result.get("actions"):
|
||
return {"success": False, "error": parse_result.get("message", "无法解析指令")}
|
||
adb_device = adb_manager.get_device(req.device_id)
|
||
if not adb_device or not adb_device.is_online():
|
||
return {"success": False, "error": "ADB 设备不可用"}
|
||
ok, err = await _execute_actions_via_adb(adb_device, parse_result["actions"])
|
||
if ok:
|
||
return {"success": True, "message_id": f"agent_adb_{int(__import__('time').time() * 1000)}"}
|
||
return {"success": False, "error": err or "ADB 执行失败"}
|
||
|
||
return {"success": False, "error": "设备离线"}
|
||
|
||
|
||
# =============================================================================
|
||
# 三十、自动注册 & 设备初始化
|
||
# =============================================================================
|
||
|
||
class AutoRegisterRequest(BaseModel):
|
||
"""自动注册请求"""
|
||
device_id: str
|
||
nickname: str = "卡若AI"
|
||
password: str = ""
|
||
test_msg_to: str = ""
|
||
test_msg_content: str = "你好,我是卡若AI工作手机"
|
||
|
||
|
||
class CheckLoginStateRequest(BaseModel):
|
||
"""检查登录状态请求"""
|
||
device_id: str
|
||
|
||
|
||
class GetSimPhoneRequest(BaseModel):
|
||
"""获取 SIM 卡手机号请求"""
|
||
device_id: str
|
||
|
||
|
||
@router.post("/auto-register/full", response_model=dict, tags=["自动注册"])
|
||
async def auto_register_full(req: AutoRegisterRequest):
|
||
"""
|
||
全自动微信注册 — 一键完成
|
||
|
||
流程:
|
||
1. 检测微信登录状态
|
||
2. 未登录 → 自动从 SIM 获取手机号 → 注册
|
||
3. 自动读取短信验证码 → 填写
|
||
4. 设置昵称/密码 → 完成注册
|
||
5. 可选:发送测试消息
|
||
"""
|
||
from services.auto_register import run_auto_register
|
||
result = await run_auto_register(
|
||
device_id=req.device_id,
|
||
nickname=req.nickname,
|
||
password=req.password,
|
||
test_msg_to=req.test_msg_to,
|
||
test_msg_content=req.test_msg_content,
|
||
)
|
||
return {"code": 200, "data": result}
|
||
|
||
|
||
@router.post("/auto-register/check-state", response_model=dict, tags=["自动注册"])
|
||
async def check_wechat_login_state(req: CheckLoginStateRequest):
|
||
"""检查微信登录状态(已登录/登录页/注册页/未安装)"""
|
||
from services.auto_register import WeChatAutoRegister
|
||
from services.adb_device import adb_manager
|
||
|
||
adb_device = adb_manager.get_device(req.device_id)
|
||
if not adb_device:
|
||
return {"code": 503, "data": {"error": f"设备不可用: {req.device_id}"}}
|
||
|
||
engine = WeChatAutoRegister(adb_device)
|
||
loop = asyncio.get_running_loop()
|
||
state = await loop.run_in_executor(None, engine.check_wechat_login_state)
|
||
return {"code": 200, "data": state}
|
||
|
||
|
||
@router.post("/auto-register/get-sim-phone", response_model=dict, tags=["自动注册"])
|
||
async def get_sim_phone(req: GetSimPhoneRequest):
|
||
"""从设备 SIM 卡获取手机号"""
|
||
from services.auto_register import WeChatAutoRegister
|
||
from services.adb_device import adb_manager
|
||
|
||
adb_device = adb_manager.get_device(req.device_id)
|
||
if not adb_device:
|
||
return {"code": 503, "data": {"error": f"设备不可用: {req.device_id}"}}
|
||
|
||
engine = WeChatAutoRegister(adb_device)
|
||
loop = asyncio.get_running_loop()
|
||
sim_info = await loop.run_in_executor(None, engine.get_sim_phone_number)
|
||
return {"code": 200, "data": sim_info}
|
||
|
||
|
||
@router.post("/account/wechat/check-login-state", response_model=dict, tags=["注册/登录"])
|
||
async def wechat_check_login_state_agent(device_id: str, platform: Platform):
|
||
"""设备端 u2 检测微信是否已登录"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(device_id, platform.value, "check_login_state", {})
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) and result.get("data") else result
|
||
code = result.get("code", 200)
|
||
return {"code": code, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
@router.post("/account/wechat/login-by-password", response_model=dict, tags=["注册/登录"])
|
||
async def wechat_login_by_password(
|
||
device_id: str,
|
||
platform: Platform,
|
||
account: str = "",
|
||
password: str = "",
|
||
phone: str = "",
|
||
):
|
||
"""微信号/手机号 + 密码登录(u2 自动化)"""
|
||
_check_device_online(device_id)
|
||
params = {
|
||
"account": account or phone,
|
||
"phone": phone,
|
||
"password": password,
|
||
}
|
||
result = await _execute_skill(
|
||
device_id, platform.value, "login_by_password", params, timeout=120
|
||
)
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) and result.get("data") else result
|
||
code = result.get("code", 200)
|
||
return {"code": code, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
@router.post("/account/wechat/ensure-login", response_model=dict, tags=["注册/登录"])
|
||
async def wechat_ensure_login(
|
||
device_id: str,
|
||
platform: Platform,
|
||
account: str = "",
|
||
password: str = "",
|
||
phone: str = "",
|
||
):
|
||
"""未登录则按参数或 sdk/config/wechat_login.yaml 自动登录"""
|
||
_check_device_online(device_id)
|
||
params = {
|
||
"account": account or phone,
|
||
"phone": phone,
|
||
"password": password,
|
||
}
|
||
result = await _execute_skill(
|
||
device_id, platform.value, "ensure_logged_in", params, timeout=120
|
||
)
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) and result.get("data") else result
|
||
code = result.get("code", 200)
|
||
return {"code": code, "data": payload, "channel_used": result.get("_channel_used", "sdk_control")}
|
||
|
||
|
||
# ========== AF12: 风控中心监控 ==========
|
||
|
||
|
||
@router.get("/anti-ban/dashboard")
|
||
async def anti_ban_dashboard():
|
||
"""防封风控中心:汇总全局限流/账号生命周期/指纹碰撞状态"""
|
||
import time as _time
|
||
from services.rate_limiter import rate_limiter as _rl
|
||
|
||
try:
|
||
devices = await device_manager.get_all_devices(limit=500)
|
||
except Exception:
|
||
devices = []
|
||
|
||
online_ws = {d["device_id"]: d for d in ws_hub.get_online_devices()}
|
||
from services.adb_device import adb_manager as _adb
|
||
adb_serials = _adb.scan_devices()
|
||
seen = {d.get("device_id") for d in devices}
|
||
for did, info in online_ws.items():
|
||
if did not in seen:
|
||
devices.append({**info, "status": "online"})
|
||
seen.add(did)
|
||
for serial in adb_serials:
|
||
if serial not in seen:
|
||
devices.append({"device_id": serial, "status": "adb"})
|
||
seen.add(serial)
|
||
|
||
total_devices = len(devices)
|
||
online = sum(1 for d in devices if d.get("status") in ("online", "adb"))
|
||
|
||
seen_fp: dict = {}
|
||
fp_collision_devices = []
|
||
for d in devices:
|
||
fp = d.get("fingerprint_hash", "")
|
||
if not fp:
|
||
continue
|
||
did = d.get("device_id", "")
|
||
seen_fp.setdefault(fp, []).append(did)
|
||
for fp, dids in seen_fp.items():
|
||
if len(dids) > 1:
|
||
for did in dids:
|
||
fp_collision_devices.append({
|
||
"device_id": did,
|
||
"collided_with": [x for x in dids if x != did],
|
||
})
|
||
|
||
lifecycle_summary = []
|
||
for d in devices[:50]:
|
||
did = d.get("device_id", "")
|
||
plat = d.get("platform", "wechat")
|
||
try:
|
||
phase_info = await account_lifecycle.get_phase(did, plat)
|
||
rules = await account_lifecycle.get_rules(did, plat)
|
||
except Exception:
|
||
phase_info = "unknown"
|
||
rules = {}
|
||
lifecycle_summary.append({
|
||
"device_id": did,
|
||
"phase": phase_info.value if hasattr(phase_info, "value") else str(phase_info),
|
||
"max_daily_add_friend": rules.get("max_daily_add_friend", "N/A"),
|
||
"max_daily_message": rules.get("max_daily_send_message", "N/A"),
|
||
})
|
||
|
||
return {
|
||
"timestamp": int(_time.time()),
|
||
"devices": {"total": total_devices, "online": online},
|
||
"fingerprint_collisions": fp_collision_devices,
|
||
"account_lifecycle": lifecycle_summary,
|
||
}
|
||
|
||
|
||
@router.get("/anti-ban/device/{device_id}")
|
||
async def anti_ban_device_detail(device_id: str):
|
||
"""单设备防封详情"""
|
||
device = await device_manager.get_device(device_id)
|
||
if not device:
|
||
raise HTTPException(status_code=404, detail="设备不存在")
|
||
|
||
plat = device.get("platform", "wechat")
|
||
phase = await account_lifecycle.get_phase(device_id, plat)
|
||
rules = await account_lifecycle.get_rules(device_id, plat)
|
||
fp = device.get("fingerprint_hash", "")
|
||
|
||
collision_result = {"collision": False, "collided_with": []}
|
||
if fp and device_manager.db:
|
||
collision_result = await device_manager.check_fingerprint_collision(
|
||
device_id, device.get("fingerprint_info", {})
|
||
)
|
||
|
||
return {
|
||
"device_id": device_id,
|
||
"status": device.get("status", "unknown"),
|
||
"fingerprint_hash": fp,
|
||
"fingerprint_collision": collision_result,
|
||
"account_phase": phase.value if hasattr(phase, "value") else str(phase),
|
||
"phase_rules": rules,
|
||
"last_heartbeat": str(device.get("last_heartbeat", "")),
|
||
}
|