7772 lines
317 KiB
Python
7772 lines
317 KiB
Python
"""
|
||
工作手机SDK v3.0 - 统一接口路由
|
||
存客宝重点对接的接口
|
||
|
||
功能模块:
|
||
1. 消息管理 - 发送/获取消息
|
||
2. 好友管理 - 添加/通过好友请求
|
||
3. 群聊管理 - 创建群/邀请入群/群发消息
|
||
4. 标签管理 - 添加/删除/查询标签
|
||
5. 朋友圈管理 - 发布/点赞/评论
|
||
6. 联系人管理 - 获取联系人列表
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from fastapi import APIRouter, HTTPException, Body, Response
|
||
from typing import Any, Dict, Optional, List, Literal
|
||
from pydantic import BaseModel, Field
|
||
from enum import Enum
|
||
import asyncio
|
||
import uuid
|
||
import logging
|
||
import os
|
||
import random
|
||
import re
|
||
import time
|
||
import base64
|
||
import io
|
||
import hashlib
|
||
import json
|
||
import subprocess
|
||
import tempfile
|
||
|
||
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
|
||
import qrcode
|
||
from services.device_id_util import device_id_md5
|
||
from services.moments_idempotency import (
|
||
build_moments_idempotency_key,
|
||
moments_idempotency,
|
||
)
|
||
|
||
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
|
||
MAX_QR_IMAGE_BYTES = 8 * 1024 * 1024
|
||
|
||
|
||
def _decode_qr_image_base64(image_base64: str) -> Dict[str, Any]:
|
||
"""解码调用方提交的二维码图片;只返回原始二维码内容与可审计元数据。"""
|
||
encoded = str(image_base64 or "").strip()
|
||
if encoded.startswith("data:"):
|
||
encoded = encoded.split(",", 1)[1] if "," in encoded else ""
|
||
if not encoded:
|
||
return {"success": False, "error_code": "qr_image_base64_required"}
|
||
try:
|
||
raw = base64.b64decode(encoded, validate=True)
|
||
except Exception as exc:
|
||
return {"success": False, "error_code": "qr_image_base64_invalid", "error_message": str(exc)}
|
||
if not raw or len(raw) > MAX_QR_IMAGE_BYTES:
|
||
return {
|
||
"success": False,
|
||
"error_code": "qr_image_size_invalid",
|
||
"image_bytes": len(raw),
|
||
"max_image_bytes": MAX_QR_IMAGE_BYTES,
|
||
}
|
||
path = ""
|
||
try:
|
||
with tempfile.NamedTemporaryFile(prefix="workphone_qr_", suffix=".img", delete=False) as fp:
|
||
fp.write(raw)
|
||
path = fp.name
|
||
completed = subprocess.run(
|
||
["zbarimg", "--quiet", "--raw", path],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=10,
|
||
check=False,
|
||
)
|
||
decoded_values = [line.strip() for line in completed.stdout.splitlines() if line.strip()]
|
||
if len(decoded_values) != 1:
|
||
return {
|
||
"success": False,
|
||
"error_code": "qr_decode_not_single",
|
||
"decoded_count": len(decoded_values),
|
||
"decoder_stderr": completed.stderr.strip(),
|
||
}
|
||
qr_content = decoded_values[0]
|
||
return {
|
||
"success": True,
|
||
"verified": True,
|
||
"qr_content": qr_content,
|
||
"qr_payload_type": "url" if re.match(r"^https?://", qr_content, re.I) else "weixin_uri" if qr_content.lower().startswith("weixin://") else "text",
|
||
"decoder": "zbarimg",
|
||
"image_bytes": len(raw),
|
||
"image_sha256": hashlib.sha256(raw).hexdigest(),
|
||
"decoded_count": 1,
|
||
"source": "image_base64",
|
||
}
|
||
except FileNotFoundError:
|
||
return {"success": False, "error_code": "qr_decoder_not_installed"}
|
||
except subprocess.TimeoutExpired:
|
||
return {"success": False, "error_code": "qr_decode_timeout"}
|
||
finally:
|
||
if path:
|
||
try:
|
||
os.unlink(path)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def _generate_wechat_contact_qr_from_wxid(wxid: str) -> Dict[str, Any]:
|
||
"""按公开 wxid 联系人 scheme 生成二维码,并立即本地解码核查。"""
|
||
cleaned = str(wxid or "").strip()
|
||
if not cleaned:
|
||
return {"success": False, "verified": False, "error": "wxid_required"}
|
||
qr_content = f"weixin://contacts/profile/{cleaned}"
|
||
img = qrcode.QRCode(
|
||
version=None,
|
||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||
box_size=10,
|
||
border=4,
|
||
)
|
||
img.add_data(qr_content)
|
||
img.make(fit=True)
|
||
pil_img = img.make_image(fill_color="black", back_color="white").convert("RGB")
|
||
buf = io.BytesIO()
|
||
pil_img.save(buf, format="PNG")
|
||
raw = buf.getvalue()
|
||
decoded_values: List[str] = []
|
||
try:
|
||
from pyzbar.pyzbar import decode as _qr_decode
|
||
decoded_values = [x.data.decode("utf-8", "replace") for x in _qr_decode(pil_img)]
|
||
except Exception as exc:
|
||
decoded_values = []
|
||
decode_error = str(exc)
|
||
else:
|
||
decode_error = ""
|
||
local_decoded_ok = bool(decoded_values and decoded_values[0] == qr_content)
|
||
return {
|
||
"success": False,
|
||
"verified": False,
|
||
"local_qr_decoded": local_decoded_ok,
|
||
"wechat_scan_verified": False,
|
||
"status": "wechat_scan_unverified",
|
||
"method": "wxid_contact_scheme_qr",
|
||
"source": "github_public_scheme",
|
||
"wxid": cleaned,
|
||
"qr_content": qr_content,
|
||
"qr_payload_type": "weixin_uri",
|
||
"qr_image_base64": base64.b64encode(raw).decode("ascii"),
|
||
"image_format": "PNG",
|
||
"image_bytes": len(raw),
|
||
"decoded_count": len(decoded_values),
|
||
"decoded_values": decoded_values,
|
||
"decode_error": decode_error,
|
||
"error": "wechat_scan_not_verified",
|
||
"error_message": "该wxid scheme二维码只能证明图片可解码,不能证明微信扫一扫可跳转;按用户真机反馈标记为未通过微信扫码验证。",
|
||
"references": [
|
||
"https://github.com/Zdelta/Wechat_ID_To_Contact",
|
||
"https://github.com/jamloveu/WXID-QRCode/blob/master/index.html",
|
||
"https://zdelta.github.io/Wechat_ID_To_Contact/",
|
||
],
|
||
}
|
||
|
||
|
||
# ========== 枚举定义 ==========
|
||
|
||
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 = Field(..., min_length=1, description="工作手机设备ID", examples=["xgfe65eimrrofyws"])
|
||
platform: Platform = Field(..., description="目标平台;微信传 wechat", examples=["wechat"])
|
||
to_id: str = Field(..., min_length=1, description="接收方wxid、微信号、昵称或备注名", examples=["filehelper"])
|
||
content: str = Field(..., min_length=1, description="消息正文", examples=["消息接口联调成功"])
|
||
msg_type: MessageType = Field(MessageType.TEXT, description="消息类型;文本消息传text")
|
||
media_url: Optional[str] = Field(None, description="图片、视频或文件资源地址")
|
||
at_list: Optional[List[str]] = Field(None, description="群消息需要@的wxid列表")
|
||
timeout_seconds: Optional[int] = Field(None, ge=5, le=180, description="本次调用超时秒数")
|
||
channel: Optional[str] = Field(
|
||
None,
|
||
description="指定执行通道:hook/sdk_control/ai_agent/official_api;留空自动选择",
|
||
examples=["hook"],
|
||
)
|
||
hook_config: Optional[dict] = Field(None, description="Hook扩展配置")
|
||
|
||
|
||
class SendMessageResponse(BaseModel):
|
||
"""兼容旧客户端的发送消息结果。"""
|
||
success: bool
|
||
message_id: Optional[str] = None
|
||
channel_used: str
|
||
error: Optional[str] = None
|
||
|
||
|
||
class GetMessagesRequest(BaseModel):
|
||
"""获取消息请求。"""
|
||
device_id: str = Field(..., min_length=1, description="工作手机设备ID", examples=["xgfe65eimrrofyws"])
|
||
platform: Platform = Field(..., description="目标平台", examples=["wechat"])
|
||
conversation_id: Optional[str] = Field(None, description="会话wxid、微信号、昵称或备注名")
|
||
limit: int = Field(DEFAULT_MESSAGE_PULL_LIMIT, ge=1, le=MAX_MESSAGE_PULL_LIMIT, description="本次最多返回条数")
|
||
offset: int = Field(0, ge=0, description="分页偏移量")
|
||
since_time: Optional[int] = Field(None, ge=0, description="兼容字段;增量同步请使用/message/sync-since")
|
||
|
||
|
||
class MessageFeedback(BaseModel):
|
||
"""消息接口统一反馈。"""
|
||
level: str = Field(..., description="反馈级别:success/warning/error")
|
||
title: str = Field(..., description="反馈标题")
|
||
message: str = Field(..., description="可直接展示的反馈说明")
|
||
action: str = Field(..., description="下一动作:none/readback/retry/check_target/check_device/check_hook")
|
||
|
||
|
||
class MessageOperationResult(BaseModel):
|
||
"""发送、转发、撤回、名片、语音和评论操作的统一结果。"""
|
||
success: bool = Field(..., description="业务动作是否成功")
|
||
verified: bool = Field(..., description="结果是否有真实回执支撑")
|
||
status: str = Field(..., description="verified/accepted_unverified/validation_failed/device_offline/hook_unavailable/timeout/operation_failed")
|
||
action: str = Field(..., description="机擎AI执行动作")
|
||
target_id: str = Field("", description="消息接收方或业务目标")
|
||
message_id: Optional[str] = Field(None, description="真实消息ID;执行端未返回时为空")
|
||
error_code: str = Field("", description="机器可读错误码")
|
||
error_message: str = Field("", description="错误说明")
|
||
retryable: bool = Field(False, description="是否适合自动重试")
|
||
retry_after_seconds: float = Field(0, description="建议等待秒数")
|
||
channel_used: str = Field(..., description="真实执行通道")
|
||
trace_id: str = Field("", description="跨服务追踪ID")
|
||
raw_rpc_receipt: Optional[Any] = Field(None, description="Frida RPC原始回执")
|
||
readback: Optional[Any] = Field(None, description="消息或数据库业务回读")
|
||
|
||
class Config:
|
||
extra = "allow"
|
||
|
||
|
||
class MessageOperationResponse(BaseModel):
|
||
"""消息写操作统一响应。"""
|
||
code: int = Field(..., description="业务响应码;失败保留SDK原始语义")
|
||
success: bool = Field(..., description="顶层业务成功标记")
|
||
data: MessageOperationResult
|
||
feedback: MessageFeedback
|
||
channel_used: str
|
||
trace_id: str = ""
|
||
|
||
|
||
# ========== WP-HL-04 微信无界面动作模型 ==========
|
||
|
||
WP_HL04_ACTIONS = (
|
||
"account_status", "add_tag", "add_to_favorites", "appeal_restriction",
|
||
"clear_history", "comment_video", "create_tag", "delete_tag",
|
||
"do_not_disturb", "follow_video_creator", "forward_moments_link",
|
||
"get_friend_info", "get_steps", "get_video_list", "like_steps",
|
||
"like_video", "open_miniprogram", "recall_message", "remove_tag",
|
||
"safety_center", "send_file_from_chat", "set_chat_top", "set_gender",
|
||
"set_group_notice", "set_moments_cover", "set_moments_privacy",
|
||
"set_mute_chat", "set_remark", "share_video", "show_my_qr",
|
||
"toggle_do_not_disturb", "top_stories", "unblock_account",
|
||
"unblock_appeal", "unblock_with_sms", "view_transactions", "view_wallet",
|
||
"wechat_search",
|
||
)
|
||
|
||
|
||
class WP_HL04ActionParams(BaseModel):
|
||
"""38项未映射动作共用的严格参数面;未使用字段保持null,不拼接UI参数。"""
|
||
|
||
user_id: Optional[str] = Field(None, description="微信用户wxid、微信号或联系人标识")
|
||
target_id: Optional[str] = Field(None, description="业务目标标识")
|
||
group_id: Optional[str] = Field(None, description="微信群标识")
|
||
message_id: Optional[str] = Field(None, description="消息或朋友圈消息标识")
|
||
index: Optional[int] = Field(None, ge=0, description="视频号内容索引")
|
||
limit: Optional[int] = Field(None, ge=1, le=500, description="读取条数")
|
||
offset: Optional[int] = Field(None, ge=0, description="读取偏移量")
|
||
comment: Optional[str] = Field(None, max_length=2000, description="视频号评论正文")
|
||
content: Optional[str] = Field(None, max_length=10000, description="动作正文或公告内容")
|
||
keyword: Optional[str] = Field(None, max_length=200, description="搜索关键词")
|
||
to_id: Optional[str] = Field(None, description="分享或转发目标标识")
|
||
tag_id: Optional[str] = Field(None, description="标签标识")
|
||
tag_name: Optional[str] = Field(None, max_length=64, description="标签名称")
|
||
remark: Optional[str] = Field(None, max_length=128, description="联系人备注")
|
||
phone: Optional[str] = Field(None, max_length=32, description="短信验证手机号")
|
||
file_path: Optional[str] = Field(None, description="设备端文件路径")
|
||
url: Optional[str] = Field(None, description="链接或小程序路径")
|
||
value: Optional[Any] = Field(None, description="设置类动作值")
|
||
|
||
class Config:
|
||
extra = "allow"
|
||
|
||
|
||
class WP_HL04ActionRequest(BaseModel):
|
||
"""WP-HL-04统一动作入口;默认演练,真实写入必须显式confirm。"""
|
||
|
||
device_id: str = Field(..., min_length=1, examples=["DEVICE_ID"])
|
||
platform: Platform = Field(Platform.WECHAT, description="WP-HL-04固定为wechat")
|
||
action: Literal[tuple(WP_HL04_ACTIONS)] = Field(..., description="38项动作名")
|
||
params: WP_HL04ActionParams = Field(default_factory=WP_HL04ActionParams)
|
||
trace_id: Optional[str] = Field(None, description="跨服务追踪ID;缺省自动生成")
|
||
dry_run: bool = Field(True, description="默认只校验参数和链路,不执行微信写入")
|
||
confirm: bool = Field(False, description="真实写入确认;需dry_run=false且confirm=true")
|
||
|
||
|
||
class HeadlessActionData(BaseModel):
|
||
success: bool
|
||
verified: bool = False
|
||
status: str
|
||
action: str
|
||
target_id: str = ""
|
||
trace_id: str
|
||
error_code: str = ""
|
||
error_message: str = ""
|
||
retryable: bool = False
|
||
retry_after_seconds: float = 0
|
||
channel_used: str
|
||
raw_rpc_receipt: Optional[Any] = None
|
||
readback: Optional[Any] = None
|
||
|
||
class Config:
|
||
extra = "allow"
|
||
|
||
|
||
class HeadlessActionResponse(BaseModel):
|
||
code: int
|
||
success: bool
|
||
data: HeadlessActionData
|
||
feedback: MessageFeedback
|
||
channel_used: str
|
||
trace_id: str
|
||
|
||
|
||
# ========== WP-FG-01 好友/群无界面首批模型 ==========
|
||
|
||
FRIEND_GROUP_FIRST_ACTIONS = (
|
||
"add_friend", "accept_friend", "set_friend_remark", "delete_friend",
|
||
"create_group", "invite_to_group", "remove_from_group",
|
||
"set_group_notice", "set_group_name", "send_group_message",
|
||
)
|
||
|
||
|
||
class FriendGroupActionParams(BaseModel):
|
||
"""好友/群首批统一参数;字段按action选择,服务端做必填校验后再下发Frida。"""
|
||
|
||
user_id: Optional[str] = Field(None, min_length=1, description="好友wxid或微信标识")
|
||
message: Optional[str] = Field(None, max_length=2000, description="好友验证消息")
|
||
source_type: Optional[int] = Field(None, ge=1, le=99, description="微信好友来源场景值")
|
||
encrypt_username: Optional[str] = Field(None, description="好友请求加密用户名")
|
||
ticket: Optional[str] = Field(None, description="好友请求ticket")
|
||
remark: Optional[str] = Field(None, min_length=1, max_length=128, description="好友备注")
|
||
group_id: Optional[str] = Field(None, min_length=1, description="微信群chatroom标识")
|
||
group_name: Optional[str] = Field(None, min_length=1, max_length=128, description="微信群名称")
|
||
member_ids: Optional[List[str]] = Field(None, min_length=1, max_length=500, description="群成员wxid列表")
|
||
notice: Optional[str] = Field(None, min_length=1, max_length=5000, description="群公告正文")
|
||
content: Optional[str] = Field(None, min_length=1, max_length=10000, description="群消息正文")
|
||
msg_type: MessageType = Field(MessageType.TEXT, description="群消息类型")
|
||
media_url: Optional[str] = Field(None, description="媒体资源地址")
|
||
at_all: bool = Field(False, description="是否@所有成员")
|
||
at_list: Optional[List[str]] = Field(None, max_length=500, description="需要@的成员wxid列表")
|
||
|
||
|
||
class FriendGroupActionRequest(BaseModel):
|
||
"""好友/群首批统一执行请求;默认演练,真实写入需要二次确认。"""
|
||
|
||
device_id: str = Field(..., min_length=1, examples=["DEVICE_ID"])
|
||
platform: Literal["wechat"] = Field("wechat", description="固定微信平台")
|
||
action: Literal[tuple(FRIEND_GROUP_FIRST_ACTIONS)] = Field(..., description="好友/群首批动作名")
|
||
params: FriendGroupActionParams = Field(default_factory=FriendGroupActionParams)
|
||
trace_id: Optional[str] = Field(None, description="跨服务追踪ID;缺省自动生成")
|
||
dry_run: bool = Field(True, description="默认只做参数与WSS/Frida链路预检")
|
||
confirm: bool = Field(False, description="真实微信写入确认;需confirm=true且dry_run=false")
|
||
|
||
|
||
# ========== WP-WX-04 无线扫码媒体模型 ==========
|
||
|
||
WIRELESS_QR_ACTIONS = (
|
||
"scan_qr_code", "extract_qr_from_image", "scan_extract_qr",
|
||
"add_friend_by_qr", "scan_add_friend",
|
||
)
|
||
|
||
|
||
class WirelessQrMediaPayload(BaseModel):
|
||
"""仅经WSS Agent传递给无线Frida的二维码媒体描述,不进入本机相册或UI链路。"""
|
||
|
||
image_base64: Optional[str] = Field(None, description="二维码图片Base64,可含data URI前缀")
|
||
image_url: Optional[str] = Field(None, description="WSS Agent可获取的图片URL")
|
||
media_id: Optional[str] = Field(None, description="WSS Agent已上传媒体标识")
|
||
media_path: Optional[str] = Field(None, description="无线Agent设备侧媒体路径")
|
||
mime_type: Optional[str] = Field(None, description="媒体MIME类型,例如image/png")
|
||
file_name: Optional[str] = Field(None, max_length=255, description="原始文件名")
|
||
sha256: Optional[str] = Field(None, min_length=64, max_length=64, description="媒体SHA-256校验值")
|
||
|
||
|
||
class WirelessQrActionRequest(BaseModel):
|
||
"""WP-WX-04扫码五动作统一请求;写类由dry_run/confirm控制。"""
|
||
|
||
device_id: str = Field(..., min_length=1, examples=["DEVICE_ID"])
|
||
platform: Literal["wechat"] = Field("wechat", description="固定微信平台")
|
||
action: Literal[tuple(WIRELESS_QR_ACTIONS)] = Field(..., description="扫码动作名")
|
||
media: Optional[WirelessQrMediaPayload] = Field(None, description="无线媒体载荷")
|
||
qr_content: Optional[str] = Field(None, max_length=4096, description="已知二维码原始文本")
|
||
wechat_id: Optional[str] = Field(None, max_length=128, description="微信号、手机号或wxid")
|
||
verify_message: str = Field("", max_length=2000, description="加好友验证消息")
|
||
source_type: int = Field(30, ge=1, le=99, description="微信扫码来源场景值")
|
||
trace_id: Optional[str] = Field(None, description="跨服务追踪ID;缺省自动生成")
|
||
dry_run: bool = Field(True, description="写类默认只做参数与无线通道预检")
|
||
confirm: bool = Field(False, description="加好友真实写入确认;需confirm=true且dry_run=false")
|
||
|
||
|
||
class MessageListData(BaseModel):
|
||
"""消息列表数据。"""
|
||
success: bool
|
||
messages: List[Dict[str, Any]]
|
||
count: int
|
||
requested_limit: int
|
||
offset: int
|
||
total_count: Optional[int] = None
|
||
has_more: bool = False
|
||
status: str
|
||
error_code: str = ""
|
||
error_message: str = ""
|
||
trace_id: str = ""
|
||
raw_rpc_receipt: Optional[Any] = None
|
||
readback: Optional[Any] = None
|
||
|
||
|
||
class MessageListResponse(BaseModel):
|
||
code: int
|
||
success: bool
|
||
data: MessageListData
|
||
feedback: MessageFeedback
|
||
channel_used: str
|
||
trace_id: str = ""
|
||
|
||
|
||
class MessageSyncData(MessageListData):
|
||
since_time: int
|
||
next_since_time: int
|
||
|
||
|
||
class MessageSyncResponse(BaseModel):
|
||
code: int
|
||
success: bool
|
||
data: MessageSyncData
|
||
feedback: MessageFeedback
|
||
channel_used: str
|
||
|
||
|
||
class BatchMessageItem(BaseModel):
|
||
to_id: str
|
||
success: bool
|
||
verified: bool
|
||
message_id: Optional[str] = None
|
||
error_code: str = ""
|
||
error_message: str = ""
|
||
channel_used: str = ""
|
||
|
||
|
||
class BatchMessageData(BaseModel):
|
||
success: bool
|
||
status: str
|
||
sent: List[BatchMessageItem]
|
||
failed: List[BatchMessageItem]
|
||
total: int
|
||
success_count: int
|
||
failed_count: int
|
||
trace_id: str = ""
|
||
raw_rpc_receipt: Optional[Any] = None
|
||
readback: Optional[Any] = None
|
||
|
||
|
||
class BatchMessageResponse(BaseModel):
|
||
code: int
|
||
success: bool
|
||
data: BatchMessageData
|
||
feedback: MessageFeedback
|
||
channel_used: str
|
||
trace_id: str = ""
|
||
|
||
|
||
MESSAGE_API_RESPONSES = {
|
||
400: {"description": "请求参数或指定通道错误"},
|
||
401: {"description": "API Key缺失或错误"},
|
||
422: {"description": "请求体校验失败"},
|
||
503: {"description": "设备、WebSocket、Frida Hook或执行通道未就绪"},
|
||
}
|
||
|
||
MESSAGE_WRITE_RESPONSES = {
|
||
**MESSAGE_API_RESPONSES,
|
||
200: {
|
||
"description": "消息写操作已执行",
|
||
"content": {
|
||
"application/json": {
|
||
"examples": {
|
||
"verified": {
|
||
"summary": "真实回执成功",
|
||
"value": {
|
||
"code": 200,
|
||
"success": True,
|
||
"data": {
|
||
"success": True,
|
||
"verified": True,
|
||
"status": "verified",
|
||
"action": "send_message",
|
||
"target_id": "filehelper",
|
||
"message_id": "10420",
|
||
"error_code": "",
|
||
"error_message": "",
|
||
"retryable": False,
|
||
"retry_after_seconds": 0,
|
||
"channel_used": "server/frida",
|
||
},
|
||
"feedback": {
|
||
"level": "success",
|
||
"title": "消息操作已确认",
|
||
"message": "执行端已返回真实业务回执。",
|
||
"action": "readback",
|
||
},
|
||
"channel_used": "server/frida",
|
||
},
|
||
},
|
||
"hook_unavailable": {
|
||
"summary": "Hook通道未就绪",
|
||
"value": {
|
||
"code": 503,
|
||
"success": False,
|
||
"data": {
|
||
"success": False,
|
||
"verified": False,
|
||
"status": "hook_unavailable",
|
||
"action": "send_message",
|
||
"target_id": "filehelper",
|
||
"error_code": "hook_unavailable",
|
||
"error_message": "Frida Hook未就绪",
|
||
"retryable": True,
|
||
"retry_after_seconds": 0,
|
||
"channel_used": "websocket/hook(failed)",
|
||
},
|
||
"feedback": {
|
||
"level": "error",
|
||
"title": "消息操作失败",
|
||
"message": "Frida Hook未就绪",
|
||
"action": "check_hook",
|
||
},
|
||
"channel_used": "websocket/hook(failed)",
|
||
},
|
||
},
|
||
}
|
||
}
|
||
},
|
||
},
|
||
}
|
||
|
||
MESSAGE_LIST_RESPONSES = {
|
||
**MESSAGE_API_RESPONSES,
|
||
200: {
|
||
"description": "真实消息列表",
|
||
"content": {
|
||
"application/json": {
|
||
"example": {
|
||
"code": 200,
|
||
"success": True,
|
||
"data": {
|
||
"success": True,
|
||
"messages": [{"id": "10420", "type": "1", "from_id": "filehelper", "content": "联调成功", "timestamp": "1784790000000", "is_send": True}],
|
||
"count": 1,
|
||
"requested_limit": 20,
|
||
"offset": 0,
|
||
"total_count": 1,
|
||
"has_more": False,
|
||
"status": "verified",
|
||
"error_code": "",
|
||
"error_message": "",
|
||
},
|
||
"feedback": {"level": "success", "title": "消息操作已确认", "message": "执行端已返回真实业务回执。", "action": "none"},
|
||
"channel_used": "server/frida",
|
||
}
|
||
}
|
||
},
|
||
},
|
||
}
|
||
|
||
MESSAGE_SYNC_RESPONSES = {
|
||
**MESSAGE_API_RESPONSES,
|
||
200: {
|
||
"description": "增量消息与下一游标",
|
||
"content": {
|
||
"application/json": {
|
||
"example": {
|
||
"code": 200,
|
||
"success": True,
|
||
"data": {
|
||
"success": True,
|
||
"messages": [],
|
||
"count": 0,
|
||
"requested_limit": 100,
|
||
"offset": 0,
|
||
"total_count": 0,
|
||
"has_more": False,
|
||
"status": "verified",
|
||
"error_code": "",
|
||
"error_message": "",
|
||
"since_time": 1784790000,
|
||
"next_since_time": 1784790000,
|
||
},
|
||
"feedback": {"level": "success", "title": "消息操作已确认", "message": "执行端已返回真实业务回执。", "action": "none"},
|
||
"channel_used": "server/frida",
|
||
}
|
||
}
|
||
},
|
||
},
|
||
}
|
||
|
||
BATCH_MESSAGE_RESPONSES = {
|
||
**MESSAGE_API_RESPONSES,
|
||
200: {
|
||
"description": "批量消息逐项结果",
|
||
"content": {
|
||
"application/json": {
|
||
"example": {
|
||
"code": 207,
|
||
"success": False,
|
||
"data": {
|
||
"success": False,
|
||
"status": "partial_success",
|
||
"sent": [{"to_id": "filehelper", "success": True, "verified": True, "message_id": "10420", "error_code": "", "error_message": "", "channel_used": "server/frida"}],
|
||
"failed": [{"to_id": "TARGET", "success": False, "verified": False, "message_id": None, "error_code": "target_not_found", "error_message": "未找到接收方", "channel_used": "server/frida"}],
|
||
"total": 2,
|
||
"success_count": 1,
|
||
"failed_count": 1,
|
||
},
|
||
"feedback": {"level": "error", "title": "消息操作失败", "message": "1个接收方发送失败", "action": "retry"},
|
||
"channel_used": "server/frida",
|
||
}
|
||
}
|
||
},
|
||
},
|
||
}
|
||
|
||
|
||
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 = Field(
|
||
...,
|
||
description="工作手机设备ID,可从 /health 的 device_ids 获取",
|
||
examples=["xgfe65eimrrofyws"],
|
||
)
|
||
platform: Platform = Field(
|
||
...,
|
||
description="目标平台;微信传 wechat",
|
||
examples=["wechat"],
|
||
)
|
||
user_id: str = Field(
|
||
...,
|
||
description="目标微信号、手机号或 wxid;接口会先查询通讯录做幂等判断",
|
||
examples=["16880802666"],
|
||
)
|
||
message: str = Field(
|
||
"",
|
||
description="好友验证消息",
|
||
examples=["你好,我是卡若"],
|
||
)
|
||
source: Optional[str] = Field(
|
||
None,
|
||
description="业务来源标记,便于任务追踪",
|
||
examples=["cli"],
|
||
)
|
||
source_type: int = Field(
|
||
3,
|
||
ge=1,
|
||
le=99,
|
||
description="微信加友场景值,默认3表示搜索添加",
|
||
examples=[3],
|
||
)
|
||
|
||
|
||
class AddFriendByQrRequest(BaseModel):
|
||
"""无界面扫码或微信ID添加好友请求。"""
|
||
device_id: str = Field(..., min_length=1, description="工作手机设备ID")
|
||
platform: Platform = Field(Platform.WECHAT, description="目标平台,默认wechat")
|
||
qr_content: str = Field("", description="已解码的二维码原始文本")
|
||
wechat_id: str = Field("", description="微信号、手机号或wxid;提供后直接走搜索加友")
|
||
image_base64: str = Field("", description="二维码图片base64,可含data前缀")
|
||
image_url: str = Field("", description="二维码图片http/https地址")
|
||
verify_message: str = Field("", description="好友验证消息")
|
||
source_type: int = Field(30, ge=1, le=99, description="二维码加友场景值,默认30")
|
||
dry_run: bool = Field(True, description="默认只做参数与链路预检")
|
||
confirm: bool = Field(False, description="真实写入确认;需confirm=true且dry_run=false")
|
||
trace_id: Optional[str] = Field(None, description="跨服务追踪ID;缺省自动生成")
|
||
|
||
|
||
class AddFriendFromGroupRequest(BaseModel):
|
||
"""从微信群成员发起好友申请。"""
|
||
device_id: str = Field(..., description="工作手机设备ID")
|
||
platform: Platform = Field(Platform.WECHAT, description="目标平台,默认wechat")
|
||
group_id: str = Field(..., min_length=10, description="真实微信群ID,格式为xxx@chatroom")
|
||
user_id: str = Field(..., min_length=1, description="群成员wxid")
|
||
message: str = Field("你好,我们在同一个微信群", description="好友验证消息")
|
||
source_type: int = Field(14, ge=1, le=99, description="微信群来源场景,默认14")
|
||
confirm: bool = Field(True, description="确认执行写操作")
|
||
|
||
|
||
class FriendAddFeedback(BaseModel):
|
||
"""调用方可直接展示或交给调度器处理的反馈。"""
|
||
level: str = Field(..., description="反馈级别:success/error")
|
||
title: str = Field(..., description="反馈标题")
|
||
message: str = Field(..., description="反馈说明")
|
||
action: str = Field(
|
||
...,
|
||
description="下一动作:none/retry/fix_request/check_target/check_device/check_hook/inspect_error",
|
||
)
|
||
|
||
|
||
class AddFriendResult(BaseModel):
|
||
"""加好友业务结果;保留底层Hook扩展字段。"""
|
||
success: bool = Field(..., description="本次加好友业务是否成功")
|
||
verified: bool = Field(..., description="业务成功是否已确认;失败固定为false")
|
||
already_friend: bool = Field(..., description="目标是否已经是好友")
|
||
request_sent: bool = Field(..., description="本次是否真实发送好友申请")
|
||
status: str = Field(
|
||
...,
|
||
description="标准状态:already_friend/request_verified/validation_failed/rate_limited/timeout/user_not_found/device_offline/hook_unavailable/request_failed",
|
||
)
|
||
target_user_id: str = Field(..., description="本次请求的目标账号")
|
||
error: str = Field("", description="兼容错误码字段;成功为空字符串")
|
||
error_code: str = Field("", description="机器可读错误码;成功为空字符串")
|
||
error_message: str = Field("", description="错误说明;成功为空字符串")
|
||
retryable: bool = Field(..., description="当前错误是否适合自动重试")
|
||
retry_after_seconds: float = Field(0, description="建议等待秒数")
|
||
cooldown_seconds: float = Field(..., description="当前本地加好友冷却秒数")
|
||
cooldown_jitter_seconds: float = Field(..., description="当前冷却随机浮动秒数")
|
||
config_key: str = Field(..., description="冷却配置环境变量名")
|
||
method: Optional[str] = Field(None, description="实际执行或幂等判断方法")
|
||
wechat_err_type: Optional[int] = Field(None, description="微信原始errType")
|
||
wechat_err_code: Optional[int] = Field(None, description="微信原始errCode")
|
||
contact: Optional[dict] = Field(None, description="现有好友命中时返回的联系人信息")
|
||
feedback: FriendAddFeedback
|
||
|
||
class Config:
|
||
extra = "allow"
|
||
|
||
|
||
class AddFriendResponse(BaseModel):
|
||
"""无界面添加好友统一响应。"""
|
||
code: int = Field(..., description="业务响应码")
|
||
success: bool = Field(..., description="顶层业务成功标记")
|
||
data: AddFriendResult
|
||
feedback: FriendAddFeedback
|
||
channel_used: str = Field(..., description="真实通道,例如server/frida")
|
||
|
||
|
||
class AcceptFriendRequest(BaseModel):
|
||
"""通过好友请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
user_id: str = ""
|
||
encrypt_username: str = ""
|
||
ticket: str = ""
|
||
|
||
|
||
class SetRemarkRequest(BaseModel):
|
||
"""单个微信好友改名(修改备注)请求。"""
|
||
device_id: str = Field(..., description="工作手机设备ID", examples=["xgfe65eimrrofyws"])
|
||
platform: Platform = Field(Platform.WECHAT, description="目标平台,微信传wechat")
|
||
user_id: str = Field(..., min_length=1, description="好友wxid", examples=["wxid_example"])
|
||
remark: str = Field(..., description="新的完整好友备注", examples=["张总|估值88"])
|
||
|
||
|
||
class BatchRemarkItem(BaseModel):
|
||
"""单个好友备注配置。"""
|
||
user_id: str = Field(..., min_length=1, description="好友wxid")
|
||
remark: str = Field(..., description="写入微信的完整备注")
|
||
value_score: Optional[float] = Field(None, description="业务估值分,仅随回执透传")
|
||
|
||
|
||
class BatchSetRemarkRequest(BaseModel):
|
||
"""批量设置好友备注请求。"""
|
||
device_id: str = Field(..., description="工作手机设备ID")
|
||
platform: Platform = Field(Platform.WECHAT, description="目标平台,默认wechat")
|
||
items: List[BatchRemarkItem] = Field(
|
||
...,
|
||
min_length=1,
|
||
max_length=500,
|
||
description="备注任务,单次1~500项",
|
||
)
|
||
|
||
model_config = {
|
||
"json_schema_extra": {
|
||
"examples": [{
|
||
"device_id": "xgfe65eimrrofyws",
|
||
"platform": "wechat",
|
||
"items": [
|
||
{"user_id": "wxid_a", "remark": "张总|估值88", "value_score": 88},
|
||
{"user_id": "wxid_b", "remark": "李老师|估值76", "value_score": 76},
|
||
],
|
||
}]
|
||
}
|
||
}
|
||
|
||
|
||
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):
|
||
"""无界面设置群欢迎语;真实写入由Frida内部Scene和chatroom回读共同确认。"""
|
||
device_id: str = Field(..., min_length=1, description="工作手机设备ID")
|
||
platform: Platform = Field(Platform.WECHAT, description="目标平台")
|
||
group_id: str = Field(..., min_length=1, description="真实群ID,必须以@chatroom结尾")
|
||
welcome_text: str = Field(..., min_length=1, max_length=5000, description="群欢迎语正文")
|
||
welcome_image: Optional[str] = Field(None, description="欢迎语图片资源;当前仅随RPC载荷透传")
|
||
dry_run: bool = Field(True, description="默认仅执行群表探测与回读,不写入欢迎语")
|
||
confirm: bool = Field(False, description="真实写入确认;需confirm=true且dry_run=false")
|
||
trace_id: Optional[str] = Field(None, description="跨服务追踪ID;缺省由传输层生成")
|
||
|
||
|
||
# ========== 标签相关模型 ==========
|
||
|
||
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 = Field(..., min_length=1, description="工作手机设备ID")
|
||
platform: Platform = Field(Platform.WECHAT, description="目标平台")
|
||
tag_name: str = Field(
|
||
...,
|
||
min_length=1,
|
||
max_length=20,
|
||
description="标签名称;微信当前真机链路最多20个字符,过长会被微信拒绝",
|
||
)
|
||
dry_run: bool = Field(True, description="默认只做参数与链路预检,保证零写")
|
||
confirm: bool = Field(False, description="真实创建确认;需dry_run=false且confirm=true")
|
||
idempotency_key: Optional[str] = Field(
|
||
None,
|
||
min_length=1,
|
||
max_length=128,
|
||
description="调用方幂等键;同设备同键同参数只执行一次",
|
||
)
|
||
trace_id: Optional[str] = Field(None, min_length=1, max_length=128, description="跨服务追踪ID")
|
||
retry: int = Field(0, ge=0, le=3, description="调用方重试计数;路由不自动重试")
|
||
|
||
|
||
class TagCreateData(BaseModel):
|
||
"""创建标签统一业务回执。"""
|
||
success: bool = False
|
||
verified: bool = False
|
||
verified_no_write: bool = False
|
||
status: str = ""
|
||
action: str = "create_tag"
|
||
tag_name: str = ""
|
||
idempotency_key: str = ""
|
||
idempotency_reused: bool = False
|
||
trace_id: str = ""
|
||
error_code: str = ""
|
||
error_message: str = ""
|
||
retryable: bool = False
|
||
retry_after_seconds: float = 0
|
||
channel_used: str = ""
|
||
raw_rpc_receipt: Optional[Any] = None
|
||
readback: Optional[Any] = None
|
||
|
||
class Config:
|
||
extra = "allow"
|
||
|
||
|
||
class TagCreateResponse(BaseModel):
|
||
"""创建标签 HTTP 合同响应。"""
|
||
code: int
|
||
success: bool = False
|
||
data: TagCreateData
|
||
feedback: dict
|
||
channel_used: str
|
||
trace_id: str
|
||
idempotency_key: str
|
||
idempotency_reused: bool = False
|
||
verified_no_write: bool = False
|
||
|
||
|
||
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 # 不可见名单
|
||
hook_only: bool = True # 微信朋友圈默认只走 Frida/Hook
|
||
idempotency_key: Optional[str] = None # 调用方幂等键
|
||
|
||
|
||
class GenerateMomentsMaterialRequest(BaseModel):
|
||
"""自动生成朋友圈素材。"""
|
||
topic: str = "工作手机自动化"
|
||
content: Optional[str] = None
|
||
media_type: str = Field(default="image", pattern="^(text|image|video)$")
|
||
duration: int = Field(default=6, ge=3, le=15)
|
||
|
||
|
||
class AutoMomentsRequest(GenerateMomentsMaterialRequest):
|
||
"""生成素材后直接发布。"""
|
||
device_id: str
|
||
platform: Platform = Platform.WECHAT
|
||
public_base_url: str
|
||
hook_only: bool = True
|
||
|
||
|
||
class LikeMomentsRequest(BaseModel):
|
||
"""点赞朋友圈请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
sns_id: Optional[str] = None # 真实朋友圈ID,真机写操作必须优先使用
|
||
user_id: str # 朋友圈所属用户
|
||
post_index: int = 0 # 朋友圈索引(第几条)
|
||
|
||
|
||
class CommentMomentsRequest(BaseModel):
|
||
"""评论朋友圈请求"""
|
||
device_id: str
|
||
platform: Platform
|
||
sns_id: Optional[str] = None # 真实朋友圈ID,真机写操作必须优先使用
|
||
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 = Field(..., min_length=1, description="工作手机设备ID")
|
||
platform: Platform = Field(..., description="目标平台")
|
||
video_id: Optional[str] = Field(None, description="视频或内容ID")
|
||
comment_id: str = Field(..., min_length=1, description="待回复评论ID")
|
||
content: str = Field(..., min_length=1, description="回复内容")
|
||
confirm: bool = Field(False, description="写操作确认;false时执行端可返回dry_run")
|
||
|
||
|
||
# ========== 批量操作模型 ==========
|
||
|
||
class BatchSendMessageRequest(BaseModel):
|
||
"""批量发送消息请求。"""
|
||
device_id: str = Field(..., min_length=1, description="工作手机设备ID")
|
||
platform: Platform = Field(..., description="目标平台")
|
||
to_ids: List[str] = Field(..., min_length=1, max_length=100, description="接收方列表,最多100个")
|
||
content: str = Field(..., min_length=1, description="消息正文")
|
||
msg_type: MessageType = Field(MessageType.TEXT, description="消息类型")
|
||
media_url: Optional[str] = Field(None, description="媒体资源地址")
|
||
interval: float = Field(2.0, ge=0.5, le=30, description="逐条发送间隔秒数")
|
||
|
||
|
||
class BatchAddFriendRequest(BaseModel):
|
||
"""批量添加好友请求。"""
|
||
device_id: str = Field(..., description="工作手机设备ID")
|
||
platform: Platform = Field(..., description="目标平台")
|
||
user_ids: List[str] = Field(..., min_length=1, max_length=100, description="目标账号,自动去重")
|
||
message: str = Field("", description="好友验证消息")
|
||
interval: float = Field(10.0, ge=0, le=60, description="本地逐目标最小间隔,默认10秒")
|
||
max_retries: int = Field(1, ge=0, le=3, description="单目标可重试次数")
|
||
respect_retry_after: bool = Field(True, description="是否遵循微信返回的retry_after_seconds")
|
||
max_retry_wait: float = Field(60.0, ge=1, le=300, description="单次自动等待上限")
|
||
|
||
|
||
class GroupBatchAddFriendRequest(BaseModel):
|
||
"""从一个微信群筛选非好友后批量添加。"""
|
||
device_id: str = Field(..., description="工作手机设备ID")
|
||
platform: Platform = Field(Platform.WECHAT, description="目标平台")
|
||
group_id: str = Field(..., min_length=10, description="真实微信群ID")
|
||
limit: int = Field(10, ge=1, le=10, description="本次最多申请人数,最大10")
|
||
message: str = Field("你好,我们在同一个微信群", description="好友验证消息")
|
||
interval: float = Field(10.0, ge=10, le=120, description="逐目标间隔,最少10秒")
|
||
source_type: int = Field(14, ge=1, le=99, description="微信群来源场景")
|
||
random_select: bool = Field(True, description="随机打散群成员后筛选")
|
||
|
||
|
||
class MessageSyncSinceRequest(BaseModel):
|
||
"""按时间增量同步消息请求。"""
|
||
device_id: str = Field(..., min_length=1, description="工作手机设备ID")
|
||
platform: Platform = Field(Platform.WECHAT, description="目标平台")
|
||
conversation_id: Optional[str] = Field(None, description="指定会话")
|
||
since_time: int = Field(0, ge=0, description="起始Unix时间戳,秒或毫秒均可")
|
||
limit: int = Field(DEFAULT_MESSAGE_PULL_LIMIT, ge=1, le=MAX_MESSAGE_PULL_LIMIT)
|
||
offset: int = Field(0, ge=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 _message_error_code(error: str, code: int = 0) -> str:
|
||
"""将执行端错误归一为稳定的机器码,同时保留原始说明。"""
|
||
text = str(error or "").lower()
|
||
if code == 401:
|
||
return "unauthorized"
|
||
if code == 408 or "timeout" in text or "超时" in text:
|
||
return "timeout"
|
||
if "未找到" in text or "not found" in text:
|
||
return "target_not_found"
|
||
if "offline" in text or "不在线" in text:
|
||
return "device_offline"
|
||
if "hook" in text or "frida" in text or "receiver" in text:
|
||
return "hook_unavailable"
|
||
if "accessibility" in text or "无障碍" in text:
|
||
return "accessibility_unavailable"
|
||
if "频繁" in text or "限流" in text or "cooldown" in text:
|
||
return "rate_limited"
|
||
if code == 422:
|
||
return "validation_failed"
|
||
return "operation_failed" if error else ""
|
||
|
||
|
||
def _message_feedback(
|
||
*,
|
||
success: bool,
|
||
verified: bool,
|
||
action: str,
|
||
error_code: str = "",
|
||
error_message: str = "",
|
||
) -> dict:
|
||
if success and verified:
|
||
return {
|
||
"level": "success",
|
||
"title": "消息操作已确认",
|
||
"message": "执行端已返回真实业务回执。",
|
||
"action": "readback" if action == "send_message" else "none",
|
||
}
|
||
if success:
|
||
return {
|
||
"level": "warning",
|
||
"title": "消息操作已受理",
|
||
"message": "执行端报告成功,但本次未返回可回读的消息ID。",
|
||
"action": "readback",
|
||
}
|
||
action_map = {
|
||
"target_not_found": "check_target",
|
||
"device_offline": "check_device",
|
||
"hook_unavailable": "check_hook",
|
||
"accessibility_unavailable": "check_device",
|
||
"validation_failed": "check_target",
|
||
}
|
||
return {
|
||
"level": "error",
|
||
"title": "消息操作失败",
|
||
"message": error_message or "执行端返回失败,请根据错误码处理。",
|
||
"action": action_map.get(error_code, "retry"),
|
||
}
|
||
|
||
|
||
def _message_operation_response(
|
||
result: dict,
|
||
*,
|
||
action: str,
|
||
target_id: str = "",
|
||
fallback_channel: str = "sdk_control",
|
||
require_message_id: bool = False,
|
||
) -> dict:
|
||
"""把Hook、Agent、ADB和官方API回执收口成同一消息写操作契约。"""
|
||
result = result if isinstance(result, dict) else {}
|
||
payload = _payload_of(result)
|
||
# 执行链路未提供trace时保持空值,不能伪造为已可关联的真机证据。
|
||
trace_id = str(result.get("trace_id") or payload.get("trace_id") or "")
|
||
channel = str(
|
||
result.get("_channel_used")
|
||
or result.get("channel_used")
|
||
or result.get("channel")
|
||
or payload.get("channel_used")
|
||
or payload.get("channel")
|
||
or fallback_channel
|
||
)
|
||
raw_code = result.get("code", payload.get("code", 200))
|
||
code = int(raw_code) if isinstance(raw_code, int) else 200
|
||
explicit_success = payload.get("success", result.get("success"))
|
||
success = explicit_success is True
|
||
message_id = (
|
||
payload.get("message_id")
|
||
or payload.get("msg_svr_id")
|
||
or payload.get("svr_id")
|
||
or result.get("message_id")
|
||
or result.get("msg_svr_id")
|
||
or result.get("svr_id")
|
||
)
|
||
raw_rpc_receipt = result.get("raw_rpc_receipt") if "raw_rpc_receipt" in result else payload.get("raw_rpc_receipt")
|
||
readback = result.get("readback") if "readback" in result else payload.get("readback")
|
||
if readback is None:
|
||
readback = payload.get("db_readback") or payload.get("message_readback")
|
||
verified = bool(payload.get("verified", result.get("verified", success)))
|
||
if require_message_id:
|
||
verified = bool(success and message_id)
|
||
error_message = str(
|
||
payload.get("error_message")
|
||
or payload.get("error")
|
||
or result.get("error_message")
|
||
or result.get("error")
|
||
or result.get("message")
|
||
or ""
|
||
)
|
||
error_code = str(
|
||
payload.get("error_code")
|
||
or result.get("error_code")
|
||
or _message_error_code(error_message, code)
|
||
)
|
||
if not success and code == 200:
|
||
code = 503
|
||
if success and code >= 400:
|
||
success = False
|
||
verified = False
|
||
if success:
|
||
status = "verified" if verified else "accepted_unverified"
|
||
elif error_code == "device_offline":
|
||
status = "device_offline"
|
||
elif error_code in ("hook_unavailable", "accessibility_unavailable"):
|
||
status = "hook_unavailable"
|
||
elif error_code == "timeout":
|
||
status = "timeout"
|
||
elif error_code == "validation_failed":
|
||
status = "validation_failed"
|
||
else:
|
||
status = "operation_failed"
|
||
retryable = bool(
|
||
payload.get("retryable", result.get("retryable", error_code in {
|
||
"timeout", "device_offline", "hook_unavailable", "accessibility_unavailable", "rate_limited",
|
||
}))
|
||
)
|
||
retry_after = float(payload.get("retry_after_seconds") or result.get("retry_after_seconds") or 0)
|
||
operation_status = (
|
||
payload.get("operation_status")
|
||
or payload.get("payment_status")
|
||
or result.get("operation_status")
|
||
or result.get("status")
|
||
or ""
|
||
)
|
||
data = dict(payload)
|
||
data.update({
|
||
"success": success,
|
||
"verified": verified,
|
||
"status": status,
|
||
"action": action,
|
||
"target_id": target_id,
|
||
"message_id": str(message_id) if message_id is not None else None,
|
||
"error_code": "" if success else error_code,
|
||
"error_message": "" if success else error_message,
|
||
"retryable": False if success else retryable,
|
||
"retry_after_seconds": 0 if success else retry_after,
|
||
"channel_used": channel,
|
||
"trace_id": trace_id,
|
||
"raw_rpc_receipt": raw_rpc_receipt,
|
||
"readback": readback,
|
||
})
|
||
if operation_status and operation_status != status:
|
||
data["operation_status"] = str(operation_status)
|
||
feedback = _message_feedback(
|
||
success=success,
|
||
verified=verified,
|
||
action=action,
|
||
error_code=data["error_code"],
|
||
error_message=data["error_message"],
|
||
)
|
||
return {
|
||
"code": code,
|
||
"success": success,
|
||
"data": data,
|
||
"feedback": feedback,
|
||
"channel_used": channel,
|
||
"trace_id": trace_id,
|
||
}
|
||
|
||
|
||
def _strict_frida_operation_response(
|
||
result: dict,
|
||
*,
|
||
action: str,
|
||
target_id: str = "",
|
||
require_readback: bool = True,
|
||
) -> dict:
|
||
"""WP-HL:无界面动作统一回执;禁止 Intent/ADB/UI 假成功。"""
|
||
result = result if isinstance(result, dict) else {}
|
||
payload = _payload_of(result)
|
||
trace_id = str(result.get("trace_id") or payload.get("trace_id") or "")
|
||
channel = str(
|
||
result.get("_channel_used")
|
||
or result.get("channel_used")
|
||
or payload.get("channel")
|
||
or result.get("channel")
|
||
or "frida_rpc"
|
||
)
|
||
raw_rpc_receipt = result.get("raw_rpc_receipt") if "raw_rpc_receipt" in result else payload.get("raw_rpc_receipt")
|
||
readback = result.get("readback") if "readback" in result else payload.get("readback")
|
||
if readback is None:
|
||
readback = payload.get("db_readback")
|
||
method = str(payload.get("method") or result.get("method") or "")
|
||
error_message = str(
|
||
payload.get("error_message")
|
||
or payload.get("error")
|
||
or result.get("error_message")
|
||
or result.get("error")
|
||
or result.get("message")
|
||
or ""
|
||
)
|
||
explicit_success = payload.get("success", result.get("success")) is True
|
||
frida_channel = "frida" in channel.lower() or channel == "frida_rpc"
|
||
intent_placeholder = method == "intent_broadcast" or "no_receiver" in error_message
|
||
verified = bool(payload.get("verified", result.get("verified", False)))
|
||
success = bool(explicit_success and frida_channel and not intent_placeholder)
|
||
if require_readback:
|
||
success = bool(success and (verified or readback))
|
||
if success:
|
||
error_code = ""
|
||
status = "verified"
|
||
code = 200
|
||
feedback = {"level": "success", "title": "微信动作已确认", "message": "Frida RPC已返回真实回执并完成回读。", "action": "readback"}
|
||
else:
|
||
error_code = str(
|
||
payload.get("error_code")
|
||
or result.get("error_code")
|
||
or ("capability_unavailable" if intent_placeholder or not frida_channel else "")
|
||
or _message_error_code(error_message)
|
||
or "operation_failed"
|
||
)
|
||
status = "capability_unavailable" if error_code == "capability_unavailable" else "operation_failed"
|
||
code = 503
|
||
error_message = error_message or "当前动作缺少可用的无线 Frida RPC 能力"
|
||
feedback = {"level": "error", "title": "微信动作未执行", "message": error_message, "action": "check_hook" if error_code == "capability_unavailable" else "retry"}
|
||
data = dict(payload)
|
||
data.update({
|
||
"success": success,
|
||
"verified": bool(success and verified),
|
||
"status": status,
|
||
"action": action,
|
||
"target_id": target_id,
|
||
"trace_id": trace_id,
|
||
"error_code": error_code,
|
||
"error_message": "" if success else error_message,
|
||
"retryable": False if success else error_code in {"capability_unavailable", "timeout", "device_offline", "hook_unavailable"},
|
||
"retry_after_seconds": 0 if success else 1,
|
||
"channel_used": channel,
|
||
"raw_rpc_receipt": raw_rpc_receipt,
|
||
"readback": readback,
|
||
})
|
||
return {"code": code, "success": success, "data": data, "feedback": feedback, "channel_used": channel, "trace_id": trace_id}
|
||
|
||
|
||
def _set_http_status(response: Optional[Response], result: dict) -> dict:
|
||
"""保留函数直调兼容性,同时让HTTP调用拥有真实4xx/5xx语义。"""
|
||
if response is not None:
|
||
response.status_code = int(result.get("code") or 200)
|
||
return result
|
||
|
||
|
||
def _headless_dry_run_response(req: WP_HL04ActionRequest) -> dict:
|
||
trace_id = str(req.trace_id or uuid.uuid4().hex)
|
||
data = {
|
||
"success": False,
|
||
"verified": False,
|
||
"status": "dry_run_no_write",
|
||
"action": req.action,
|
||
"target_id": req.params.target_id or req.params.user_id or "",
|
||
"trace_id": trace_id,
|
||
"error_code": "dry_run_no_write" if req.dry_run else "confirm_required",
|
||
"error_message": "参数已校验,未执行微信写入;请显式confirm=true且dry_run=false。",
|
||
"retryable": False,
|
||
"retry_after_seconds": 0,
|
||
"channel_used": "dry_run",
|
||
"raw_rpc_receipt": None,
|
||
"readback": None,
|
||
"dry_run": req.dry_run,
|
||
"confirm": req.confirm,
|
||
}
|
||
return {
|
||
"code": 200,
|
||
"success": False,
|
||
"data": data,
|
||
"feedback": {"level": "warning", "title": "仅完成参数演练", "message": data["error_message"], "action": "confirm"},
|
||
"channel_used": "dry_run",
|
||
"trace_id": trace_id,
|
||
}
|
||
|
||
|
||
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
|
||
from services.security_modules import security_module_registry
|
||
|
||
if not security_module_registry.is_enabled("anti_ban"):
|
||
return {"pass": True, "reason": "anti_ban_disabled", "content": content}
|
||
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, retry_after_seconds: float = 0) -> dict:
|
||
# AB-06:附带风险分级 risk_action(block/needs_human/log)
|
||
return {"pass": False, "reason": reason, "content": content,
|
||
"risk_action": classify_risk(reason),
|
||
"retry_after_seconds": max(0, round(retry_after_seconds, 1))}
|
||
|
||
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), getattr(e, "wait_seconds", 0))
|
||
except (LongPauseRequired, HighRiskComboBlocked) as e:
|
||
# AB-03:长会话中断 / 高危组合互斥,fast-fail 不长 sleep
|
||
return _blocked(str(e), getattr(e, "wait_seconds", 0))
|
||
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:
|
||
"""执行技能并返回结果,微信固定走 WebSocket Agent + 无线 Frida Hook。
|
||
|
||
微信通道:WebSocket Agent → 设备端 Frida RPC;离线时返回结构化错误。
|
||
非微信平台保留原有传输策略。
|
||
"""
|
||
from services.device_transport import (
|
||
device_transport,
|
||
WECHAT_U2_ONLY_ACTIONS,
|
||
WECHAT_STRICT_FRIDA_ACTIONS,
|
||
)
|
||
|
||
hook_only = device_transport.should_force_hook_only(platform, action, hook_only)
|
||
strict_frida = platform == "wechat" and action in WECHAT_STRICT_FRIDA_ACTIONS
|
||
mode = _get_device_mode(device_id, platform, action)
|
||
# 登录等纯 UI 动作交给 Android Agent;业务动作遵循 Hook-only 契约。
|
||
if platform == "wechat" and action in WECHAT_U2_ONLY_ACTIONS and not hook_only:
|
||
adb_device = adb_manager.get_device(device_id)
|
||
if device_transport.is_ws_online(device_id):
|
||
mode = "websocket"
|
||
else:
|
||
if adb_device and adb_device.is_online():
|
||
mode = "adb"
|
||
ws_hook_only = device_transport.ws_hook_only(platform) or strict_frida
|
||
|
||
# 〇-A、微信 Hook 强制主控。Server Frida 可在 Agent WS 离线时直连,
|
||
# 避免把已就绪的 Phantom RPC 错判为设备离线。
|
||
if platform == "wechat" and ws_hook_only:
|
||
from services.server_frida_bridge import server_frida_bridge
|
||
if (
|
||
(action not in WECHAT_U2_ONLY_ACTIONS or strict_frida)
|
||
and server_frida_bridge.enabled_for(device_id)
|
||
):
|
||
bridge_result = await server_frida_bridge.execute(device_id, action, params)
|
||
account_write_actions = {
|
||
"set_nickname", "set_signature", "set_region", "set_sex", "set_gender",
|
||
"set_avatar", "set_privacy", "set_account_protection", "set_notification",
|
||
"set_do_not_disturb", "toggle_do_not_disturb", "set_what_up",
|
||
}
|
||
# 资料写入固定 Server-Frida;桥接失败保留原始回执,不进入设备 Frida CLI/UI 回退。
|
||
if (
|
||
bridge_result.get("success")
|
||
or not device_transport.is_ws_online(device_id)
|
||
or action in account_write_actions
|
||
):
|
||
return bridge_result
|
||
logger.warning(
|
||
"[hook] server/frida失败,切换在线WebSocket Agent→Frida: %s",
|
||
bridge_result.get("error") or bridge_result.get("message"),
|
||
)
|
||
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_wechat_wss_frida(
|
||
device_id: str,
|
||
action: str,
|
||
params: dict,
|
||
*,
|
||
timeout: int = 120,
|
||
trace_id: Optional[str] = None,
|
||
) -> dict:
|
||
"""WP-HL-05专用:WSS Agent→无线Frida,禁止直连与UI回退。"""
|
||
request_trace_id = str(trace_id or params.get("trace_id") or uuid.uuid4().hex)
|
||
rpc_params = dict(params or {})
|
||
rpc_params["trace_id"] = request_trace_id
|
||
if not ws_hub.is_online(device_id):
|
||
return {
|
||
"code": 503,
|
||
"success": False,
|
||
"error": "device_offline",
|
||
"error_code": "device_offline",
|
||
"message": "WSS Agent未在线",
|
||
"trace_id": request_trace_id,
|
||
"_channel_used": "wss/offline",
|
||
"raw_rpc_receipt": None,
|
||
"readback": None,
|
||
}
|
||
# 优先使用服务端 Frida 反向隧道执行,避免 Agent 历史 execute 分支降级到 u2/ADB 点击。
|
||
from services.server_frida_bridge import server_frida_bridge
|
||
if server_frida_bridge.enabled_for(device_id):
|
||
result = await server_frida_bridge.execute(device_id, action, rpc_params)
|
||
if result.get("success") or not ws_hub.is_online(device_id):
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else result
|
||
result["trace_id"] = request_trace_id
|
||
result["_channel_used"] = result.get("_channel_used") or "server/frida"
|
||
if "raw_rpc_receipt" not in result:
|
||
result["raw_rpc_receipt"] = payload.get("raw_rpc_receipt") if isinstance(payload, dict) else None
|
||
if "readback" not in result:
|
||
result["readback"] = payload.get("readback") if isinstance(payload, dict) else None
|
||
return result
|
||
logger.warning("[hook] server/frida失败,切换在线WSS Agent→Frida")
|
||
result = await ws_hub.send_command(
|
||
device_id,
|
||
{
|
||
"type": "execute",
|
||
"data": {"script": "wechat", "action": action, "params": rpc_params, "hook_only": True},
|
||
},
|
||
timeout=timeout,
|
||
)
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else {}
|
||
result["trace_id"] = request_trace_id
|
||
result["_channel_used"] = result.get("_channel_used") or result.get("channel") or "websocket/frida"
|
||
if "raw_rpc_receipt" not in result:
|
||
result["raw_rpc_receipt"] = payload.get("raw_rpc_receipt")
|
||
if "readback" not in result:
|
||
result["readback"] = payload.get("readback")
|
||
return result
|
||
|
||
|
||
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 所需参数(键值对)")
|
||
trace_id: Optional[str] = Field(None, description="跨服务任务追踪ID;缺省自动生成")
|
||
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", "confirm_prepared_transfer", "transfer_confirm", "receive_transfer", "get_wallet_balance", "get_transaction_history", "inspect_payment_classes", "inspect_transfer_readback"],
|
||
"二维码": ["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 [])
|
||
frida_server_reported = bool(info.get("frida_available")) or bool(
|
||
{"frida", "frida_server"} & capabilities
|
||
)
|
||
reported_ready = bool({"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": frida_server_reported,
|
||
"hook_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 {}
|
||
channel = str(ping.get("_channel_used") or ping.get("channel") or "").lower()
|
||
ping_text = str(ping).lower()
|
||
frida_ok = (
|
||
reported_ready
|
||
or bool(pdata.get("hooked"))
|
||
or "pong from wechat_hook" in ping_text
|
||
or (bool(ping.get("success")) and "frida" in channel)
|
||
)
|
||
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,
|
||
include_hook: bool = False,
|
||
):
|
||
"""
|
||
设备稳定性采样入口:连接链路默认只读 WS/心跳/ADB/网络/前台服务/微信状态。
|
||
include_hook=true 时才执行Hook探测,避免24小时连接门禁被Hook耗时或无Root条件阻断。
|
||
"""
|
||
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)
|
||
quick_status = info.get("quick_status") if isinstance(info.get("quick_status"), dict) else {}
|
||
hook_ok = False
|
||
probe = {}
|
||
error = ""
|
||
if include_hook:
|
||
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")
|
||
except Exception as exc:
|
||
error = str(exc)[:200]
|
||
heartbeat = ws_hub.get_heartbeat_status(device_id) or {}
|
||
foreground = info.get("foreground_service")
|
||
if foreground is None:
|
||
foreground = info.get("agent_service_running")
|
||
if foreground is None:
|
||
foreground = info.get("on_device")
|
||
network_type = quick_status.get("network_type", info.get("network_type", ""))
|
||
wechat_running = quick_status.get("wechat_running", info.get("wechat_running"))
|
||
recovery_reason = info.get("last_recovery_reason", "")
|
||
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,
|
||
"hook_probe_included": include_hook,
|
||
"wechat_version": probe.get("wechat_version") or quick_status.get("wechat_version", info.get("wechat_version", "")),
|
||
"wechat_running": wechat_running,
|
||
"wechat_foreground": quick_status.get("wechat_foreground", info.get("wechat_foreground")),
|
||
"network_type": network_type,
|
||
"foreground_service": foreground,
|
||
"agent_running": online,
|
||
"last_heartbeat": info.get("last_heartbeat"),
|
||
"heartbeat_age_seconds": heartbeat.get("heartbeat_age_seconds"),
|
||
"heartbeat_stale": heartbeat.get("stale", not online),
|
||
"connect_stage": quick_status.get("connect_stage", info.get("connect_stage", "")),
|
||
"recovery_reason": recovery_reason,
|
||
"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 not row["heartbeat_stale"]
|
||
and (not include_hook or 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),
|
||
"hook_required": include_hook,
|
||
},
|
||
},
|
||
}
|
||
|
||
|
||
@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, req.platform.value, req.action)
|
||
# 易阻塞动作快速失败(如 check_login_state 的 WCDB 加密读会挂 ~58s),
|
||
# 缩短超时让其尽快落 u2 兜底而非拖垮通道。
|
||
_SLOW_ACTION_TIMEOUTS = {"check_login_state": 20}
|
||
_action_timeout = _SLOW_ACTION_TIMEOUTS.get(req.action, 120)
|
||
params = dict(req.params or {})
|
||
trace_id = str(req.trace_id or params.get("trace_id") or uuid.uuid4().hex)
|
||
params["trace_id"] = trace_id
|
||
result = await _execute_skill(
|
||
req.device_id,
|
||
req.platform.value,
|
||
req.action,
|
||
params,
|
||
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 主控不可用)
|
||
# 将已验证的只读微信回执写入当前 WS 设备快照,供设备详情和首页直接显示。
|
||
if code == 200 and isinstance(payload, dict) and inner_success is not False:
|
||
live = ws_hub.device_info.get(req.device_id)
|
||
if isinstance(live, dict) and req.platform.value == "wechat":
|
||
if req.action == "get_profile":
|
||
profile = _profile_payload(payload)
|
||
if profile.get("nickname"):
|
||
live["wechat_nickname"] = profile.get("nickname")
|
||
if profile.get("wxid"):
|
||
live["wxid"] = profile.get("wxid")
|
||
live["wechat_id"] = profile.get("wxid")
|
||
live["wechat_logged_in"] = True
|
||
if profile.get("wechat_version"):
|
||
live["wechat_version"] = profile.get("wechat_version")
|
||
live["wechat_source_at"] = now_iso()
|
||
elif req.action == "get_contacts":
|
||
count = _contacts_count(payload)
|
||
if count is not None:
|
||
live["friend_count"] = count
|
||
live["wechat_source_at"] = now_iso()
|
||
return {
|
||
"code": code,
|
||
"data": payload,
|
||
"action": req.action,
|
||
"trace_id": trace_id,
|
||
"channel_used": channel,
|
||
"raw_rpc_receipt": result.get("raw_rpc_receipt") or (payload.get("raw_rpc_receipt") if isinstance(payload, dict) else None),
|
||
"readback": result.get("readback") or (payload.get("readback") or payload.get("db_readback") if isinstance(payload, dict) else None),
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# 一、消息管理接口
|
||
# =============================================================================
|
||
|
||
@router.post(
|
||
"/message/send",
|
||
response_model=MessageOperationResponse,
|
||
response_model_exclude_none=True,
|
||
tags=["消息管理"],
|
||
summary="发送微信消息",
|
||
description="通过真实Hook、WebSocket或设备通道发送消息;成功时优先返回可回读的message_id。",
|
||
responses=MESSAGE_WRITE_RESPONSES,
|
||
)
|
||
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 _message_operation_response(
|
||
{
|
||
"code": 503,
|
||
"success": False,
|
||
"error": guard["reason"],
|
||
"error_code": "rate_limited",
|
||
"retry_after_seconds": guard.get("retry_after_seconds", 0),
|
||
"risk_action": guard.get("risk_action"),
|
||
},
|
||
action="send_message",
|
||
target_id=req.to_id,
|
||
fallback_channel="none",
|
||
require_message_id=True,
|
||
)
|
||
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
|
||
)
|
||
# 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 result.get("success") and not mid:
|
||
cooldown = rate_limiter.trip_silent_throttle(req.device_id)
|
||
result["risk"] = "silent_throttle"
|
||
result["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 result.get("success") and mid:
|
||
rate_limiter.reset_silent_throttle(req.device_id)
|
||
ch_used = result.get("channel_used") or channel.value
|
||
return _message_operation_response(
|
||
result,
|
||
action="send_message",
|
||
target_id=req.to_id,
|
||
fallback_channel=ch_used,
|
||
require_message_id=True,
|
||
)
|
||
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 _message_operation_response(
|
||
result,
|
||
action="send_message",
|
||
target_id=req.to_id,
|
||
fallback_channel=Channel.AI_AGENT.value,
|
||
require_message_id=True,
|
||
)
|
||
except Exception:
|
||
pass
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.post(
|
||
"/message/list",
|
||
response_model=MessageListResponse,
|
||
tags=["消息管理"],
|
||
summary="获取微信消息",
|
||
description="从真实Hook或设备执行端读取消息,支持会话、limit和offset。",
|
||
responses=MESSAGE_LIST_RESPONSES,
|
||
)
|
||
async def get_messages(req: GetMessagesRequest):
|
||
"""获取消息列表"""
|
||
|
||
_check_device_online(req.device_id, req.platform.value, "get_messages")
|
||
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")
|
||
raw_code = result.get("code", 200)
|
||
success = raw_code == 200 and payload.get("success") is not False
|
||
error_message = str(payload.get("error") or result.get("error") or result.get("message") or "")
|
||
error_code = "" if success else _message_error_code(error_message, int(raw_code or 0))
|
||
code = 200 if success else (int(raw_code) if isinstance(raw_code, int) and raw_code != 200 else 503)
|
||
channel = str(result.get("_channel_used") or result.get("channel") or payload.get("channel") or "sdk_control")
|
||
trace_id = str(result.get("trace_id") or payload.get("trace_id") or "")
|
||
raw_rpc_receipt = result.get("raw_rpc_receipt") if "raw_rpc_receipt" in result else payload.get("raw_rpc_receipt")
|
||
readback = result.get("readback") if "readback" in result else payload.get("readback")
|
||
if readback is None:
|
||
readback = payload.get("db_readback")
|
||
feedback = _message_feedback(
|
||
success=success,
|
||
verified=success,
|
||
action="get_messages",
|
||
error_code=error_code,
|
||
error_message=error_message,
|
||
)
|
||
return {
|
||
"code": code,
|
||
"success": success,
|
||
"data": {
|
||
"success": success,
|
||
"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,
|
||
"status": "verified" if success else "operation_failed",
|
||
"error_code": error_code,
|
||
"error_message": error_message,
|
||
"trace_id": trace_id,
|
||
"raw_rpc_receipt": raw_rpc_receipt,
|
||
"readback": readback,
|
||
},
|
||
"feedback": feedback,
|
||
"channel_used": channel,
|
||
"trace_id": trace_id,
|
||
}
|
||
|
||
|
||
@router.post(
|
||
"/message/sync-since",
|
||
response_model=MessageSyncResponse,
|
||
tags=["消息管理"],
|
||
summary="增量同步微信消息",
|
||
description="按时间戳筛选真实消息,返回next_since_time供BFF继续拉取。",
|
||
responses=MESSAGE_SYNC_RESPONSES,
|
||
)
|
||
async def sync_messages_since(req: MessageSyncSinceRequest):
|
||
"""按时间增量同步消息,供存客宝/超管轮询拉取。"""
|
||
_check_device_online(req.device_id, req.platform.value, "get_messages")
|
||
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")
|
||
raw_code = result.get("code", 200)
|
||
success = raw_code == 200 and payload.get("success") is not False
|
||
error_message = str(payload.get("error") or result.get("error") or result.get("message") or "")
|
||
error_code = "" if success else _message_error_code(error_message, int(raw_code or 0))
|
||
code = 200 if success else (int(raw_code) if isinstance(raw_code, int) and raw_code != 200 else 503)
|
||
filtered = []
|
||
seen_message_keys = set()
|
||
max_ts = int(req.since_time or 0)
|
||
ordered_messages = sorted(
|
||
messages,
|
||
key=lambda item: (
|
||
_message_timestamp(item),
|
||
str(item.get("message_id") or item.get("msg_svr_id") or item.get("msg_id") or ""),
|
||
),
|
||
)
|
||
for message in ordered_messages:
|
||
ts = _message_timestamp(message)
|
||
message_key = str(
|
||
message.get("message_id")
|
||
or message.get("msg_svr_id")
|
||
or message.get("msg_id")
|
||
or f"{ts}:{message.get('from_id')}:{message.get('content')}"
|
||
)
|
||
is_new = ts > int(req.since_time or 0) or (int(req.since_time or 0) == 0 and ts == 0)
|
||
if is_new and message_key not in seen_message_keys:
|
||
seen_message_keys.add(message_key)
|
||
filtered.append(message)
|
||
if ts > max_ts:
|
||
max_ts = ts
|
||
channel = str(result.get("_channel_used") or result.get("channel") or payload.get("channel") or "sdk_control")
|
||
trace_id = str(result.get("trace_id") or payload.get("trace_id") or uuid.uuid4().hex)
|
||
raw_rpc_receipt = result.get("raw_rpc_receipt") if "raw_rpc_receipt" in result else payload.get("raw_rpc_receipt")
|
||
readback = result.get("readback") if "readback" in result else payload.get("readback")
|
||
if readback is None:
|
||
readback = payload.get("db_readback")
|
||
feedback = _message_feedback(
|
||
success=success,
|
||
verified=success,
|
||
action="get_messages",
|
||
error_code=error_code,
|
||
error_message=error_message,
|
||
)
|
||
return {
|
||
"code": code,
|
||
"success": success,
|
||
"data": {
|
||
"success": success,
|
||
"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,
|
||
"status": "verified" if success else "operation_failed",
|
||
"error_code": error_code,
|
||
"error_message": error_message,
|
||
"trace_id": trace_id,
|
||
"raw_rpc_receipt": raw_rpc_receipt,
|
||
"readback": readback,
|
||
},
|
||
"feedback": feedback,
|
||
"channel_used": channel,
|
||
"trace_id": trace_id,
|
||
}
|
||
|
||
|
||
@router.post(
|
||
"/message/batch-send",
|
||
response_model=BatchMessageResponse,
|
||
tags=["消息管理"],
|
||
summary="批量发送微信消息",
|
||
description="服务端按间隔逐条发送并返回每个接收方的真实结果,部分失败不会包装成整体成功。",
|
||
responses=BATCH_MESSAGE_RESPONSES,
|
||
)
|
||
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"]:
|
||
failed_items = [
|
||
{
|
||
"to_id": (to_id or "").strip(),
|
||
"success": False,
|
||
"verified": False,
|
||
"error_code": "rate_limited",
|
||
"error_message": guard["reason"],
|
||
"channel_used": "none",
|
||
}
|
||
for to_id in req.to_ids
|
||
]
|
||
feedback = _message_feedback(
|
||
success=False,
|
||
verified=False,
|
||
action="batch_send",
|
||
error_code="rate_limited",
|
||
error_message=guard["reason"],
|
||
)
|
||
return {
|
||
"code": 503,
|
||
"success": False,
|
||
"data": {
|
||
"success": False,
|
||
"status": "operation_failed",
|
||
"sent": [],
|
||
"failed": failed_items,
|
||
"total": len(req.to_ids),
|
||
"success_count": 0,
|
||
"failed_count": len(req.to_ids),
|
||
},
|
||
"feedback": feedback,
|
||
"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,
|
||
)
|
||
try:
|
||
result = await _send_via_hook(send_req)
|
||
except Exception as exc:
|
||
result = {
|
||
"success": False,
|
||
"error": str(exc),
|
||
"channel_used": last_channel or "hook/exception",
|
||
}
|
||
last_channel = result.get("channel_used") or last_channel
|
||
if result.get("success"):
|
||
sent.append({
|
||
"to_id": normalized_to_id,
|
||
"success": True,
|
||
"verified": bool(result.get("message_id")),
|
||
"message_id": result.get("message_id"),
|
||
"channel_used": result.get("channel_used") or last_channel,
|
||
})
|
||
else:
|
||
error_message = str(result.get("error") or "unknown")
|
||
failed.append({
|
||
"to_id": normalized_to_id,
|
||
"success": False,
|
||
"verified": False,
|
||
"error_code": _message_error_code(error_message),
|
||
"error_message": error_message,
|
||
"channel_used": result.get("channel_used") or last_channel,
|
||
})
|
||
all_success = not failed and len(sent) == len(req.to_ids)
|
||
status = "verified" if all_success and all(item["verified"] for item in sent) else (
|
||
"accepted_unverified" if all_success else ("partial_success" if sent else "operation_failed")
|
||
)
|
||
code = 200 if all_success else (207 if sent else 503)
|
||
feedback = _message_feedback(
|
||
success=all_success,
|
||
verified=all_success and all(item["verified"] for item in sent),
|
||
action="batch_send",
|
||
error_code="" if all_success else "operation_failed",
|
||
error_message="" if all_success else f"{len(failed)}个接收方发送失败",
|
||
)
|
||
return {
|
||
"code": code,
|
||
"success": all_success,
|
||
"data": {
|
||
"success": all_success,
|
||
"status": status,
|
||
"sent": sent,
|
||
"failed": failed,
|
||
"total": len(req.to_ids),
|
||
"success_count": len(sent),
|
||
"failed_count": len(failed),
|
||
},
|
||
"feedback": feedback,
|
||
"channel_used": last_channel,
|
||
}
|
||
|
||
|
||
@router.post(
|
||
"/comment/reply",
|
||
response_model=MessageOperationResponse,
|
||
response_model_exclude_none=True,
|
||
tags=["消息管理"],
|
||
summary="回复评论",
|
||
description="回复视频号、抖音或小红书评论;confirm=false时执行端可返回dry_run。",
|
||
responses=MESSAGE_WRITE_RESPONSES,
|
||
)
|
||
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 _message_operation_response(
|
||
{
|
||
"code": 503,
|
||
"success": False,
|
||
"error": guard["reason"],
|
||
"error_code": "rate_limited",
|
||
"retry_after_seconds": guard.get("retry_after_seconds", 0),
|
||
},
|
||
action="reply_comment",
|
||
target_id=req.comment_id,
|
||
fallback_channel="none",
|
||
)
|
||
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),
|
||
"confirm": req.confirm,
|
||
}
|
||
)
|
||
return _message_operation_response(
|
||
result,
|
||
action="reply_comment",
|
||
target_id=req.comment_id,
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# 二、好友管理接口
|
||
# =============================================================================
|
||
|
||
def _friend_add_cooldown_config() -> dict:
|
||
"""返回当前加好友冷却配置,供接口回执和运维查询共用。"""
|
||
limit = rate_limiter.get_limit("wechat", "add_friend") or {}
|
||
interval = limit.get("interval") or (10.0, 10.0)
|
||
return {
|
||
"cooldown_seconds": float(limit.get("cooldown_seconds", interval[0])),
|
||
"cooldown_jitter_seconds": float(
|
||
limit.get("cooldown_jitter_seconds", max(0.0, interval[1] - interval[0]))
|
||
),
|
||
"config_key": limit.get("config_key", "WECHAT_ADD_FRIEND_COOLDOWN_SECONDS"),
|
||
"long_pause_enabled": bool(
|
||
getattr(settings, "WECHAT_ADD_FRIEND_LONG_PAUSE_ENABLED", False)
|
||
),
|
||
"daily_max": int(limit.get("daily_max", 50)),
|
||
"new_daily_max": int(limit.get("new_daily_max", 50)),
|
||
}
|
||
|
||
|
||
def _wechat_contact_is_friend(contact: Optional[dict]) -> bool:
|
||
"""按微信联系人type好友位判断,避免把普通群成员误判成好友。"""
|
||
if not isinstance(contact, dict):
|
||
return False
|
||
try:
|
||
return bool(int(str(contact.get("type", "0"))) & 1)
|
||
except (TypeError, ValueError):
|
||
return False
|
||
|
||
|
||
def _friend_add_response(
|
||
target_user_id: str,
|
||
payload: Optional[dict] = None,
|
||
channel_used: str = "none",
|
||
http_code: int = 200,
|
||
) -> dict:
|
||
"""统一加好友成功/失败回执,确保调用方每次都有可执行反馈。"""
|
||
data = dict(payload or {})
|
||
success = bool(data.get("success", False))
|
||
already_friend = bool(data.get("already_friend", False))
|
||
# verified 表示好友申请业务成功已确认;失败回执不得继承底层“RPC已核验”标记。
|
||
verified = bool(success and data.get("verified", True))
|
||
error_code = str(data.get("error_code") or data.get("error") or "")
|
||
error_message = str(
|
||
data.get("error_message")
|
||
or data.get("message")
|
||
or data.get("error")
|
||
or ""
|
||
)
|
||
retry_after = data.get("retry_after_seconds", 0)
|
||
try:
|
||
retry_after = max(0, round(float(retry_after), 1))
|
||
except (TypeError, ValueError):
|
||
retry_after = 0
|
||
|
||
if success:
|
||
status = "already_friend" if already_friend else "request_verified"
|
||
title = "好友已存在" if already_friend else "好友申请已发送"
|
||
message = (
|
||
"目标已在通讯录中,本次按幂等成功处理。"
|
||
if already_friend else "微信已接收好友验证申请。"
|
||
)
|
||
retryable = False
|
||
action = "none"
|
||
level = "success"
|
||
error_code = ""
|
||
error_message = ""
|
||
else:
|
||
lowered = error_code.lower()
|
||
if error_code == "user_id_required":
|
||
status, title, action = "validation_failed", "参数校验失败", "fix_request"
|
||
elif error_code == "group_id_invalid":
|
||
status, title, action = "validation_failed", "群ID格式错误", "fix_request"
|
||
elif error_code == "group_not_found":
|
||
status, title, action = "group_not_found", "未找到微信群", "check_target"
|
||
elif error_code == "group_member_not_found":
|
||
status, title, action = "group_member_not_found", "目标不在该微信群", "check_target"
|
||
elif (
|
||
error_code == "anti_ban_blocked"
|
||
or data.get("wechat_err_code") == -24
|
||
or "frequent" in lowered
|
||
or "rate" in lowered
|
||
):
|
||
status, title, action = "rate_limited", "操作频率受限", "retry"
|
||
elif "timeout" in lowered:
|
||
status, title, action = "timeout", "微信处理超时", "retry"
|
||
elif "not_found" in lowered:
|
||
status, title, action = "user_not_found", "未找到目标微信用户", "check_target"
|
||
elif "offline" in lowered or "device" in lowered:
|
||
status, title, action = "device_offline", "工作手机连接异常", "check_device"
|
||
elif "frida" in lowered or "hook" in lowered or error_code == "no_receiver_registered":
|
||
status, title, action = "hook_unavailable", "微信Hook链路异常", "check_hook"
|
||
else:
|
||
status, title, action = "request_failed", "好友申请失败", "inspect_error"
|
||
retryable = action in {"retry", "check_hook", "check_device"}
|
||
level = "error"
|
||
message = error_message or "微信未确认好友申请,请按错误码处理。"
|
||
|
||
data.update({
|
||
"success": success,
|
||
"verified": verified,
|
||
"already_friend": already_friend,
|
||
"request_sent": bool(success and not already_friend),
|
||
"status": status,
|
||
"target_user_id": target_user_id,
|
||
"error": error_code,
|
||
"error_code": error_code,
|
||
"error_message": error_message,
|
||
"retryable": retryable,
|
||
"retry_after_seconds": retry_after,
|
||
**_friend_add_cooldown_config(),
|
||
})
|
||
feedback = {
|
||
"level": level,
|
||
"title": title,
|
||
"message": message,
|
||
"action": action,
|
||
}
|
||
data["feedback"] = feedback
|
||
trace_id = str(data.get("trace_id") or uuid.uuid4().hex)
|
||
raw_rpc_receipt = data.get("raw_rpc_receipt") or {
|
||
"success": success,
|
||
"verified": verified,
|
||
"already_friend": already_friend,
|
||
"request_sent": bool(success and not already_friend),
|
||
"method": data.get("method", ""),
|
||
"target_user_id": target_user_id,
|
||
}
|
||
readback = data.get("readback")
|
||
if readback is None and isinstance(data.get("contact"), dict):
|
||
readback = {"contact": data["contact"]}
|
||
data.update({
|
||
"trace_id": trace_id,
|
||
"raw_rpc_receipt": raw_rpc_receipt,
|
||
"readback": readback,
|
||
})
|
||
return {
|
||
"code": http_code,
|
||
"success": success,
|
||
"data": data,
|
||
"trace_id": trace_id,
|
||
"raw_rpc_receipt": raw_rpc_receipt,
|
||
"readback": readback,
|
||
"feedback": feedback,
|
||
"channel_used": channel_used,
|
||
}
|
||
|
||
|
||
@router.get(
|
||
"/friend/add/config",
|
||
response_model=dict,
|
||
tags=["好友管理"],
|
||
summary="查询加好友冷却配置",
|
||
description="返回当前生效的本地冷却、浮动值及对应环境变量。",
|
||
)
|
||
async def get_add_friend_config():
|
||
"""查询当前微信加好友冷却参数。"""
|
||
config = _friend_add_cooldown_config()
|
||
return {
|
||
"code": 200,
|
||
"success": True,
|
||
"data": {
|
||
**config,
|
||
"interval_seconds": [
|
||
config["cooldown_seconds"],
|
||
config["cooldown_seconds"] + config["cooldown_jitter_seconds"],
|
||
],
|
||
"configurable_via": [
|
||
"WECHAT_ADD_FRIEND_COOLDOWN_SECONDS",
|
||
"WECHAT_ADD_FRIEND_COOLDOWN_JITTER_SECONDS",
|
||
"WECHAT_ADD_FRIEND_LONG_PAUSE_ENABLED",
|
||
"WECHAT_ADD_FRIEND_DAILY_MAX",
|
||
"WECHAT_ADD_FRIEND_NEW_DAILY_MAX",
|
||
],
|
||
},
|
||
}
|
||
|
||
|
||
@router.post(
|
||
"/friend/add",
|
||
response_model=AddFriendResponse,
|
||
response_model_exclude_none=True,
|
||
tags=["好友管理"],
|
||
summary="无界面添加微信好友",
|
||
description=(
|
||
"固定采用命令行/API→Frida Hook链路。先查询联系人数据库,现有好友幂等返回;"
|
||
"陌生目标通过微信内部搜索与验证请求执行。成功、失败、限流、离线、Hook异常和超时"
|
||
"均返回统一feedback、错误码、重试标记与等待时间。"
|
||
),
|
||
responses={
|
||
200: {
|
||
"description": "统一业务回执;通过success与status判断业务结果。",
|
||
"content": {
|
||
"application/json": {
|
||
"examples": {
|
||
"already_friend": {
|
||
"summary": "目标已是好友",
|
||
"value": {
|
||
"code": 200,
|
||
"success": True,
|
||
"data": {
|
||
"success": True,
|
||
"verified": True,
|
||
"already_friend": True,
|
||
"request_sent": False,
|
||
"status": "already_friend",
|
||
"target_user_id": "13779954946",
|
||
"error": "",
|
||
"error_code": "",
|
||
"error_message": "",
|
||
"retryable": False,
|
||
"retry_after_seconds": 0,
|
||
"cooldown_seconds": 10,
|
||
"cooldown_jitter_seconds": 0,
|
||
"config_key": "WECHAT_ADD_FRIEND_COOLDOWN_SECONDS",
|
||
"method": "contact_db",
|
||
"feedback": {
|
||
"level": "success",
|
||
"title": "好友已存在",
|
||
"message": "目标已在通讯录中,本次按幂等成功处理。",
|
||
"action": "none",
|
||
},
|
||
},
|
||
"feedback": {
|
||
"level": "success",
|
||
"title": "好友已存在",
|
||
"message": "目标已在通讯录中,本次按幂等成功处理。",
|
||
"action": "none",
|
||
},
|
||
"channel_used": "server/frida",
|
||
},
|
||
},
|
||
"rate_limited": {
|
||
"summary": "微信频率限制",
|
||
"value": {
|
||
"code": 200,
|
||
"success": False,
|
||
"data": {
|
||
"success": False,
|
||
"verified": False,
|
||
"already_friend": False,
|
||
"request_sent": False,
|
||
"status": "rate_limited",
|
||
"target_user_id": "16880802666",
|
||
"error": "wechat_search_rejected",
|
||
"error_code": "wechat_search_rejected",
|
||
"error_message": "操作过于频繁,请稍后再试",
|
||
"retryable": True,
|
||
"retry_after_seconds": 30,
|
||
"cooldown_seconds": 10,
|
||
"cooldown_jitter_seconds": 0,
|
||
"config_key": "WECHAT_ADD_FRIEND_COOLDOWN_SECONDS",
|
||
"wechat_err_type": 4,
|
||
"wechat_err_code": -24,
|
||
"feedback": {
|
||
"level": "error",
|
||
"title": "操作频率受限",
|
||
"message": "操作过于频繁,请稍后再试",
|
||
"action": "retry",
|
||
},
|
||
},
|
||
"feedback": {
|
||
"level": "error",
|
||
"title": "操作频率受限",
|
||
"message": "操作过于频繁,请稍后再试",
|
||
"action": "retry",
|
||
},
|
||
"channel_used": "server/frida",
|
||
},
|
||
},
|
||
}
|
||
}
|
||
},
|
||
}
|
||
},
|
||
)
|
||
async def add_friend(req: AddFriendRequest):
|
||
"""无界面添加好友;微信固定走 Frida Hook,并返回幂等与验证状态。"""
|
||
user_id = (req.user_id or "").strip()
|
||
if not user_id:
|
||
return _friend_add_response(
|
||
"",
|
||
{
|
||
"success": False,
|
||
"verified": False,
|
||
"error_code": "user_id_required",
|
||
"error_message": "user_id 不能为空",
|
||
},
|
||
http_code=422,
|
||
)
|
||
# 先以只读 Frida RPC 核对好友状态;现有好友直接幂等返回,
|
||
# 避免现有好友进入新增流程;新请求采用可配置的默认 10 秒冷却。
|
||
if req.platform == Platform.WECHAT:
|
||
from services.server_frida_bridge import server_frida_bridge
|
||
if server_frida_bridge.enabled_for(req.device_id):
|
||
try:
|
||
existing = await server_frida_bridge.execute(
|
||
req.device_id, "get_contact_info", {"wxid": user_id}
|
||
)
|
||
except Exception as exc:
|
||
logger.exception("[friend/add] Frida 好友预检异常")
|
||
return _friend_add_response(
|
||
user_id,
|
||
{
|
||
"success": False,
|
||
"verified": False,
|
||
"error_code": "frida_preflight_failed",
|
||
"error_message": str(exc),
|
||
},
|
||
)
|
||
existing_payload = (
|
||
existing.get("data")
|
||
if isinstance(existing.get("data"), dict)
|
||
else existing
|
||
)
|
||
contact = existing_payload.get("contact") or {}
|
||
if existing_payload.get("success") and _wechat_contact_is_friend(contact):
|
||
return _friend_add_response(
|
||
user_id,
|
||
{
|
||
"success": True,
|
||
"verified": True,
|
||
"already_friend": True,
|
||
"method": "contact_db",
|
||
"contact": contact,
|
||
},
|
||
existing.get("_channel_used", "server/frida"),
|
||
)
|
||
guard = await _anti_ban_guard(req.device_id, req.platform.value, "add_friend", req.message)
|
||
if not guard["pass"]:
|
||
return _friend_add_response(
|
||
user_id,
|
||
{
|
||
"success": False,
|
||
"verified": False,
|
||
"error_code": "anti_ban_blocked",
|
||
"error_message": guard["reason"],
|
||
"risk_action": guard.get("risk_action"),
|
||
"retry_after_seconds": guard.get("retry_after_seconds", 0),
|
||
},
|
||
)
|
||
req.message = guard["content"] or req.message
|
||
|
||
try:
|
||
_check_device_online(req.device_id)
|
||
except HTTPException as exc:
|
||
return _friend_add_response(
|
||
user_id,
|
||
{
|
||
"success": False,
|
||
"verified": False,
|
||
"error_code": "device_offline",
|
||
"error_message": str(exc.detail),
|
||
"retry_after_seconds": 3,
|
||
},
|
||
)
|
||
|
||
try:
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "add_friend",
|
||
{
|
||
"user_id": user_id,
|
||
"message": req.message,
|
||
"source": req.source,
|
||
"source_type": req.source_type,
|
||
},
|
||
hook_only=req.platform == Platform.WECHAT,
|
||
)
|
||
except Exception as exc:
|
||
logger.exception("[friend/add] 执行异常")
|
||
return _friend_add_response(
|
||
user_id,
|
||
{
|
||
"success": False,
|
||
"verified": False,
|
||
"error_code": "add_friend_execution_failed",
|
||
"error_message": str(exc),
|
||
},
|
||
)
|
||
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else result
|
||
payload = dict(payload or {})
|
||
success = bool(payload.get("success", False))
|
||
return _friend_add_response(
|
||
user_id,
|
||
payload,
|
||
result.get("_channel_used", "frida/hook" if success else "none"),
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/friend/add-by-qr",
|
||
response_model=dict,
|
||
tags=["好友管理", "扫一扫"],
|
||
summary="无界面扫码或微信ID添加好友",
|
||
description=(
|
||
"支持qr_content、wechat_id、image_base64、image_url四种输入。图片在服务端解码,"
|
||
"业务动作固定通过Frida Hook调用微信内部搜索与验证网络场景,全程不依赖界面点击。"
|
||
),
|
||
)
|
||
async def add_friend_by_qr(req: AddFriendByQrRequest):
|
||
"""WSS Agent→无线Frida扫码加友;默认只做预检。"""
|
||
trace_id = str(req.trace_id or uuid.uuid4().hex)
|
||
qr_content = (req.qr_content or "").strip()
|
||
wechat_id = (req.wechat_id or "").strip()
|
||
decode_meta: Dict[str, Any] = {}
|
||
if not qr_content and not wechat_id:
|
||
if req.image_base64:
|
||
decode_meta = await asyncio.to_thread(_decode_qr_image_base64, req.image_base64)
|
||
if not decode_meta.get("success"):
|
||
return {
|
||
"code": 422,
|
||
"success": False,
|
||
"data": {
|
||
"success": False,
|
||
"verified": False,
|
||
"request_sent": False,
|
||
"status": "qr_decode_failed",
|
||
"error": decode_meta.get("error_code", "qr_decode_failed"),
|
||
"error_code": decode_meta.get("error_code", "qr_decode_failed"),
|
||
"error_message": decode_meta.get("error_message", "二维码图片未识别到唯一内容"),
|
||
"decoder": decode_meta.get("decoder", "zbarimg"),
|
||
},
|
||
"trace_id": trace_id,
|
||
"channel_used": "local_qr_decoder",
|
||
"raw_rpc_receipt": None,
|
||
"readback": decode_meta,
|
||
}
|
||
qr_content = str(decode_meta["qr_content"])
|
||
elif req.image_url:
|
||
return {
|
||
"code": 503,
|
||
"success": False,
|
||
"data": {
|
||
"success": False,
|
||
"verified": False,
|
||
"request_sent": False,
|
||
"error": "capability_unavailable",
|
||
"error_code": "qr_image_url_transport_unavailable",
|
||
"status": "capability_unavailable",
|
||
},
|
||
"trace_id": trace_id,
|
||
"channel_used": "wss/frida",
|
||
"raw_rpc_receipt": None,
|
||
"readback": None,
|
||
}
|
||
if not qr_content and not wechat_id:
|
||
return {
|
||
"code": 422,
|
||
"success": False,
|
||
"data": {
|
||
"success": False,
|
||
"error": "qr_input_required",
|
||
"error_code": "qr_input_required",
|
||
},
|
||
"trace_id": trace_id,
|
||
"channel_used": "wss/frida/preflight",
|
||
"raw_rpc_receipt": None,
|
||
"readback": None,
|
||
}
|
||
|
||
if qr_content.lower().startswith("weixin://contacts/profile/"):
|
||
return {
|
||
"code": 422,
|
||
"success": False,
|
||
"data": {
|
||
"success": False,
|
||
"verified": False,
|
||
"request_sent": False,
|
||
"status": "wechat_scan_unverified",
|
||
"error": "wechat_scan_not_verified",
|
||
"error_code": "wechat_scan_not_verified",
|
||
"error_message": "wxid文本二维码未通过微信扫码验证,不进入加好友动作。",
|
||
},
|
||
"trace_id": trace_id,
|
||
"channel_used": "local_qr_decoder",
|
||
"raw_rpc_receipt": None,
|
||
"readback": decode_meta or None,
|
||
}
|
||
|
||
target_content = wechat_id or qr_content
|
||
input_mode = "wechat_id" if wechat_id else decode_meta.get("source", "qr_content")
|
||
if req.dry_run or not req.confirm:
|
||
return {
|
||
"code": 200,
|
||
"success": True,
|
||
"data": {
|
||
"success": True,
|
||
"verified": True,
|
||
"request_sent": False,
|
||
"status": "dry_run_ready",
|
||
"input_mode": input_mode,
|
||
"target_present": bool(target_content),
|
||
"next_action": "设置dry_run=false且confirm=true后执行",
|
||
},
|
||
"trace_id": trace_id,
|
||
"channel_used": "wss/frida/preflight",
|
||
"raw_rpc_receipt": None,
|
||
"readback": None,
|
||
}
|
||
|
||
guard = await _anti_ban_guard(
|
||
req.device_id, req.platform.value, "add_friend", req.verify_message
|
||
)
|
||
if not guard["pass"]:
|
||
return _friend_add_response(
|
||
"qr_payload",
|
||
{
|
||
"success": False,
|
||
"verified": False,
|
||
"error_code": "anti_ban_blocked",
|
||
"error_message": guard["reason"],
|
||
"retry_after_seconds": guard.get("retry_after_seconds", 0),
|
||
"risk_action": guard.get("risk_action"),
|
||
"method": "wechat_internal_qr_search_verify",
|
||
},
|
||
)
|
||
try:
|
||
result = await _execute_wechat_wss_frida(
|
||
req.device_id,
|
||
"add_friend_by_qr",
|
||
{
|
||
"qr_content": target_content,
|
||
"verify_message": guard.get("content") or req.verify_message,
|
||
"source_type": 3 if wechat_id else req.source_type,
|
||
},
|
||
trace_id=trace_id,
|
||
)
|
||
except HTTPException as exc:
|
||
return _friend_add_response(
|
||
"qr_payload",
|
||
{
|
||
"success": False,
|
||
"verified": False,
|
||
"error_code": "device_offline",
|
||
"error_message": str(exc.detail),
|
||
"retry_after_seconds": 3,
|
||
},
|
||
)
|
||
except Exception as exc:
|
||
logger.exception("[friend/add-by-qr] 执行异常")
|
||
return _friend_add_response(
|
||
"qr_payload",
|
||
{
|
||
"success": False,
|
||
"verified": False,
|
||
"error_code": "qr_add_execution_failed",
|
||
"error_message": str(exc),
|
||
},
|
||
)
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else result
|
||
payload = dict(payload or {})
|
||
payload.update({
|
||
"input_mode": input_mode,
|
||
"qr_count": decode_meta.get("qr_count", 1),
|
||
"decoder": decode_meta.get("decoder", "caller"),
|
||
})
|
||
response = _friend_add_response(
|
||
str(payload.get("resolved_user_id") or "qr_payload"),
|
||
payload,
|
||
result.get("_channel_used", "websocket/frida"),
|
||
)
|
||
response["trace_id"] = trace_id
|
||
response["raw_rpc_receipt"] = result.get("raw_rpc_receipt")
|
||
response["readback"] = result.get("readback") or payload.get("readback") or payload.get("contact")
|
||
return response
|
||
|
||
|
||
@router.post(
|
||
"/friend/add-from-group",
|
||
response_model=AddFriendResponse,
|
||
response_model_exclude_none=True,
|
||
tags=["好友管理"],
|
||
summary="从微信群成员添加好友",
|
||
description=(
|
||
"无界面读取真实群成员,确认目标属于指定微信群后,"
|
||
"使用微信内部搜索与验证网络场景发起好友申请;现有好友幂等成功。"
|
||
),
|
||
)
|
||
async def add_friend_from_group(req: AddFriendFromGroupRequest):
|
||
"""从真实微信群成员添加好友,固定走Frida Hook。"""
|
||
group_id = req.group_id.strip()
|
||
user_id = req.user_id.strip()
|
||
if not group_id.endswith("@chatroom"):
|
||
return _friend_add_response(
|
||
user_id,
|
||
{
|
||
"success": False,
|
||
"verified": False,
|
||
"error_code": "group_id_invalid",
|
||
"error_message": "group_id 必须以 @chatroom 结尾",
|
||
"group_id": group_id,
|
||
},
|
||
http_code=422,
|
||
)
|
||
guard = await _anti_ban_guard(
|
||
req.device_id,
|
||
req.platform.value,
|
||
"add_friend",
|
||
req.message,
|
||
)
|
||
if not guard["pass"]:
|
||
return _friend_add_response(
|
||
user_id,
|
||
{
|
||
"success": False,
|
||
"verified": False,
|
||
"error_code": "anti_ban_blocked",
|
||
"error_message": guard["reason"],
|
||
"retry_after_seconds": guard.get("retry_after_seconds", 0),
|
||
"risk_action": guard.get("risk_action"),
|
||
"group_id": group_id,
|
||
},
|
||
)
|
||
try:
|
||
result = await _execute_skill(
|
||
req.device_id,
|
||
req.platform.value,
|
||
"add_friend_in_room",
|
||
{
|
||
"group_id": group_id,
|
||
"user_id": user_id,
|
||
"verify_message": guard.get("content") or req.message,
|
||
"source_type": req.source_type,
|
||
"confirm": req.confirm,
|
||
},
|
||
hook_only=req.platform == Platform.WECHAT,
|
||
)
|
||
except Exception as exc:
|
||
logger.exception("[friend/add-from-group] 执行异常")
|
||
return _friend_add_response(
|
||
user_id,
|
||
{
|
||
"success": False,
|
||
"verified": False,
|
||
"error_code": "add_friend_in_room_execution_failed",
|
||
"error_message": str(exc),
|
||
"group_id": group_id,
|
||
},
|
||
)
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else result
|
||
payload = dict(payload or {})
|
||
payload["group_id"] = group_id
|
||
return _friend_add_response(
|
||
user_id,
|
||
payload,
|
||
result.get("_channel_used", "frida/hook"),
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/friend/batch-add-from-group",
|
||
response_model=dict,
|
||
tags=["好友管理"],
|
||
summary="过滤已好友后批量添加微信群成员",
|
||
)
|
||
async def batch_add_friend_from_group(req: GroupBatchAddFriendRequest):
|
||
"""读取真实群成员,按联系人type过滤好友,再添加最多10个非好友。"""
|
||
group_id = req.group_id.strip()
|
||
if not group_id.endswith("@chatroom"):
|
||
return {
|
||
"code": 422,
|
||
"success": False,
|
||
"data": {"error_code": "group_id_invalid", "group_id": group_id},
|
||
}
|
||
|
||
group_result = await _execute_skill(
|
||
req.device_id,
|
||
req.platform.value,
|
||
"get_group_members",
|
||
{"group_id": group_id},
|
||
hook_only=req.platform == Platform.WECHAT,
|
||
)
|
||
group_payload = (
|
||
group_result.get("data")
|
||
if isinstance(group_result.get("data"), dict)
|
||
else group_result
|
||
)
|
||
members = list(group_payload.get("members") or [])
|
||
if not group_payload.get("success") or not members:
|
||
return {
|
||
"code": 200,
|
||
"success": False,
|
||
"data": {
|
||
"error_code": group_payload.get("error_code", "group_not_found"),
|
||
"error_message": group_payload.get("error", "未读取到群成员"),
|
||
"group_id": group_id,
|
||
},
|
||
"channel_used": group_result.get("_channel_used", "none"),
|
||
}
|
||
|
||
if req.random_select:
|
||
random.SystemRandom().shuffle(members)
|
||
|
||
already_friends = []
|
||
non_friends = []
|
||
precheck_errors = []
|
||
for member in members:
|
||
user_id = str(member.get("wxid") or "").strip()
|
||
if not user_id:
|
||
continue
|
||
contact_result = await _execute_skill(
|
||
req.device_id,
|
||
req.platform.value,
|
||
"get_contact_info",
|
||
{"wxid": user_id},
|
||
hook_only=req.platform == Platform.WECHAT,
|
||
)
|
||
contact_payload = (
|
||
contact_result.get("data")
|
||
if isinstance(contact_result.get("data"), dict)
|
||
else contact_result
|
||
)
|
||
contact = contact_payload.get("contact") or {}
|
||
row = {
|
||
"user_id": user_id,
|
||
"display_name": member.get("display_name", ""),
|
||
"contact_type": str(contact.get("type", "0")),
|
||
"nickname": contact.get("nickname", ""),
|
||
"remark": contact.get("conRemark", ""),
|
||
}
|
||
if contact_payload.get("success") and _wechat_contact_is_friend(contact):
|
||
row["status"] = "already_friend"
|
||
already_friends.append(row)
|
||
elif contact_payload.get("success") or contact_payload.get("error"):
|
||
row["status"] = "non_friend"
|
||
non_friends.append(row)
|
||
else:
|
||
row["status"] = "precheck_failed"
|
||
row["error"] = contact_payload.get("error", "contact_precheck_failed")
|
||
precheck_errors.append(row)
|
||
if len(non_friends) >= req.limit:
|
||
break
|
||
|
||
selected = non_friends[: req.limit]
|
||
results = []
|
||
for index, target in enumerate(selected):
|
||
response = await add_friend_from_group(AddFriendFromGroupRequest(
|
||
device_id=req.device_id,
|
||
platform=req.platform,
|
||
group_id=group_id,
|
||
user_id=target["user_id"],
|
||
message=req.message,
|
||
source_type=req.source_type,
|
||
confirm=True,
|
||
))
|
||
results.append({
|
||
"user_id": target["user_id"],
|
||
"success": bool(response.get("success", False)),
|
||
"data": response.get("data", {}),
|
||
"feedback": response.get("feedback", {}),
|
||
"channel_used": response.get("channel_used", "none"),
|
||
})
|
||
if (
|
||
index + 1 < len(selected)
|
||
and req.interval > 0
|
||
and bool((response.get("data") or {}).get("request_sent", False))
|
||
):
|
||
await asyncio.sleep(req.interval)
|
||
|
||
succeeded = sum(1 for item in results if item["success"])
|
||
return {
|
||
"code": 200,
|
||
"success": bool(results) and succeeded == len(results),
|
||
"data": {
|
||
"group_id": group_id,
|
||
"group_member_count": len(members),
|
||
"requested_limit": req.limit,
|
||
"selected_non_friend_count": len(selected),
|
||
"filtered_already_friend_count": len(already_friends),
|
||
"precheck_error_count": len(precheck_errors),
|
||
"succeeded": succeeded,
|
||
"failed": len(results) - succeeded,
|
||
"already_friends": already_friends,
|
||
"selected_non_friends": selected,
|
||
"precheck_errors": precheck_errors,
|
||
"results": results,
|
||
"interval_seconds": req.interval,
|
||
},
|
||
"channel_used": "server/frida",
|
||
}
|
||
|
||
|
||
@router.post("/friend/accept", response_model=dict, tags=["好友管理"])
|
||
async def accept_friend(req: AcceptFriendRequest):
|
||
"""通过好友请求;微信使用请求记录中的 encrypt_username/ticket。"""
|
||
_check_device_online(req.device_id)
|
||
user_id = (req.user_id or "").strip()
|
||
# 通过请求前必须先回读联系人。现有好友按幂等成功处理,避免错误地把普通
|
||
# wxid 送入 MM_VERIFYUSER_VERIFYOK 场景,部分微信版本会因此终止进程。
|
||
if req.platform == Platform.WECHAT and user_id:
|
||
from services.server_frida_bridge import server_frida_bridge
|
||
if server_frida_bridge.enabled_for(req.device_id):
|
||
try:
|
||
existing = await server_frida_bridge.execute(
|
||
req.device_id, "get_contact_info", {"wxid": user_id}
|
||
)
|
||
existing_payload = (
|
||
existing.get("data")
|
||
if isinstance(existing.get("data"), dict)
|
||
else existing
|
||
)
|
||
contact = existing_payload.get("contact") or {}
|
||
if existing_payload.get("success") and _wechat_contact_is_friend(contact):
|
||
trace_id = uuid.uuid4().hex
|
||
raw_receipt = {
|
||
"success": True,
|
||
"verified": True,
|
||
"already_friend": True,
|
||
"request_accepted": False,
|
||
"method": "contact_db",
|
||
"user_id": user_id,
|
||
}
|
||
return {
|
||
"code": 200,
|
||
"success": True,
|
||
"data": {
|
||
**raw_receipt,
|
||
"trace_id": trace_id,
|
||
"channel": "frida/hook",
|
||
"raw_rpc_receipt": raw_receipt,
|
||
"readback": {"contact": contact},
|
||
},
|
||
"trace_id": trace_id,
|
||
"raw_rpc_receipt": raw_receipt,
|
||
"readback": {"contact": contact},
|
||
"channel_used": existing.get("_channel_used", "server/frida"),
|
||
}
|
||
except Exception:
|
||
logger.exception("[friend/accept] Frida 好友预检异常")
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "accept_friend",
|
||
{
|
||
"user_id": user_id,
|
||
"encrypt_username": req.encrypt_username or user_id,
|
||
"ticket": req.ticket,
|
||
},
|
||
hook_only=req.platform == Platform.WECHAT,
|
||
)
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else result
|
||
return {
|
||
"code": 200,
|
||
"success": bool(payload.get("success", False)),
|
||
"data": payload,
|
||
"channel_used": result.get("_channel_used", "sdk_control"),
|
||
}
|
||
|
||
|
||
@router.post(
|
||
"/friend/set-remark",
|
||
response_model=dict,
|
||
tags=["好友管理"],
|
||
summary="单个微信好友改名",
|
||
description=(
|
||
"修改单个微信好友备注。微信固定通过Frida写入联系人数据库,"
|
||
"随后回读conRemark;只有回读值与新备注一致才返回success=true。"
|
||
),
|
||
responses={
|
||
200: {
|
||
"description": "改名执行结果",
|
||
"content": {"application/json": {"example": {
|
||
"code": 200,
|
||
"success": True,
|
||
"data": {
|
||
"success": True,
|
||
"verified": True,
|
||
"user_id": "wxid_example",
|
||
"remark": "张总|估值88",
|
||
"method": "contact_db_remark",
|
||
},
|
||
"channel_used": "server/frida",
|
||
}}},
|
||
},
|
||
422: {"description": "请求参数校验失败"},
|
||
503: {"description": "设备或Frida通道未就绪"},
|
||
},
|
||
)
|
||
async def set_friend_remark(req: SetRemarkRequest):
|
||
"""设置好友备注并由 Hook 数据库回读确认。"""
|
||
_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},
|
||
hook_only=req.platform == Platform.WECHAT,
|
||
)
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else result
|
||
return {
|
||
"code": 200,
|
||
"success": bool(payload.get("success", False)),
|
||
"data": payload,
|
||
"channel_used": result.get("_channel_used", "sdk_control"),
|
||
}
|
||
|
||
|
||
@router.post(
|
||
"/friend/batch-set-remark",
|
||
response_model=dict,
|
||
tags=["好友管理"],
|
||
summary="批量设置微信好友备注",
|
||
description=(
|
||
"批量修改微信好友名称(实际修改好友备注)。逐项复用单好友改名接口,"
|
||
"通过Frida写入并回读conRemark。返回总数、成功数、失败数及每个好友的"
|
||
"真实回执,单次支持1~500项。"
|
||
),
|
||
responses={
|
||
200: {
|
||
"description": "批量改名执行结果",
|
||
"content": {"application/json": {"example": {
|
||
"code": 200,
|
||
"success": True,
|
||
"data": {
|
||
"total": 2,
|
||
"succeeded": 2,
|
||
"failed": 0,
|
||
"results": [],
|
||
},
|
||
"channel_used": "server/frida",
|
||
}}},
|
||
},
|
||
422: {"description": "请求为空、超过500项或字段格式错误"},
|
||
503: {"description": "设备或Frida通道未就绪"},
|
||
},
|
||
)
|
||
async def batch_set_friend_remark(req: BatchSetRemarkRequest):
|
||
"""批量设置好友备注;逐项通过Frida写入并回读验证。"""
|
||
_check_device_online(req.device_id)
|
||
results = []
|
||
for item in req.items:
|
||
response = await set_friend_remark(SetRemarkRequest(
|
||
device_id=req.device_id,
|
||
platform=req.platform,
|
||
user_id=item.user_id.strip(),
|
||
remark=item.remark.strip(),
|
||
))
|
||
results.append({
|
||
"user_id": item.user_id,
|
||
"remark": item.remark,
|
||
"value_score": item.value_score,
|
||
"success": bool(response.get("success", False)),
|
||
"data": response.get("data", {}),
|
||
"channel_used": response.get("channel_used", "none"),
|
||
})
|
||
succeeded = sum(1 for item in results if item["success"])
|
||
return {
|
||
"code": 200,
|
||
"success": succeeded == len(results),
|
||
"data": {
|
||
"total": len(results),
|
||
"succeeded": succeeded,
|
||
"failed": len(results) - succeeded,
|
||
"results": results,
|
||
},
|
||
"channel_used": "server/frida" if req.platform == Platform.WECHAT else "mixed",
|
||
}
|
||
|
||
|
||
@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},
|
||
hook_only=req.platform == Platform.WECHAT,
|
||
)
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else result
|
||
return {
|
||
"code": 200,
|
||
"success": bool(payload.get("success", False)),
|
||
"data": payload,
|
||
"channel_used": result.get("_channel_used", "sdk_control"),
|
||
}
|
||
|
||
|
||
@router.post("/friend/batch-add", response_model=dict, tags=["好友管理"])
|
||
async def batch_add_friend(req: BatchAddFriendRequest):
|
||
"""批量添加好友;逐个复用单加接口,并按微信retry_after_seconds自动等待重试。"""
|
||
_check_device_online(req.device_id)
|
||
targets = list(dict.fromkeys(x.strip() for x in req.user_ids if x and x.strip()))
|
||
results = []
|
||
for index, user_id in enumerate(targets):
|
||
attempts = []
|
||
item = {}
|
||
for attempt_index in range(req.max_retries + 1):
|
||
item = await add_friend(AddFriendRequest(
|
||
device_id=req.device_id,
|
||
platform=req.platform,
|
||
user_id=user_id,
|
||
message=req.message,
|
||
source="batch_add",
|
||
))
|
||
item_data = item.get("data", {})
|
||
attempts.append({
|
||
"attempt": attempt_index + 1,
|
||
"success": bool(item.get("success", False)),
|
||
"status": item_data.get("status", ""),
|
||
"error_code": item_data.get("error_code", ""),
|
||
"retry_after_seconds": item_data.get("retry_after_seconds", 0),
|
||
"channel_used": item.get("channel_used", "none"),
|
||
})
|
||
if item.get("success") or not item_data.get("retryable"):
|
||
break
|
||
if attempt_index >= req.max_retries:
|
||
break
|
||
wait_seconds = float(req.interval)
|
||
if req.respect_retry_after:
|
||
try:
|
||
wait_seconds = max(
|
||
wait_seconds,
|
||
float(item_data.get("retry_after_seconds") or 0),
|
||
)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
await asyncio.sleep(min(wait_seconds, req.max_retry_wait))
|
||
results.append({
|
||
"user_id": user_id,
|
||
"success": bool(item.get("success", False)),
|
||
"data": item.get("data", {}),
|
||
"feedback": item.get("feedback", {}),
|
||
"channel_used": item.get("channel_used", "none"),
|
||
"attempt_count": len(attempts),
|
||
"attempts": attempts,
|
||
})
|
||
if index + 1 < len(targets) and req.interval > 0:
|
||
final_data = item.get("data", {})
|
||
wait_seconds = float(req.interval)
|
||
if req.respect_retry_after and final_data.get("retryable"):
|
||
try:
|
||
wait_seconds = max(
|
||
wait_seconds,
|
||
float(final_data.get("retry_after_seconds") or 0),
|
||
)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
await asyncio.sleep(min(wait_seconds, req.max_retry_wait))
|
||
succeeded = sum(1 for item in results if item["success"])
|
||
trace_id = uuid.uuid4().hex
|
||
raw_rpc_receipt = {
|
||
"success": succeeded == len(results),
|
||
"requested_count": len(req.user_ids),
|
||
"deduplicated_count": len(targets),
|
||
"succeeded": succeeded,
|
||
"failed": len(results) - succeeded,
|
||
"items": [
|
||
item.get("data", {}).get("raw_rpc_receipt")
|
||
for item in results
|
||
],
|
||
}
|
||
readback = {
|
||
"contacts": [
|
||
item.get("data", {}).get("readback")
|
||
for item in results
|
||
],
|
||
}
|
||
return {
|
||
"code": 200,
|
||
"success": succeeded == len(results),
|
||
"data": {
|
||
"total": len(results),
|
||
"succeeded": succeeded,
|
||
"failed": len(results) - succeeded,
|
||
"results": results,
|
||
"local_interval_seconds": req.interval,
|
||
"max_retries": req.max_retries,
|
||
"respect_retry_after": req.respect_retry_after,
|
||
"trace_id": trace_id,
|
||
"raw_rpc_receipt": raw_rpc_receipt,
|
||
"readback": readback,
|
||
},
|
||
"trace_id": trace_id,
|
||
"raw_rpc_receipt": raw_rpc_receipt,
|
||
"readback": readback,
|
||
"channel_used": "server/frida" if req.platform == Platform.WECHAT else "mixed",
|
||
}
|
||
|
||
|
||
@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=HeadlessActionResponse,
|
||
response_model_exclude_none=False,
|
||
tags=["群聊管理"],
|
||
summary="设置群欢迎语(无线Frida)",
|
||
description=(
|
||
"仅经WSS Agent与无线Frida RPC执行。dry_run返回群表探测回读;"
|
||
"真实写入只有chatroom_notice与welcome_text一致时才标记成功。"
|
||
),
|
||
responses={
|
||
200: {"description": "群表探测或已验证写入回执"},
|
||
422: {"description": "群ID或欢迎语参数错误"},
|
||
503: {"description": "WSS、Frida、内部Scene或chatroom回读未就绪"},
|
||
},
|
||
)
|
||
async def set_group_welcome(req: SetGroupWelcomeRequest, response: Response = None):
|
||
"""对齐setGroupWelcome RPC;当前内核未定位真实Scene时保持能力不足。"""
|
||
if req.platform != Platform.WECHAT or not req.group_id.endswith("@chatroom"):
|
||
result = _strict_frida_operation_response(
|
||
{
|
||
"success": False,
|
||
"trace_id": req.trace_id or "",
|
||
"error_code": "invalid_params",
|
||
"error_message": "group_id必须为以@chatroom结尾的真实微信群ID",
|
||
"raw_rpc_receipt": None,
|
||
"readback": None,
|
||
"_channel_used": "preflight",
|
||
},
|
||
action="set_group_welcome",
|
||
target_id=req.group_id,
|
||
)
|
||
result["code"] = 422
|
||
result["data"]["status"] = "validation_failed"
|
||
result["feedback"] = {"level": "error", "title": "参数校验失败", "message": result["data"]["error_message"], "action": "fix_request"}
|
||
return _set_http_status(response, result)
|
||
|
||
params = {
|
||
"group_id": req.group_id,
|
||
"welcome": req.welcome_text,
|
||
"welcome_text": req.welcome_text,
|
||
"welcome_message": req.welcome_text,
|
||
"welcome_image": req.welcome_image,
|
||
"dry_run": req.dry_run,
|
||
"confirm": req.confirm,
|
||
"trace_id": req.trace_id or uuid.uuid4().hex,
|
||
}
|
||
result = await _execute_skill(
|
||
req.device_id,
|
||
req.platform.value,
|
||
"set_group_welcome",
|
||
params,
|
||
hook_only=True,
|
||
)
|
||
body = _strict_frida_operation_response(
|
||
result,
|
||
action="set_group_welcome",
|
||
target_id=req.group_id,
|
||
require_readback=True,
|
||
)
|
||
return _set_http_status(response, body)
|
||
|
||
|
||
@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):
|
||
"""获取群聊列表;微信离线于WebSocket时仍可走server/Frida。"""
|
||
limit = _bounded_limit(limit, 100, 500)
|
||
offset = _bounded_offset(offset)
|
||
|
||
result = await _execute_skill(
|
||
device_id, platform.value, "get_groups",
|
||
{"limit": limit, "offset": offset},
|
||
hook_only=platform == Platform.WECHAT,
|
||
)
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else result
|
||
return {
|
||
"code": 200,
|
||
"success": bool(payload.get("success", False)),
|
||
"data": payload,
|
||
"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):
|
||
"""获取群成员列表;微信离线于WebSocket时仍可走server/Frida。"""
|
||
result = await _execute_skill(
|
||
device_id, platform.value, "get_group_members",
|
||
{"group_id": group_id},
|
||
hook_only=platform == Platform.WECHAT,
|
||
)
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else result
|
||
return {
|
||
"code": 200,
|
||
"success": bool(payload.get("success", False)),
|
||
"data": payload,
|
||
"channel_used": result.get("_channel_used", "sdk_control")
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# 四、标签管理接口
|
||
# =============================================================================
|
||
|
||
_TAG_CREATE_IDEMPOTENCY: Dict[str, Dict[str, Any]] = {}
|
||
_TAG_CREATE_IDEMPOTENCY_MAX = 256
|
||
|
||
|
||
def _tag_create_key(req: CreateTagRequest, trace_id: str) -> str:
|
||
"""确保进入 Hook 的创建请求始终带有非空幂等键。"""
|
||
return str(req.idempotency_key or f"tag-create:{req.device_id}:{trace_id}")
|
||
|
||
|
||
def _tag_create_fingerprint(req: CreateTagRequest) -> str:
|
||
"""幂等比较只看业务参数,不把 trace_id/retry 误判为异参。"""
|
||
return hashlib.sha256(json.dumps({
|
||
"device_id": req.device_id,
|
||
"platform": req.platform.value,
|
||
"tag_name": req.tag_name,
|
||
"dry_run": req.dry_run,
|
||
"confirm": req.confirm,
|
||
}, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
|
||
|
||
|
||
def _tag_create_feedback(success: bool, error_code: str, error_message: str) -> dict:
|
||
if success:
|
||
return {"level": "success", "title": "标签创建已确认", "message": "Hook已返回创建回执。", "action": "none"}
|
||
if error_code in {"dry_run_no_write", "confirm_required"}:
|
||
return {"level": "warning", "title": "标签创建未写入", "message": error_message, "action": "confirm"}
|
||
action = "retry" if error_code in {"rate_limited", "hook_unavailable", "device_offline", "timeout"} else "fix_request"
|
||
return {"level": "error", "title": "标签创建失败", "message": error_message or error_code, "action": action}
|
||
|
||
|
||
def _tag_create_response(
|
||
req: CreateTagRequest,
|
||
*,
|
||
idempotency_key: str,
|
||
trace_id: str,
|
||
result: Optional[dict] = None,
|
||
verified_no_write: bool = False,
|
||
idempotency_reused: bool = False,
|
||
) -> dict:
|
||
"""把 Hook/预检结果收口成稳定的 tag/create HTTP 合同。"""
|
||
source = result if isinstance(result, dict) else {}
|
||
payload = source.get("data") if isinstance(source.get("data"), dict) else source
|
||
payload = dict(payload or {})
|
||
error_code = str(payload.get("error_code") or source.get("error_code") or "")
|
||
error_message = str(
|
||
payload.get("error_message")
|
||
or payload.get("error")
|
||
or source.get("error_message")
|
||
or source.get("error")
|
||
or ""
|
||
)
|
||
raw_code = source.get("code", payload.get("code", 200))
|
||
try:
|
||
code = int(raw_code)
|
||
except (TypeError, ValueError):
|
||
code = 200
|
||
if code == 200 and error_code in {"invalid_params", "idempotency_conflict"}:
|
||
code = 422
|
||
elif code == 200 and error_code in {"rate_limited", "too_many_requests"}:
|
||
code = 429
|
||
elif code == 200 and error_code in {"hook_unavailable", "capability_unavailable", "device_offline", "timeout"}:
|
||
code = 503
|
||
success = bool(payload.get("success", source.get("success", False)) is True and code < 400)
|
||
status = str(payload.get("status") or source.get("status") or ("verified" if success else "operation_failed"))
|
||
channel_used = str(
|
||
source.get("_channel_used")
|
||
or source.get("channel_used")
|
||
or payload.get("channel_used")
|
||
or payload.get("channel")
|
||
or ("dry_run" if verified_no_write else "frida_rpc")
|
||
)
|
||
response_trace_id = str(payload.get("trace_id") or source.get("trace_id") or trace_id)
|
||
data = dict(payload)
|
||
data.update({
|
||
"success": success,
|
||
"verified": bool(payload.get("verified", source.get("verified", success))) if success else False,
|
||
"verified_no_write": bool(verified_no_write or payload.get("verified_no_write", False)),
|
||
"status": status,
|
||
"action": "create_tag",
|
||
"tag_name": req.tag_name,
|
||
"idempotency_key": idempotency_key,
|
||
"idempotency_reused": idempotency_reused,
|
||
"trace_id": response_trace_id,
|
||
"error_code": "" if success else error_code,
|
||
"error_message": "" if success else error_message,
|
||
"retryable": bool(payload.get("retryable", source.get("retryable", code in {429, 503}))) if not success else False,
|
||
"retry_after_seconds": float(payload.get("retry_after_seconds") or source.get("retry_after_seconds") or 0),
|
||
"channel_used": channel_used,
|
||
"raw_rpc_receipt": source.get("raw_rpc_receipt", payload.get("raw_rpc_receipt")),
|
||
"readback": source.get("readback", payload.get("readback")),
|
||
})
|
||
body = {
|
||
"code": code,
|
||
"success": success,
|
||
"data": data,
|
||
"feedback": _tag_create_feedback(success, data["error_code"], data["error_message"]),
|
||
"channel_used": channel_used,
|
||
"trace_id": response_trace_id,
|
||
"idempotency_key": idempotency_key,
|
||
"idempotency_reused": idempotency_reused,
|
||
"verified_no_write": data["verified_no_write"],
|
||
}
|
||
return body
|
||
|
||
|
||
def _tag_create_preflight_response(req: CreateTagRequest, idempotency_key: str, trace_id: str) -> dict:
|
||
error_code = "dry_run_no_write" if req.dry_run else "confirm_required"
|
||
message = (
|
||
"参数已校验,未执行微信写入;如需真实创建,需显式confirm=true且dry_run=false。"
|
||
if req.dry_run else
|
||
"创建标签需要confirm=true且dry_run=false,本次未执行微信写入。"
|
||
)
|
||
return _tag_create_response(
|
||
req,
|
||
idempotency_key=idempotency_key,
|
||
trace_id=trace_id,
|
||
result={
|
||
"code": 200,
|
||
"success": False,
|
||
"data": {
|
||
"success": False,
|
||
"status": error_code,
|
||
"error_code": error_code,
|
||
"error_message": message,
|
||
"readback": {"write_performed": False, "source": "route_preflight"},
|
||
},
|
||
"_channel_used": "dry_run",
|
||
},
|
||
verified_no_write=True,
|
||
)
|
||
|
||
@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=TagCreateResponse,
|
||
response_model_exclude_none=False,
|
||
tags=["标签管理"],
|
||
summary="创建微信标签(统一合同)",
|
||
description=(
|
||
"默认dry_run且零写;真实创建固定经WSS Agent→无线Frida Hook。"
|
||
"请求中的dry_run、confirm、idempotency_key、trace_id和retry会原样进入执行载荷;"
|
||
"同一设备同一幂等键同参数只执行一次,异参数返回422。"
|
||
),
|
||
responses={
|
||
200: {"description": "dry_run零写、confirm_required或已完成的业务回执"},
|
||
422: {"description": "请求字段校验失败或同一幂等键参数冲突"},
|
||
429: {"description": "Hook或风控返回限流"},
|
||
503: {"description": "设备、WSS、Frida Hook或执行通道未就绪"},
|
||
},
|
||
)
|
||
async def create_tag(req: CreateTagRequest, response: Response = None):
|
||
"""创建标签;默认和未确认路径均在路由层保证零写。"""
|
||
trace_id = str(req.trace_id or uuid.uuid4().hex)
|
||
idempotency_key = _tag_create_key(req, trace_id)
|
||
cache_key = f"{req.device_id}:{idempotency_key}"
|
||
fingerprint = _tag_create_fingerprint(req)
|
||
cached = _TAG_CREATE_IDEMPOTENCY.get(cache_key)
|
||
if cached:
|
||
if cached["fingerprint"] != fingerprint:
|
||
conflict = _tag_create_response(
|
||
req,
|
||
idempotency_key=idempotency_key,
|
||
trace_id=trace_id,
|
||
result={
|
||
"code": 422,
|
||
"success": False,
|
||
"data": {
|
||
"status": "idempotency_conflict",
|
||
"error_code": "idempotency_conflict",
|
||
"error_message": "同一幂等键不能提交不同的标签参数。",
|
||
},
|
||
"_channel_used": "idempotency",
|
||
},
|
||
verified_no_write=True,
|
||
)
|
||
return _set_http_status(response, conflict)
|
||
replay = dict(cached["response"])
|
||
replay["idempotency_reused"] = True
|
||
replay["data"] = dict(replay.get("data") or {})
|
||
replay["data"]["idempotency_reused"] = True
|
||
return _set_http_status(response, replay)
|
||
|
||
# 先在 HTTP 层完成零写门禁;不触达 Hook,避免旧 Hook/连接异常造成演练写入。
|
||
if req.dry_run or not req.confirm:
|
||
body = _tag_create_preflight_response(req, idempotency_key, trace_id)
|
||
else:
|
||
_check_device_online(req.device_id, req.platform.value, "create_tag")
|
||
params = {
|
||
"name": req.tag_name,
|
||
"tag_name": req.tag_name,
|
||
"dry_run": req.dry_run,
|
||
"confirm": req.confirm,
|
||
"idempotency_key": idempotency_key,
|
||
"trace_id": trace_id,
|
||
"retry": req.retry,
|
||
}
|
||
result = await _execute_skill(
|
||
req.device_id,
|
||
req.platform.value,
|
||
"create_tag",
|
||
params,
|
||
hook_only=True,
|
||
)
|
||
body = _tag_create_response(
|
||
req,
|
||
idempotency_key=idempotency_key,
|
||
trace_id=trace_id,
|
||
result=result,
|
||
)
|
||
|
||
_TAG_CREATE_IDEMPOTENCY[cache_key] = {"fingerprint": fingerprint, "response": body}
|
||
while len(_TAG_CREATE_IDEMPOTENCY) > _TAG_CREATE_IDEMPOTENCY_MAX:
|
||
_TAG_CREATE_IDEMPOTENCY.pop(next(iter(_TAG_CREATE_IDEMPOTENCY)))
|
||
return _set_http_status(response, body)
|
||
|
||
|
||
@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/material/generate", response_model=dict, tags=["朋友圈管理"])
|
||
async def generate_moments_material(req: GenerateMomentsMaterialRequest):
|
||
"""自动生成文字、图片或视频素材。"""
|
||
from services.moments_material import generate_material
|
||
try:
|
||
data = await asyncio.to_thread(generate_material, req.topic, req.content, req.media_type, req.duration)
|
||
return {"code": 200, "success": True, "data": data}
|
||
except Exception as exc:
|
||
logger.exception("朋友圈素材生成异常: %s", exc)
|
||
return {"code": 200, "success": False, "error": str(exc), "data": {}}
|
||
|
||
|
||
@router.post("/moments/auto", response_model=dict, tags=["朋友圈管理"])
|
||
async def auto_post_moments(req: AutoMomentsRequest):
|
||
"""生成素材并通过 Frida/Hook 发布朋友圈。"""
|
||
from services.moments_material import generate_material
|
||
data = await asyncio.to_thread(generate_material, req.topic, req.content, req.media_type, req.duration)
|
||
base = req.public_base_url.rstrip("/")
|
||
images = [base + path for path in data["images"]]
|
||
video_url = base + data["video_url"] if data.get("video_url") else None
|
||
publish = await post_moments(PostMomentsRequest(
|
||
device_id=req.device_id, platform=req.platform, content=data["content"],
|
||
images=images, video_url=video_url, hook_only=req.hook_only,
|
||
))
|
||
return {"code": 200, "success": bool(publish.get("success")), "material": data, "publish": publish}
|
||
|
||
@router.post("/moments/post", response_model=dict, tags=["朋友圈管理"])
|
||
async def post_moments(req: PostMomentsRequest):
|
||
"""发布朋友圈/瞬间(统一返回 200,业务失败用 success=false 表示,避免 502)"""
|
||
key = build_moments_idempotency_key(
|
||
req.device_id, req.content, req.images, req.idempotency_key
|
||
)
|
||
|
||
async def publish_once():
|
||
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",
|
||
}
|
||
|
||
result = {}
|
||
try:
|
||
_check_device_online(req.device_id)
|
||
result = await _execute_skill(
|
||
req.device_id,
|
||
req.platform.value,
|
||
"post_moments",
|
||
{
|
||
"content": guard["content"],
|
||
"images": req.images,
|
||
"image_urls": req.images or [],
|
||
"video_url": req.video_url,
|
||
"location": req.location,
|
||
"visible_list": req.visible_list,
|
||
"invisible_list": req.invisible_list,
|
||
},
|
||
timeout=60,
|
||
hook_only=req.hook_only,
|
||
)
|
||
rc = result.get("code", 200)
|
||
if rc != 200 or result.get("success") is False:
|
||
channel = result.get("_channel_used") or result.get("channel") or "sdk_control"
|
||
return {
|
||
"code": 200,
|
||
"success": False,
|
||
"error": result.get("message", result.get("error", "发布失败")),
|
||
"data": result.get("data") if isinstance(result.get("data"), dict) else result,
|
||
"trace_id": result.get("trace_id"),
|
||
"raw_rpc_receipt": result.get("raw_rpc_receipt"),
|
||
"readback": result.get("readback"),
|
||
"channel_used": "frida_rpc" if "frida" in channel else channel,
|
||
}
|
||
channel = result.get("_channel_used") or result.get("channel") or "sdk_control"
|
||
return {
|
||
"code": 200,
|
||
"success": True,
|
||
"data": result.get("data") if isinstance(result.get("data"), dict) else result,
|
||
"trace_id": result.get("trace_id"),
|
||
"raw_rpc_receipt": result.get("raw_rpc_receipt"),
|
||
"readback": result.get("readback"),
|
||
"channel_used": "frida_rpc" if "frida" in channel else channel,
|
||
}
|
||
except HTTPException:
|
||
raise
|
||
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"),
|
||
}
|
||
|
||
response, reused, state = await moments_idempotency.execute(key, publish_once)
|
||
return {
|
||
**response,
|
||
"idempotency_key": key,
|
||
"idempotency_reused": reused,
|
||
"idempotency_state": state,
|
||
}
|
||
|
||
|
||
@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",
|
||
{"sns_id": req.sns_id, "user_id": req.user_id, "post_index": req.post_index}
|
||
)
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else result
|
||
channel = result.get("_channel_used") or result.get("channel") or "sdk_control"
|
||
return {
|
||
"code": 200,
|
||
"success": bool(payload.get("success")) if isinstance(payload, dict) else False,
|
||
"data": payload,
|
||
"trace_id": result.get("trace_id") or payload.get("trace_id"),
|
||
"raw_rpc_receipt": result.get("raw_rpc_receipt") or payload,
|
||
"readback": result.get("readback") or payload.get("readback"),
|
||
"channel_used": "frida_rpc" if "frida" in channel else channel,
|
||
}
|
||
|
||
|
||
@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",
|
||
{
|
||
"sns_id": req.sns_id,
|
||
"user_id": req.user_id,
|
||
"post_index": req.post_index,
|
||
"comment": guard.get("content", req.comment),
|
||
"reply_to": req.reply_to
|
||
}
|
||
)
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else result
|
||
channel = result.get("_channel_used") or result.get("channel") or "sdk_control"
|
||
return {
|
||
"code": 200,
|
||
"success": bool(payload.get("success")) if isinstance(payload, dict) else False,
|
||
"data": payload,
|
||
"trace_id": result.get("trace_id") or payload.get("trace_id"),
|
||
"raw_rpc_receipt": result.get("raw_rpc_receipt") or payload,
|
||
"readback": result.get("readback") or payload.get("readback"),
|
||
"channel_used": "frida_rpc" if "frida" in channel else channel,
|
||
}
|
||
|
||
|
||
@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)",
|
||
}
|
||
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else result
|
||
channel = result.get("_channel_used") or result.get("channel") or "sdk_control"
|
||
return {
|
||
"code": 200,
|
||
"success": bool(payload.get("success")) if isinstance(payload, dict) else False,
|
||
"data": payload,
|
||
"trace_id": result.get("trace_id") or payload.get("trace_id"),
|
||
"raw_rpc_receipt": result.get("raw_rpc_receipt") or payload,
|
||
"readback": result.get("readback") or payload.get("readback"),
|
||
"channel_used": "frida_rpc" if "frida" in channel else channel,
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# 六、消息高级操作
|
||
# =============================================================================
|
||
|
||
class ForwardMessageRequest(BaseModel):
|
||
"""转发一条已有消息或指定文本。"""
|
||
device_id: str = Field(..., min_length=1, description="工作手机设备ID")
|
||
platform: Platform = Field(..., description="目标平台")
|
||
to_id: str = Field(..., min_length=1, description="接收方wxid、微信号、昵称或备注名")
|
||
content: str = Field("", description="需要转发的文本内容")
|
||
msg_svr_id: Optional[str] = Field(None, description="来源消息真实msgSvrId,优先于content")
|
||
|
||
class SendCardRequest(BaseModel):
|
||
"""发送微信联系人名片。"""
|
||
device_id: str = Field(..., min_length=1, description="工作手机设备ID")
|
||
platform: Platform = Field(..., description="目标平台")
|
||
to_id: str = Field(..., min_length=1, description="名片接收方")
|
||
card_wxid: str = Field(..., min_length=1, description="被分享联系人的wxid")
|
||
|
||
|
||
class SendVoiceMessageRequest(BaseModel):
|
||
"""发送语音消息。"""
|
||
device_id: str = Field(..., min_length=1, description="工作手机设备ID")
|
||
platform: Platform = Field(Platform.WECHAT, description="目标平台")
|
||
to_id: str = Field("", description="接收方;与user_id二选一")
|
||
user_id: str = Field("", description="接收方兼容字段;与to_id二选一")
|
||
duration: int = Field(3, ge=1, le=60, description="现场录音时长,单位秒")
|
||
voice_path: str = Field("", description="已有语音文件路径;留空则由设备端现场录音")
|
||
|
||
@router.post(
|
||
"/message/forward",
|
||
response_model=MessageOperationResponse,
|
||
response_model_exclude_none=True,
|
||
tags=["消息管理"],
|
||
summary="转发微信消息",
|
||
description="微信Hook按msg_svr_id读取原消息并转发;失败保留底层错误。",
|
||
responses=MESSAGE_WRITE_RESPONSES,
|
||
)
|
||
async def forward_message(req: ForwardMessageRequest):
|
||
"""转发消息"""
|
||
_check_device_online(req.device_id)
|
||
if req.platform == Platform.WECHAT and not req.msg_svr_id:
|
||
return _message_operation_response(
|
||
{"code": 422, "success": False, "error": "微信消息转发必须提供msg_svr_id", "error_code": "validation_failed"},
|
||
action="forward_message",
|
||
target_id=req.to_id,
|
||
fallback_channel="none",
|
||
)
|
||
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
|
||
)
|
||
return _message_operation_response(
|
||
result,
|
||
action="forward_message",
|
||
target_id=req.to_id,
|
||
)
|
||
|
||
class RecallMessageRequest(BaseModel):
|
||
"""撤回指定消息;msg_svr_id留空时由执行端处理当前会话最近一条本人消息。"""
|
||
device_id: str = Field(..., min_length=1, description="工作手机设备ID")
|
||
platform: Platform = Field(Platform.WECHAT, description="目标平台")
|
||
msg_svr_id: Optional[str] = Field(None, description="待撤回消息真实msgSvrId")
|
||
|
||
@router.post(
|
||
"/message/recall",
|
||
response_model=MessageOperationResponse,
|
||
response_model_exclude_none=True,
|
||
tags=["消息管理"],
|
||
summary="撤回微信消息",
|
||
description="按msg_svr_id撤回本人时限内消息,并保留消息不存在、非本人和超时错误。",
|
||
responses=MESSAGE_WRITE_RESPONSES,
|
||
)
|
||
async def recall_message(req: RecallMessageRequest):
|
||
"""撤回最近一条消息"""
|
||
_check_device_online(req.device_id)
|
||
if req.platform == Platform.WECHAT and not req.msg_svr_id:
|
||
return _message_operation_response(
|
||
{"code": 422, "success": False, "error": "微信消息撤回必须提供msg_svr_id", "error_code": "validation_failed"},
|
||
action="recall_message",
|
||
fallback_channel="none",
|
||
)
|
||
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)
|
||
return _message_operation_response(
|
||
result,
|
||
action="recall_message",
|
||
target_id=req.msg_svr_id or "",
|
||
)
|
||
|
||
@router.post(
|
||
"/message/send-card",
|
||
response_model=MessageOperationResponse,
|
||
response_model_exclude_none=True,
|
||
tags=["消息管理"],
|
||
summary="发送微信名片",
|
||
description="向指定接收方发送联系人名片,保留Hook或设备端真实回执。",
|
||
responses=MESSAGE_WRITE_RESPONSES,
|
||
)
|
||
async def send_card(req: SendCardRequest):
|
||
"""发送名片"""
|
||
_check_device_online(req.device_id)
|
||
result = await _execute_wechat_wss_frida(
|
||
req.device_id,
|
||
"send_card",
|
||
{"to_id": req.to_id, "card_wxid": req.card_wxid},
|
||
timeout=45,
|
||
)
|
||
return _message_operation_response(
|
||
result,
|
||
action="send_card",
|
||
target_id=req.to_id,
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# 七、个人设置接口
|
||
# =============================================================================
|
||
|
||
class _WechatProfileWriteBase(BaseModel):
|
||
device_id: str
|
||
platform: Platform
|
||
dry_run: bool = False
|
||
confirm: bool = False
|
||
|
||
class SetNicknameRequest(_WechatProfileWriteBase):
|
||
nickname: str
|
||
|
||
class SetSignatureRequest(_WechatProfileWriteBase):
|
||
signature: str
|
||
|
||
class SetAvatarRequest(_WechatProfileWriteBase):
|
||
image_path: Optional[str] = None
|
||
image_sha256: Optional[str] = None
|
||
|
||
class SetGenderRequest(_WechatProfileWriteBase):
|
||
gender: str
|
||
|
||
class SetRegionRequest(_WechatProfileWriteBase):
|
||
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", {}, hook_only=platform == Platform.WECHAT)
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else result
|
||
channel = result.get("_channel_used") or result.get("channel") or "sdk_control"
|
||
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, "dry_run": req.dry_run, "confirm": req.confirm},
|
||
hook_only=req.platform == Platform.WECHAT,
|
||
)
|
||
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):
|
||
"""通过 WebSocket Agent + 无线 Frida Hook 修改个性签名。"""
|
||
_check_device_online(req.device_id)
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "set_signature",
|
||
{"signature": req.signature, "dry_run": req.dry_run, "confirm": req.confirm},
|
||
hook_only=req.platform == Platform.WECHAT,
|
||
)
|
||
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, "image_sha256": req.image_sha256, "dry_run": req.dry_run, "confirm": req.confirm},
|
||
hook_only=req.platform == Platform.WECHAT,
|
||
)
|
||
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, "dry_run": req.dry_run, "confirm": req.confirm},
|
||
hook_only=req.platform == Platform.WECHAT,
|
||
)
|
||
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, "dry_run": req.dry_run, "confirm": req.confirm},
|
||
hook_only=req.platform == Platform.WECHAT,
|
||
)
|
||
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,
|
||
sns_id: str = "",
|
||
post_index: int = 0,
|
||
):
|
||
"""删除朋友圈"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(
|
||
device_id, platform.value, "delete_moments",
|
||
{"sns_id": sns_id, "post_index": post_index}
|
||
)
|
||
payload = result.get("data") if result.get("data") is not None else result
|
||
channel = str(
|
||
result.get("_channel_used")
|
||
or result.get("channel_used")
|
||
or result.get("channel")
|
||
or (payload.get("channel_used") if isinstance(payload, dict) else "")
|
||
or "websocket/frida_rpc"
|
||
)
|
||
return {
|
||
"code": 200,
|
||
"success": bool(payload.get("success")) if isinstance(payload, dict) else False,
|
||
"data": payload,
|
||
"trace_id": result.get("trace_id") or payload.get("trace_id"),
|
||
"raw_rpc_receipt": result.get("raw_rpc_receipt") or payload,
|
||
"readback": result.get("readback") or payload.get("readback"),
|
||
"channel_used": "frida_rpc" if "frida" in channel else channel,
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# 十四、支付接口
|
||
# =============================================================================
|
||
|
||
def _payment_max_test_amount() -> float:
|
||
return float(os.getenv("WP_PAYMENT_MAX_TEST_AMOUNT") or getattr(settings, "WP_PAYMENT_MAX_TEST_AMOUNT", 2.00) or 2.00)
|
||
|
||
|
||
def _payment_rate_window_seconds() -> int:
|
||
return int(os.getenv("WP_PAYMENT_RATE_WINDOW_SECONDS") or getattr(settings, "WP_PAYMENT_RATE_WINDOW_SECONDS", 60) or 60)
|
||
|
||
|
||
def _payment_rate_max_calls() -> int:
|
||
return int(os.getenv("WP_PAYMENT_RATE_MAX_CALLS") or getattr(settings, "WP_PAYMENT_RATE_MAX_CALLS", 3) or 3)
|
||
|
||
|
||
def _payment_test_targets() -> set:
|
||
raw = os.getenv("WP_PAYMENT_TEST_TARGETS") or str(getattr(settings, "WP_PAYMENT_TEST_TARGETS", "") or "")
|
||
return {item.strip() for item in raw.split(",") if item.strip()}
|
||
|
||
|
||
_payment_rate_events: Dict[str, List[float]] = {}
|
||
|
||
|
||
def _payment_error(action: str, error_code: str, message: str, *, code: int = 422, target_id: str = "") -> dict:
|
||
return _message_operation_response(
|
||
{
|
||
"code": code,
|
||
"success": False,
|
||
"verified": False,
|
||
"error_code": error_code,
|
||
"error": message,
|
||
"retryable": error_code in {"rate_limited", "hook_unavailable", "device_offline"},
|
||
},
|
||
action=action,
|
||
target_id=target_id,
|
||
fallback_channel="validation",
|
||
)
|
||
|
||
|
||
def _payment_guard(
|
||
*,
|
||
device_id: str,
|
||
action: str,
|
||
target_id: str = "",
|
||
amount: str = "",
|
||
) -> Optional[dict]:
|
||
"""资金写操作统一测试对象、小额与频控门禁。"""
|
||
if amount:
|
||
try:
|
||
numeric_amount = float(amount)
|
||
except (TypeError, ValueError):
|
||
return _payment_error(action, "validation_failed", "金额格式错误", target_id=target_id)
|
||
max_amount = _payment_max_test_amount()
|
||
if numeric_amount <= 0 or numeric_amount > max_amount:
|
||
return _payment_error(
|
||
action,
|
||
"payment_amount_out_of_range",
|
||
f"测试金额须大于0且不超过{max_amount:.2f}元",
|
||
target_id=target_id,
|
||
)
|
||
if target_id:
|
||
allowed = _payment_test_targets()
|
||
if not allowed or target_id not in allowed:
|
||
return _payment_error(
|
||
action,
|
||
"payment_test_target_required",
|
||
"目标不在资金动作测试对象白名单",
|
||
code=403,
|
||
target_id=target_id,
|
||
)
|
||
now = time.monotonic()
|
||
key = f"{device_id}:{action}"
|
||
rate_window = _payment_rate_window_seconds()
|
||
rate_max = _payment_rate_max_calls()
|
||
events = [stamp for stamp in _payment_rate_events.get(key, []) if now - stamp < rate_window]
|
||
if len(events) >= rate_max:
|
||
retry_after = max(1, int(rate_window - (now - events[0])))
|
||
response = _payment_error(action, "rate_limited", "资金动作频率受限", code=429, target_id=target_id)
|
||
response["data"]["retry_after_seconds"] = retry_after
|
||
return response
|
||
events.append(now)
|
||
_payment_rate_events[key] = events
|
||
return None
|
||
|
||
|
||
def _payment_operation_response(result: dict, *, action: str, target_id: str = "") -> dict:
|
||
"""资金动作必须以 Frida 原始回执、trace_id 与消息/账单回读作为最终成功门禁。"""
|
||
response = _message_operation_response(result, action=action, target_id=target_id)
|
||
data = response.get("data") if isinstance(response.get("data"), dict) else {}
|
||
status = str(data.get("status") or data.get("payment_status") or "")
|
||
error_code = str(data.get("error_code") or data.get("error") or "")
|
||
if action in {"send_transfer", "transfer", "send_red_packet"}:
|
||
settlement_verified = bool(data.get("settlement_verified") or data.get("ledger_verified"))
|
||
readback = data.get("readback") or data.get("db_readback") or data.get("ledger_readback") or {}
|
||
readback_found = bool(isinstance(readback, dict) and readback.get("found"))
|
||
if status in {"payment_confirm_required", "prepared_waiting_pay_confirm"} or error_code == "payment_confirm_required" or (data.get("prepared") and not (settlement_verified or readback_found)):
|
||
data.update({
|
||
"success": False,
|
||
"verified": bool(data.get("verified")),
|
||
"settlement_verified": False,
|
||
"status": "payment_confirm_required",
|
||
"error_code": "payment_confirm_required",
|
||
"error_message": data.get("error_message") or "已完成下单准备;等待支付确认与最终消息/账单回读",
|
||
})
|
||
response.update({"code": 202, "success": False})
|
||
response["feedback"] = _message_feedback(
|
||
success=False,
|
||
verified=bool(data.get("verified")),
|
||
action=action,
|
||
error_code="payment_confirm_required",
|
||
error_message=data["error_message"],
|
||
)
|
||
return response
|
||
if not response.get("success"):
|
||
return response
|
||
channel = str(data.get("channel") or data.get("channel_used") or response.get("channel_used") or "")
|
||
raw_receipt = data.get("raw_rpc_receipt")
|
||
trace_id = data.get("trace_id")
|
||
readback = data.get("readback") or data.get("db_readback") or data.get("ledger_readback")
|
||
missing = []
|
||
if "frida" not in channel:
|
||
missing.append("channel=frida_rpc")
|
||
if not trace_id:
|
||
missing.append("trace_id")
|
||
if not raw_receipt:
|
||
missing.append("raw_rpc_receipt")
|
||
if not readback:
|
||
missing.append("message_or_ledger_readback")
|
||
if missing:
|
||
data.update({
|
||
"success": False,
|
||
"verified": False,
|
||
"status": "readback_missing",
|
||
"error_code": "payment_readback_missing",
|
||
"error_message": "资金动作成功证据不完整: " + ", ".join(missing),
|
||
"missing_evidence": missing,
|
||
})
|
||
response.update({"code": 503, "success": False})
|
||
response["feedback"] = _message_feedback(
|
||
success=False,
|
||
verified=False,
|
||
action=action,
|
||
error_code="payment_readback_missing",
|
||
error_message=data["error_message"],
|
||
)
|
||
return response
|
||
|
||
|
||
class RedPacketRequest(BaseModel):
|
||
device_id: str = Field(..., min_length=1, description="工作手机设备ID")
|
||
platform: Platform = Field(..., description="目标平台")
|
||
to_id: str = Field(..., min_length=1, description="红包接收方wxid、微信号、昵称或备注名")
|
||
amount: str = Field(..., min_length=1, description="红包金额(元)", examples=["0.01"])
|
||
message: str = Field("恭喜发财", description="红包祝福语")
|
||
confirm: bool = Field(False, description="是否确认进入真实支付流程")
|
||
dry_run: bool = Field(True, description="是否只校验参数并返回确认前演练结果")
|
||
|
||
class TransferRequest(BaseModel):
|
||
device_id: str = Field(..., min_length=1)
|
||
platform: Platform = Field(Platform.WECHAT)
|
||
to_id: str = Field(..., min_length=1)
|
||
amount: str = Field(..., min_length=1)
|
||
message: str = ""
|
||
payment_password: str = Field(
|
||
"",
|
||
description="支付密码;仅真实转账确认链路使用,响应、日志和测试报告只返回 password_present",
|
||
repr=False,
|
||
)
|
||
pay_password: str = Field(
|
||
"",
|
||
description="payment_password 的兼容别名;两者同时存在时优先 payment_password",
|
||
repr=False,
|
||
)
|
||
confirm: bool = Field(False, description="是否确认执行真实操作")
|
||
dry_run: bool = Field(True, description="是否仅做参数校验和确认前演练")
|
||
|
||
def payment_secret(self) -> str:
|
||
return self.payment_password or self.pay_password or ""
|
||
|
||
|
||
|
||
|
||
class TransferReadbackRequest(BaseModel):
|
||
"""只读核验转账最终消息/账单回读;不触发资金动作。"""
|
||
device_id: str = Field(..., min_length=1, description="工作手机设备ID")
|
||
platform: Platform = Field(Platform.WECHAT, description="目标平台")
|
||
req_key: str = Field("", description="transferplaceorder 返回的 req_key")
|
||
to_id: str = Field("", description="收款方 wxid、微信号、昵称或备注名,用于回读过滤")
|
||
transfer_id: str = Field("", description="transferplaceorder 返回的 transfer_id")
|
||
transaction_id: str = Field("", description="transferplaceorder 返回的 transaction_id")
|
||
msg_svr_id: str = Field("", description="可选:消息ID/服务端消息ID")
|
||
since_ms: int = Field(0, description="可选:开始时间戳毫秒;0 表示不限制")
|
||
|
||
|
||
class TransferConfirmRequest(BaseModel):
|
||
"""确认已准备好的微信转账支付;只负责 req_key 支付确认与最终回读。"""
|
||
device_id: str = Field(..., min_length=1, description="工作手机设备ID")
|
||
platform: Platform = Field(Platform.WECHAT, description="目标平台")
|
||
req_key: str = Field(..., min_length=1, description="transferplaceorder 返回的 req_key")
|
||
to_id: str = Field("", description="收款方wxid、微信号、昵称或备注名,用于回读过滤")
|
||
transfer_id: str = Field("", description="transferplaceorder 返回的 transfer_id")
|
||
transaction_id: str = Field("", description="transferplaceorder 返回的 transaction_id")
|
||
payment_password: str = Field("", description="支付密码;响应不回显", repr=False)
|
||
pay_password: str = Field("", description="payment_password 的兼容别名", repr=False)
|
||
confirm: bool = Field(False, description="是否确认进入真实支付流程")
|
||
dry_run: bool = Field(True, description="是否只校验参数并返回确认前演练结果")
|
||
|
||
def payment_secret(self) -> str:
|
||
return self.payment_password or self.pay_password or ""
|
||
|
||
class ReceivePaymentRequest(BaseModel):
|
||
device_id: str = Field(..., min_length=1)
|
||
platform: Platform = Field(Platform.WECHAT)
|
||
amount: str = Field("", description="固定金额收款;空值表示未指定")
|
||
desc: str = ""
|
||
payer_id: str = Field("", description="测试付款对象")
|
||
confirm: bool = Field(False)
|
||
dry_run: bool = Field(True)
|
||
|
||
|
||
class TransferDecisionRequest(BaseModel):
|
||
"""收到微信转账后的确认收款/退还请求。"""
|
||
device_id: str = Field(..., min_length=1, description="工作手机设备ID")
|
||
platform: Platform = Field(Platform.WECHAT, description="目标平台")
|
||
msg_svr_id: str = Field(..., min_length=1, description="转账消息ID")
|
||
from_id: str = Field("", description="发送方微信号、昵称或备注名")
|
||
confirm: bool = Field(False, description="是否确认执行真实操作")
|
||
dry_run: bool = Field(True, description="是否仅做参数校验和确认前演练")
|
||
|
||
|
||
class RedPacketReceiveRequest(BaseModel):
|
||
"""收到微信红包后的领取请求。"""
|
||
device_id: str = Field(..., min_length=1, description="工作手机设备ID")
|
||
platform: Platform = Field(Platform.WECHAT, description="目标平台")
|
||
msg_svr_id: str = Field("", description="红包消息ID;为空时自动尝试最近一条红包消息")
|
||
from_id: str = Field("", description="发送方微信号、昵称或备注名")
|
||
confirm: bool = Field(False, description="是否确认执行真实操作")
|
||
dry_run: bool = Field(True, description="是否仅做参数校验和确认前演练")
|
||
|
||
|
||
class IncomingPaymentBatchReceiveRequest(BaseModel):
|
||
"""按消息次数批量领取微信红包或确认微信转账。"""
|
||
device_id: str = Field(..., min_length=1, description="工作手机设备ID")
|
||
platform: Platform = Field(Platform.WECHAT, description="目标平台")
|
||
msg_svr_ids: List[str] = Field(..., min_length=1, max_length=20, description="待处理消息ID列表")
|
||
from_id: str = Field("", description="发送方微信号、昵称或备注名")
|
||
confirm: bool = Field(False, description="是否确认执行真实操作")
|
||
dry_run: bool = Field(True, description="是否仅做识别和确认前演练")
|
||
|
||
@router.post("/payment/red-packet", response_model=MessageOperationResponse, tags=["支付"])
|
||
async def send_red_packet(req: RedPacketRequest):
|
||
"""发红包;默认仅做确认前演练,confirm=true且dry_run=false时执行。"""
|
||
try:
|
||
amount = float(req.amount)
|
||
except (TypeError, ValueError):
|
||
amount = 0
|
||
if amount <= 0:
|
||
return _message_operation_response(
|
||
{
|
||
"code": 422,
|
||
"success": False,
|
||
"error_code": "validation_failed",
|
||
"error": "红包金额必须大于0",
|
||
},
|
||
action="send_red_packet",
|
||
target_id=req.to_id,
|
||
fallback_channel="validation",
|
||
)
|
||
if req.dry_run or not req.confirm:
|
||
return _message_operation_response(
|
||
{
|
||
"code": 200,
|
||
"success": True,
|
||
"verified": True,
|
||
"channel_used": "dry_run",
|
||
"data": {
|
||
"success": True,
|
||
"verified": True,
|
||
"dry_run": True,
|
||
"confirm_required": True,
|
||
"amount": req.amount,
|
||
"message": req.message,
|
||
"note": "参数校验通过;confirm=true且dry_run=false后进入真实支付流程",
|
||
},
|
||
},
|
||
action="send_red_packet",
|
||
target_id=req.to_id,
|
||
fallback_channel="dry_run",
|
||
)
|
||
blocked = _payment_guard(
|
||
device_id=req.device_id,
|
||
action="send_red_packet",
|
||
target_id=req.to_id,
|
||
amount=req.amount,
|
||
)
|
||
if blocked:
|
||
return blocked
|
||
_check_device_online(req.device_id, req.platform.value, "send_red_packet")
|
||
result = await _execute_skill(
|
||
req.device_id, req.platform.value, "send_red_packet",
|
||
{
|
||
"to_id": req.to_id,
|
||
"amount": req.amount,
|
||
"message": req.message,
|
||
"confirm": req.confirm,
|
||
"dry_run": req.dry_run,
|
||
},
|
||
timeout=45,
|
||
hook_only=True,
|
||
)
|
||
return _payment_operation_response(
|
||
result,
|
||
action="send_red_packet",
|
||
target_id=req.to_id,
|
||
)
|
||
|
||
@router.post("/payment/transfer", response_model=dict, tags=["支付"])
|
||
async def send_transfer(req: TransferRequest):
|
||
"""发起转账;真实内部 RPC 完成前返回明确能力状态。"""
|
||
if req.dry_run or not req.confirm:
|
||
return _message_operation_response(
|
||
{
|
||
"code": 200,
|
||
"success": True,
|
||
"verified": True,
|
||
"channel_used": "dry_run",
|
||
"data": {
|
||
"success": True,
|
||
"verified": True,
|
||
"dry_run": True,
|
||
"confirm_required": True,
|
||
"amount": req.amount,
|
||
"password_present": bool(req.payment_secret()),
|
||
},
|
||
},
|
||
action="send_transfer",
|
||
target_id=req.to_id,
|
||
fallback_channel="dry_run",
|
||
)
|
||
blocked = _payment_guard(
|
||
device_id=req.device_id,
|
||
action="send_transfer",
|
||
target_id=req.to_id,
|
||
amount=req.amount,
|
||
)
|
||
if blocked:
|
||
return blocked
|
||
if not req.payment_secret():
|
||
return _payment_error(
|
||
"send_transfer",
|
||
"payment_password_required",
|
||
"真实转账需要支付密码参数 payment_password 或 pay_password;未提供时不创建待支付订单",
|
||
code=422,
|
||
target_id=req.to_id,
|
||
)
|
||
_check_device_online(req.device_id, req.platform.value, "send_transfer")
|
||
result = await _execute_skill(
|
||
req.device_id,
|
||
req.platform.value,
|
||
"send_transfer",
|
||
{
|
||
"to_id": req.to_id,
|
||
"amount": req.amount,
|
||
"message": req.message,
|
||
"payment_password": req.payment_secret(),
|
||
"password_present": bool(req.payment_secret()),
|
||
"confirm": True,
|
||
"dry_run": False,
|
||
},
|
||
timeout=45,
|
||
hook_only=True,
|
||
)
|
||
return _payment_operation_response(result, action="send_transfer", target_id=req.to_id)
|
||
|
||
|
||
|
||
|
||
@router.post("/payment/transfer/readback", response_model=dict, tags=["支付"])
|
||
async def readback_transfer(req: TransferReadbackRequest):
|
||
"""只读核验转账是否已产生最终消息/账单回读;不会触发支付。"""
|
||
_check_device_online(req.device_id, req.platform.value, "inspect_transfer_readback")
|
||
result = await _execute_skill(
|
||
req.device_id,
|
||
req.platform.value,
|
||
"inspect_transfer_readback",
|
||
{
|
||
"req_key": req.req_key,
|
||
"to_id": req.to_id,
|
||
"transfer_id": req.transfer_id,
|
||
"transaction_id": req.transaction_id,
|
||
"msg_svr_id": req.msg_svr_id,
|
||
"since_ms": req.since_ms,
|
||
},
|
||
timeout=45,
|
||
hook_only=True,
|
||
)
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else result
|
||
found = bool(isinstance(payload, dict) and payload.get("found"))
|
||
return {
|
||
"code": 200 if found else 202,
|
||
"success": found,
|
||
"data": payload,
|
||
"trace_id": result.get("trace_id"),
|
||
"channel_used": result.get("_channel_used") or result.get("channel") or "server/frida",
|
||
"raw_rpc_receipt": result.get("raw_rpc_receipt") or (payload.get("raw_rpc_receipt") if isinstance(payload, dict) else None),
|
||
"readback": payload if isinstance(payload, dict) else None,
|
||
}
|
||
|
||
|
||
@router.post("/payment/transfer/confirm", response_model=dict, tags=["支付"])
|
||
async def confirm_prepared_transfer(req: TransferConfirmRequest):
|
||
"""确认已取得 req_key 的转账;成功必须有 Frida 回执和消息/账单回读。"""
|
||
if req.dry_run or not req.confirm:
|
||
return _message_operation_response(
|
||
{
|
||
"code": 200,
|
||
"success": True,
|
||
"verified": True,
|
||
"channel_used": "dry_run",
|
||
"data": {
|
||
"success": True,
|
||
"verified": True,
|
||
"dry_run": True,
|
||
"confirm_required": True,
|
||
"req_key_present": bool(req.req_key),
|
||
"transfer_id_present": bool(req.transfer_id),
|
||
"transaction_id_present": bool(req.transaction_id),
|
||
"password_present": bool(req.payment_secret()),
|
||
},
|
||
},
|
||
action="send_transfer",
|
||
target_id=req.to_id,
|
||
fallback_channel="dry_run",
|
||
)
|
||
if not req.payment_secret():
|
||
return _payment_error(
|
||
"send_transfer",
|
||
"payment_password_required",
|
||
"确认转账需要支付密码参数 payment_password 或 pay_password",
|
||
code=422,
|
||
target_id=req.to_id,
|
||
)
|
||
blocked = _payment_guard(
|
||
device_id=req.device_id,
|
||
action="send_transfer_confirm",
|
||
target_id=req.to_id or "__prepared_transfer__",
|
||
amount="0.01",
|
||
)
|
||
if blocked:
|
||
return blocked
|
||
_check_device_online(req.device_id, req.platform.value, "confirm_prepared_transfer")
|
||
result = await _execute_skill(
|
||
req.device_id,
|
||
req.platform.value,
|
||
"confirm_prepared_transfer",
|
||
{
|
||
"req_key": req.req_key,
|
||
"to_id": req.to_id,
|
||
"transfer_id": req.transfer_id,
|
||
"transaction_id": req.transaction_id,
|
||
"payment_password": req.payment_secret(),
|
||
"password_present": True,
|
||
"confirm": True,
|
||
"dry_run": False,
|
||
},
|
||
timeout=65,
|
||
hook_only=True,
|
||
)
|
||
return _payment_operation_response(result, action="send_transfer", target_id=req.to_id)
|
||
|
||
async def _execute_transfer_decision(
|
||
req: TransferDecisionRequest,
|
||
*,
|
||
action: str,
|
||
) -> dict:
|
||
"""统一处理确认收款和退还;显式确认后仅走 Frida RPC。"""
|
||
if req.dry_run or not req.confirm:
|
||
note = (
|
||
"参数校验通过;confirm=true且dry_run=false后提交确认收款网络场景"
|
||
if action == "receive_transfer"
|
||
else "参数校验通过;confirm=true且dry_run=false后提交退还网络场景"
|
||
)
|
||
return _message_operation_response(
|
||
{
|
||
"code": 200,
|
||
"success": True,
|
||
"verified": True,
|
||
"channel_used": "dry_run",
|
||
"data": {
|
||
"success": True,
|
||
"verified": True,
|
||
"dry_run": True,
|
||
"confirm_required": True,
|
||
"msg_svr_id": req.msg_svr_id,
|
||
"note": note,
|
||
},
|
||
},
|
||
action=action,
|
||
target_id=req.from_id,
|
||
fallback_channel="dry_run",
|
||
)
|
||
blocked = _payment_guard(
|
||
device_id=req.device_id,
|
||
action=action,
|
||
target_id=req.from_id,
|
||
)
|
||
if blocked:
|
||
return blocked
|
||
_check_device_online(req.device_id, req.platform.value, action)
|
||
result = await _execute_skill(
|
||
req.device_id,
|
||
req.platform.value,
|
||
action,
|
||
{
|
||
"msg_svr_id": req.msg_svr_id,
|
||
"from_id": req.from_id,
|
||
"confirm": True,
|
||
"dry_run": False,
|
||
},
|
||
timeout=45,
|
||
hook_only=True,
|
||
)
|
||
return _payment_operation_response(
|
||
result,
|
||
action=action,
|
||
target_id=req.from_id,
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/payment/transfer/receive",
|
||
response_model=MessageOperationResponse,
|
||
tags=["支付"],
|
||
summary="确认收取微信转账",
|
||
)
|
||
async def receive_transfer_payment(req: TransferDecisionRequest):
|
||
"""通过 Frida RPC 确认收取微信转账。"""
|
||
return await _execute_transfer_decision(req, action="receive_transfer")
|
||
|
||
|
||
@router.post(
|
||
"/payment/transfer/reject",
|
||
response_model=MessageOperationResponse,
|
||
tags=["支付"],
|
||
summary="退还微信转账",
|
||
)
|
||
async def reject_transfer_payment(req: TransferDecisionRequest):
|
||
"""通过 Frida RPC 退还微信转账。"""
|
||
return await _execute_transfer_decision(req, action="reject_transfer")
|
||
|
||
|
||
# =============================================================================
|
||
# 十五、小程序 & 公众号
|
||
# =============================================================================
|
||
|
||
@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,
|
||
trace_id: Optional[str] = None,
|
||
):
|
||
"""
|
||
联系客服解封 — 全自动链路 + AI 对话
|
||
|
||
流程: 设置→账号与安全→微信安全中心→联系客服→客服会话→AI 持续软磨硬泡
|
||
|
||
返回 data 含 `session.turns[]`(每轮客服消息+AI 回复+发送状态)与 `final_status`:
|
||
success / reject / timeout / fallback / error
|
||
"""
|
||
_check_device_online(device_id)
|
||
trace_id = str(trace_id or uuid.uuid4().hex)
|
||
params = {
|
||
"reason": reason,
|
||
"phone": phone,
|
||
"wxid": wxid,
|
||
"max_rounds": max_rounds,
|
||
"chat_interval_sec": chat_interval_sec,
|
||
"use_web_search": use_web_search,
|
||
"trace_id": trace_id,
|
||
}
|
||
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
|
||
final_status = "error"
|
||
if isinstance(payload, dict):
|
||
final_status = payload.get("status") or (payload.get("session") or {}).get("final_status") or "error"
|
||
ok = final_status == "success" and bool(payload.get("success")) if isinstance(payload, dict) else False
|
||
return {
|
||
"code": 200 if ok else 503,
|
||
"success": ok,
|
||
"data": payload,
|
||
"trace_id": trace_id,
|
||
"channel_used": result.get("_channel_used", "accessibility"),
|
||
"raw_rpc_receipt": result.get("raw_rpc_receipt"),
|
||
"readback": payload.get("readback") if isinstance(payload, dict) else None,
|
||
}
|
||
|
||
@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")}
|
||
|
||
|
||
# =============================================================================
|
||
# 十七、视频号
|
||
# =============================================================================
|
||
|
||
WP_HL04_WRITE_ACTIONS = {
|
||
"add_tag", "add_to_favorites", "clear_history", "comment_video", "create_tag",
|
||
"delete_tag", "do_not_disturb", "follow_video_creator", "forward_moments_link",
|
||
"like_steps", "like_video", "open_miniprogram", "recall_message", "remove_tag",
|
||
"send_file_from_chat", "set_chat_top", "set_gender", "set_group_notice",
|
||
"set_moments_cover", "set_moments_privacy", "set_mute_chat", "set_remark",
|
||
"share_video", "toggle_do_not_disturb", "unblock_account", "unblock_appeal",
|
||
"unblock_with_sms",
|
||
}
|
||
|
||
|
||
@router.post(
|
||
"/wechat/headless/execute",
|
||
response_model=HeadlessActionResponse,
|
||
response_model_exclude_none=False,
|
||
tags=["微信无界面统一动作"],
|
||
summary="执行WP-HL-04微信动作",
|
||
description=(
|
||
"38项动作统一经WSS Agent/无线Frida RPC执行。写动作默认dry_run;"
|
||
"只有返回success=true、channel=frida_rpc、raw_rpc_receipt和readback齐全才算成功。"
|
||
),
|
||
responses={
|
||
200: {"description": "演练、读取或已完成并验证的动作"},
|
||
422: {"description": "参数模型校验失败"},
|
||
503: {"description": "设备、WSS、Frida或业务回读不可用"},
|
||
},
|
||
)
|
||
async def execute_wp_hl04(req: WP_HL04ActionRequest, response: Response):
|
||
"""WP-HL-04接口层统一入口;只负责契约和透传,不实现微信内部类。"""
|
||
if req.platform != Platform.WECHAT:
|
||
result = _strict_frida_operation_response(
|
||
{"success": False, "error_code": "invalid_platform", "error_message": "WP-HL-04仅支持wechat"},
|
||
action=req.action,
|
||
target_id=req.params.target_id or req.params.user_id or "",
|
||
)
|
||
return _set_http_status(response, result)
|
||
|
||
if req.action in WP_HL04_WRITE_ACTIONS and (req.dry_run or not req.confirm):
|
||
return _set_http_status(response, _headless_dry_run_response(req))
|
||
|
||
params = req.params.model_dump(exclude_none=True)
|
||
params.update({"trace_id": req.trace_id or uuid.uuid4().hex, "dry_run": req.dry_run, "confirm": req.confirm})
|
||
result = await _execute_skill(req.device_id, req.platform.value, req.action, params, hook_only=True)
|
||
response_body = _strict_frida_operation_response(
|
||
result,
|
||
action=req.action,
|
||
target_id=req.params.target_id or req.params.user_id or req.params.to_id or "",
|
||
require_readback=req.action in WP_HL04_WRITE_ACTIONS,
|
||
)
|
||
return _set_http_status(response, response_body)
|
||
|
||
|
||
def _friend_group_invalid_params_response(req: FriendGroupActionRequest, missing: List[str]) -> dict:
|
||
"""首批好友群动作的业务参数校验回执;与FastAPI字段422保持同一语义。"""
|
||
trace_id = str(req.trace_id or uuid.uuid4().hex)
|
||
message = f"动作 {req.action} 缺少必填参数:{', '.join(missing)}"
|
||
return {
|
||
"code": 422,
|
||
"success": False,
|
||
"data": {
|
||
"success": False,
|
||
"verified": False,
|
||
"status": "validation_failed",
|
||
"action": req.action,
|
||
"target_id": req.params.user_id or req.params.group_id or "",
|
||
"trace_id": trace_id,
|
||
"error_code": "invalid_params",
|
||
"error_message": message,
|
||
"retryable": False,
|
||
"retry_after_seconds": 0,
|
||
"channel_used": "preflight",
|
||
"raw_rpc_receipt": None,
|
||
"readback": None,
|
||
},
|
||
"feedback": {"level": "error", "title": "参数校验失败", "message": message, "action": "fix_request"},
|
||
"channel_used": "preflight",
|
||
"trace_id": trace_id,
|
||
}
|
||
|
||
|
||
def _friend_group_dry_run_response(req: FriendGroupActionRequest) -> dict:
|
||
trace_id = str(req.trace_id or uuid.uuid4().hex)
|
||
error_code = "dry_run_no_write" if req.dry_run else "confirm_required"
|
||
message = "参数已校验,未执行微信写入;请显式confirm=true且dry_run=false。"
|
||
return {
|
||
"code": 200,
|
||
"success": False,
|
||
"data": {
|
||
"success": False,
|
||
"verified": False,
|
||
"status": error_code,
|
||
"action": req.action,
|
||
"target_id": req.params.user_id or req.params.group_id or "",
|
||
"trace_id": trace_id,
|
||
"error_code": error_code,
|
||
"error_message": message,
|
||
"retryable": False,
|
||
"retry_after_seconds": 0,
|
||
"channel_used": "dry_run",
|
||
"raw_rpc_receipt": None,
|
||
"readback": None,
|
||
"dry_run": req.dry_run,
|
||
"confirm": req.confirm,
|
||
},
|
||
"feedback": {"level": "warning", "title": "仅完成参数演练", "message": message, "action": "confirm"},
|
||
"channel_used": "dry_run",
|
||
"trace_id": trace_id,
|
||
}
|
||
|
||
|
||
@router.post(
|
||
"/wechat/friend-group/execute",
|
||
response_model=HeadlessActionResponse,
|
||
response_model_exclude_none=False,
|
||
tags=["好友管理", "群聊管理", "微信无界面统一动作"],
|
||
summary="执行好友/群首批无界面动作",
|
||
description=(
|
||
"首批好友/群写动作经CLI/API→WSS Agent→无线Frida RPC执行。"
|
||
"默认dry_run;真实成功同时要求Frida通道、原始RPC回执和业务回读。"
|
||
),
|
||
responses={
|
||
200: {"description": "参数演练或已验证的业务回执"},
|
||
422: {"description": "Pydantic字段或action业务参数校验失败"},
|
||
503: {"description": "WSS、无线Frida、RPC或业务回读未就绪"},
|
||
},
|
||
)
|
||
async def execute_friend_group_action(req: FriendGroupActionRequest, response: Response):
|
||
"""好友/群接口层收口;不实现微信内部类,Hook未实现时保持能力不足。"""
|
||
required = {
|
||
"add_friend": ("user_id",),
|
||
"accept_friend": ("user_id", "ticket"),
|
||
"set_friend_remark": ("user_id", "remark"),
|
||
"delete_friend": ("user_id",),
|
||
"create_group": ("group_name", "member_ids"),
|
||
"invite_to_group": ("group_id", "member_ids"),
|
||
"remove_from_group": ("group_id", "member_ids"),
|
||
"set_group_notice": ("group_id", "notice"),
|
||
"set_group_name": ("group_id", "group_name"),
|
||
"send_group_message": ("group_id", "content"),
|
||
}
|
||
missing = [field for field in required[req.action] if not getattr(req.params, field)]
|
||
if missing:
|
||
return _set_http_status(response, _friend_group_invalid_params_response(req, missing))
|
||
if req.dry_run or not req.confirm:
|
||
return _set_http_status(response, _friend_group_dry_run_response(req))
|
||
|
||
params = req.params.model_dump(exclude_none=True, mode="json")
|
||
if req.action == "accept_friend":
|
||
params.setdefault("encrypt_username", params["user_id"])
|
||
params.update({"trace_id": req.trace_id or uuid.uuid4().hex, "dry_run": False, "confirm": True})
|
||
result = await _execute_skill(req.device_id, req.platform, req.action, params, hook_only=True)
|
||
body = _strict_frida_operation_response(
|
||
result,
|
||
action=req.action,
|
||
target_id=req.params.user_id or req.params.group_id or "",
|
||
require_readback=True,
|
||
)
|
||
return _set_http_status(response, body)
|
||
|
||
@router.get("/video-channel/list", response_model=dict, tags=["视频号"])
|
||
async def get_video_list(device_id: str, platform: Platform = Platform.WECHAT, limit: int = 10, response: Response = None):
|
||
"""获取视频号列表"""
|
||
_check_device_online(device_id, platform.value, "get_video_list")
|
||
result = await _execute_skill(device_id, platform.value, "get_video_list", {"limit": limit}, hook_only=True)
|
||
return _set_http_status(response, _strict_frida_operation_response(result, action="get_video_list", require_readback=False))
|
||
|
||
@router.post(
|
||
"/video-channel/like", response_model=dict, tags=["视频号"],
|
||
responses={503: {"description": "Frida RPC不可用、执行失败或业务回读缺失"}},
|
||
)
|
||
async def like_video(device_id: str, platform: Platform, index: int = 0, response: Response = None):
|
||
"""点赞视频"""
|
||
_check_device_online(device_id, platform.value, "like_video")
|
||
result = await _execute_skill(device_id, platform.value, "like_video", {"index": index}, hook_only=True)
|
||
return _set_http_status(response, _strict_frida_operation_response(result, action="like_video", target_id=f"index_{index}"))
|
||
|
||
@router.post(
|
||
"/video-channel/comment", response_model=dict, tags=["视频号"],
|
||
responses={503: {"description": "Frida RPC不可用、执行失败或业务回读缺失"}},
|
||
)
|
||
async def comment_video(device_id: str, platform: Platform, index: int = 0, comment: str = "", response: Response = None):
|
||
"""评论视频"""
|
||
_check_device_online(device_id, platform.value, "comment_video")
|
||
result = await _execute_skill(device_id, platform.value, "comment_video", {"index": index, "comment": comment}, hook_only=True)
|
||
return _set_http_status(response, _strict_frida_operation_response(result, action="comment_video", target_id=f"index_{index}"))
|
||
|
||
@router.post(
|
||
"/video-channel/follow", response_model=dict, tags=["视频号"],
|
||
responses={503: {"description": "Frida RPC不可用、执行失败或业务回读缺失"}},
|
||
)
|
||
async def follow_video_creator(device_id: str, platform: Platform, index: int = 0, response: Response = None):
|
||
"""关注视频号创作者"""
|
||
_check_device_online(device_id, platform.value, "follow_video_creator")
|
||
result = await _execute_skill(device_id, platform.value, "follow_video_creator", {"index": index}, hook_only=True)
|
||
return _set_http_status(response, _strict_frida_operation_response(result, action="follow_video_creator", target_id=f"index_{index}"))
|
||
|
||
@router.post(
|
||
"/video-channel/share", response_model=dict, tags=["视频号"],
|
||
responses={503: {"description": "Frida RPC不可用、执行失败或业务回读缺失"}},
|
||
)
|
||
async def share_video(device_id: str, platform: Platform, index: int = 0, to_id: str = "", response: Response = None):
|
||
"""分享视频"""
|
||
_check_device_online(device_id, platform.value, "share_video")
|
||
result = await _execute_skill(device_id, platform.value, "share_video", {"index": index, "to_id": to_id}, hook_only=True)
|
||
return _set_http_status(response, _strict_frida_operation_response(result, action="share_video", target_id=to_id or f"index_{index}"))
|
||
|
||
|
||
# =============================================================================
|
||
# 十八、扫一扫
|
||
# =============================================================================
|
||
|
||
@router.post("/scan/inspect-qr-classes", response_model=dict, tags=["扫一扫"])
|
||
async def inspect_qr_classes(
|
||
device_id: str = Body(...),
|
||
trace_id: Optional[str] = Body(None),
|
||
):
|
||
"""只读枚举微信二维码相关类,用于定位当前版本真实个人码RPC。"""
|
||
result = await _execute_wechat_wss_frida(
|
||
device_id,
|
||
"inspect_qr_classes",
|
||
{},
|
||
timeout=60,
|
||
trace_id=trace_id,
|
||
)
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else result
|
||
return {
|
||
"code": 200 if payload.get("success") else 503,
|
||
"success": bool(payload.get("success")),
|
||
"data": payload,
|
||
"trace_id": result.get("trace_id") or trace_id,
|
||
"channel_used": result.get("_channel_used", "websocket/frida"),
|
||
"raw_rpc_receipt": result.get("raw_rpc_receipt"),
|
||
"readback": result.get("readback"),
|
||
}
|
||
|
||
|
||
@router.post("/scan/inspect-friend-qr-classes", response_model=dict, tags=["扫一扫"])
|
||
async def inspect_friend_qr_classes(
|
||
device_id: str = Body(...),
|
||
trace_id: Optional[str] = Body(None),
|
||
):
|
||
"""只读探测好友二维码相关类,用于定位好友二维码真实RPC。"""
|
||
result = await _execute_wechat_wss_frida(
|
||
device_id, "inspect_friend_qr_classes", {}, timeout=60, trace_id=trace_id
|
||
)
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else result
|
||
ok = bool(isinstance(payload, dict) and payload.get("success"))
|
||
return {
|
||
"code": 200 if ok else 503,
|
||
"success": ok,
|
||
"data": payload,
|
||
"trace_id": result.get("trace_id") or trace_id,
|
||
"channel_used": result.get("_channel_used", "websocket/frida"),
|
||
"raw_rpc_receipt": result.get("raw_rpc_receipt"),
|
||
"readback": result.get("readback"),
|
||
}
|
||
|
||
|
||
@router.post("/scan/friend-contact-qr", response_model=dict, tags=["扫一扫"])
|
||
async def get_friend_contact_qr(
|
||
device_id: str = Body(...),
|
||
wxid: str = Body(..., description="好友 wxid / user_id"),
|
||
trace_id: Optional[str] = Body(None),
|
||
mode: str = Body("auto", description="auto=只做真实好友二维码捕获;scheme_probe=仅输出已知微信扫码不通过的wxid scheme探测结果"),
|
||
):
|
||
"""生成/捕获指定好友可扫添加二维码;auto失败时返回真实能力缺口,不返回伪二维码。"""
|
||
request_trace_id = str(trace_id or uuid.uuid4().hex)
|
||
if mode == "scheme":
|
||
generated = _generate_wechat_contact_qr_from_wxid(wxid)
|
||
return {
|
||
"code": 200 if generated.get("success") else 503,
|
||
"success": bool(generated.get("success")),
|
||
"data": generated,
|
||
"trace_id": request_trace_id,
|
||
"channel_used": "server/qrcode",
|
||
"raw_rpc_receipt": None,
|
||
"readback": {"decoded_values": generated.get("decoded_values", [])},
|
||
}
|
||
|
||
result = await _execute_wechat_wss_frida(
|
||
device_id,
|
||
"capture_friend_qr_code",
|
||
{"wxid": wxid},
|
||
timeout=45,
|
||
trace_id=request_trace_id,
|
||
)
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else result
|
||
ok = bool(isinstance(payload, dict) and payload.get("success"))
|
||
if ok:
|
||
return {
|
||
"code": 200,
|
||
"success": True,
|
||
"data": payload,
|
||
"trace_id": result.get("trace_id") or request_trace_id,
|
||
"channel_used": result.get("_channel_used", "websocket/frida"),
|
||
"raw_rpc_receipt": result.get("raw_rpc_receipt"),
|
||
"readback": result.get("readback"),
|
||
}
|
||
|
||
# 严格按“真实好友二维码”口径返回:auto 模式不再把 wxid scheme 二维码作为兜底结果,
|
||
# 避免用户扫码看到无效码还被接口误导;仅 mode=scheme_probe 时单独输出该未验证方案。
|
||
if mode == "scheme_probe":
|
||
generated = _generate_wechat_contact_qr_from_wxid(wxid)
|
||
generated["frida_capture"] = payload
|
||
generated["fallback_reason"] = payload.get("error") if isinstance(payload, dict) else "frida_capture_failed"
|
||
return {
|
||
"code": 503,
|
||
"success": False,
|
||
"data": generated,
|
||
"trace_id": result.get("trace_id") or request_trace_id,
|
||
"channel_used": "server/frida+server/qrcode_probe",
|
||
"raw_rpc_receipt": result.get("raw_rpc_receipt"),
|
||
"readback": {"decoded_values": generated.get("decoded_values", []), "wechat_scan_verified": False},
|
||
}
|
||
return {
|
||
"code": 503,
|
||
"success": False,
|
||
"data": {
|
||
"success": False,
|
||
"verified": False,
|
||
"wechat_scan_verified": False,
|
||
"status": "friend_qr_generation_unresolved",
|
||
"error": "friend_qr_generation_unresolved",
|
||
"error_code": "friend_qr_generation_unresolved",
|
||
"error_message": "当前微信版本未定位到可无界面生成指定好友可扫添加二维码的内部RPC;不会返回wxid scheme伪二维码。",
|
||
"wxid": wxid,
|
||
"frida_capture": payload,
|
||
"rejected_alternatives": [
|
||
{"method": "wxid_contact_scheme_qr", "reason": "微信扫一扫验证不通过,仅本地可解码"},
|
||
{"method": "send_card", "reason": "这是发名片,不是生成扫码加好友二维码,已从本需求路线剔除"}
|
||
],
|
||
},
|
||
"trace_id": result.get("trace_id") or request_trace_id,
|
||
"channel_used": result.get("_channel_used", "server/frida"),
|
||
"raw_rpc_receipt": result.get("raw_rpc_receipt"),
|
||
"readback": None,
|
||
}
|
||
|
||
|
||
@router.post("/scan/my-contact-qr", response_model=dict, tags=["扫一扫"])
|
||
async def get_my_contact_qr(
|
||
device_id: str = Body(...),
|
||
trace_id: Optional[str] = Body(None),
|
||
):
|
||
"""通过 WSS Agent → 无线 Frida RPC 捕获当前登录账号真实个人二维码回执。"""
|
||
result = await _execute_wechat_wss_frida(
|
||
device_id,
|
||
"capture_my_qr_code",
|
||
{},
|
||
timeout=45,
|
||
trace_id=trace_id,
|
||
)
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else result
|
||
ok = bool(isinstance(payload, dict) and payload.get("success"))
|
||
return {
|
||
"code": 200 if ok else 503,
|
||
"success": ok,
|
||
"data": payload,
|
||
"trace_id": result.get("trace_id") or trace_id,
|
||
"channel_used": result.get("_channel_used", "websocket/frida"),
|
||
"raw_rpc_receipt": result.get("raw_rpc_receipt"),
|
||
"readback": result.get("readback"),
|
||
}
|
||
|
||
|
||
def _wireless_qr_invalid_response(req: WirelessQrActionRequest, message: str) -> dict:
|
||
trace_id = str(req.trace_id or uuid.uuid4().hex)
|
||
return {
|
||
"code": 422,
|
||
"success": False,
|
||
"data": {
|
||
"success": False,
|
||
"verified": False,
|
||
"status": "validation_failed",
|
||
"action": req.action,
|
||
"trace_id": trace_id,
|
||
"error_code": "invalid_params",
|
||
"error_message": message,
|
||
"retryable": False,
|
||
"retry_after_seconds": 0,
|
||
"channel_used": "preflight",
|
||
"raw_rpc_receipt": None,
|
||
"readback": None,
|
||
},
|
||
"feedback": {"level": "error", "title": "参数校验失败", "message": message, "action": "fix_request"},
|
||
"channel_used": "preflight",
|
||
"trace_id": trace_id,
|
||
}
|
||
|
||
|
||
def _wireless_qr_dry_run_response(req: WirelessQrActionRequest) -> dict:
|
||
trace_id = str(req.trace_id or uuid.uuid4().hex)
|
||
error_code = "dry_run_no_write" if req.dry_run else "confirm_required"
|
||
message = "参数和无线媒体载荷已校验,未执行加好友写入;请显式confirm=true且dry_run=false。"
|
||
return {
|
||
"code": 200,
|
||
"success": False,
|
||
"data": {
|
||
"success": False,
|
||
"verified": False,
|
||
"status": error_code,
|
||
"action": req.action,
|
||
"trace_id": trace_id,
|
||
"error_code": error_code,
|
||
"error_message": message,
|
||
"retryable": False,
|
||
"retry_after_seconds": 0,
|
||
"channel_used": "dry_run",
|
||
"raw_rpc_receipt": None,
|
||
"readback": None,
|
||
"dry_run": req.dry_run,
|
||
"confirm": req.confirm,
|
||
},
|
||
"feedback": {"level": "warning", "title": "仅完成参数演练", "message": message, "action": "confirm"},
|
||
"channel_used": "dry_run",
|
||
"trace_id": trace_id,
|
||
}
|
||
|
||
|
||
@router.post(
|
||
"/wechat/qr/execute",
|
||
response_model=HeadlessActionResponse,
|
||
response_model_exclude_none=False,
|
||
tags=["扫一扫", "微信无界面统一动作"],
|
||
summary="执行无线Frida扫码或扫码加友动作",
|
||
description=(
|
||
"五项扫码动作统一经CLI/API→WSS Agent→无线Frida RPC执行。"
|
||
"媒体载荷只透传给无线Agent;未提供真实媒体解码能力时返回wireless_qr_media_decoder_unavailable。"
|
||
),
|
||
responses={
|
||
200: {"description": "二维码读取回执或写类演练结果"},
|
||
422: {"description": "请求字段或媒体载荷参数错误"},
|
||
503: {"description": "WSS、无线Frida、媒体解码、RPC或业务回读未就绪"},
|
||
},
|
||
)
|
||
async def execute_wireless_qr_action(req: WirelessQrActionRequest, response: Response):
|
||
"""扫码接口层统一入口;媒体解析能力由无线Frida RPC声明。"""
|
||
media = req.media.model_dump(exclude_none=True) if req.media else {}
|
||
has_media = bool(media.get("image_base64") or media.get("image_url") or media.get("media_id") or media.get("media_path"))
|
||
is_write = req.action in {"add_friend_by_qr", "scan_add_friend"}
|
||
if is_write:
|
||
if not (req.qr_content or req.wechat_id or has_media):
|
||
return _set_http_status(response, _wireless_qr_invalid_response(req, "加好友动作需要qr_content、wechat_id或媒体载荷。"))
|
||
if req.dry_run or not req.confirm:
|
||
return _set_http_status(response, _wireless_qr_dry_run_response(req))
|
||
elif not has_media:
|
||
return _set_http_status(response, _wireless_qr_invalid_response(req, "二维码读取动作需要image_base64、image_url、media_id或media_path。"))
|
||
|
||
params = {
|
||
**media,
|
||
"media": media or None,
|
||
"qr_content": req.qr_content or req.wechat_id or "",
|
||
"verify_message": req.verify_message,
|
||
"source_type": req.source_type,
|
||
"dry_run": req.dry_run,
|
||
"confirm": req.confirm,
|
||
}
|
||
if media.get("media_path"):
|
||
params.setdefault("image_path", media["media_path"])
|
||
result = await _execute_wechat_wss_frida(
|
||
req.device_id,
|
||
req.action,
|
||
params,
|
||
trace_id=req.trace_id,
|
||
)
|
||
body = _strict_frida_operation_response(
|
||
result,
|
||
action=req.action,
|
||
target_id=req.wechat_id or req.qr_content or media.get("media_id") or media.get("media_path") or "",
|
||
require_readback=True,
|
||
)
|
||
return _set_http_status(response, body)
|
||
|
||
|
||
@router.post("/scan/qr-code", response_model=dict, tags=["扫一扫"])
|
||
async def scan_qr_code(
|
||
device_id: str = Body(...),
|
||
platform: Platform = Body(Platform.WECHAT),
|
||
trace_id: Optional[str] = Body(None),
|
||
):
|
||
"""无线Agent图片媒体解码尚未部署时,明确返回能力缺口。"""
|
||
request_trace_id = str(trace_id or uuid.uuid4().hex)
|
||
return {
|
||
"code": 503,
|
||
"success": False,
|
||
"data": {
|
||
"success": False,
|
||
"error": "capability_unavailable",
|
||
"error_code": "wireless_qr_media_decoder_unavailable",
|
||
"action": "scan_qr_code",
|
||
},
|
||
"trace_id": request_trace_id,
|
||
"channel_used": "wss/frida",
|
||
"raw_rpc_receipt": None,
|
||
"readback": None,
|
||
}
|
||
|
||
@router.post("/scan/add-friend", response_model=dict, tags=["扫一扫"])
|
||
async def scan_add_friend(req: AddFriendByQrRequest):
|
||
"""兼容入口:复用带确认门禁的WSS扫码加友接口。"""
|
||
return await add_friend_by_qr(req)
|
||
|
||
@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"),
|
||
trace_id: Optional[str] = Body(None),
|
||
):
|
||
"""无界面解码调用方上传的二维码图片,不进入相册或点击链路。"""
|
||
request_trace_id = str(trace_id or uuid.uuid4().hex)
|
||
if image_base64:
|
||
decoded = await asyncio.to_thread(_decode_qr_image_base64, image_base64)
|
||
return {
|
||
"code": 200 if decoded.get("success") else 422,
|
||
"success": bool(decoded.get("success")),
|
||
"data": decoded,
|
||
"trace_id": request_trace_id,
|
||
"channel_used": "local_qr_decoder",
|
||
"raw_rpc_receipt": None,
|
||
"readback": decoded,
|
||
}
|
||
return {
|
||
"code": 503,
|
||
"success": False,
|
||
"data": {
|
||
"success": False,
|
||
"error": "capability_unavailable",
|
||
"error_code": "qr_image_url_transport_unavailable",
|
||
"action": "extract_qr_from_image",
|
||
"input_present": bool(image_base64 or image_url),
|
||
},
|
||
"trace_id": request_trace_id,
|
||
"channel_used": "wss/frida",
|
||
"raw_rpc_receipt": None,
|
||
"readback": None,
|
||
}
|
||
|
||
|
||
@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="加好友打招呼语(可选)"),
|
||
dry_run: bool = Body(True, description="默认只做二维码解析和链路预检"),
|
||
confirm: bool = Body(False, description="真实申请需 dry_run=false 且 confirm=true"),
|
||
trace_id: Optional[str] = Body(None, description="跨服务追踪ID"),
|
||
):
|
||
"""二维码图片解码后复用微信内部扫码解析与加友网络场景。"""
|
||
if not image_base64 and not image_url:
|
||
raise HTTPException(status_code=400, detail="必须提供 image_base64 或 image_url 之一")
|
||
return await add_friend_by_qr(
|
||
AddFriendByQrRequest(
|
||
device_id=device_id,
|
||
platform=platform,
|
||
image_base64=image_base64,
|
||
image_url=image_url,
|
||
verify_message=verify_message,
|
||
dry_run=dry_run,
|
||
confirm=confirm,
|
||
trace_id=trace_id,
|
||
)
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# 十九、支付增强
|
||
# =============================================================================
|
||
|
||
@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=MessageOperationResponse, tags=["支付"])
|
||
@router.post("/payment/receive-payment", response_model=MessageOperationResponse, tags=["支付"])
|
||
@router.post("/payment/payment-receive", response_model=MessageOperationResponse, tags=["支付"])
|
||
async def receive_payment(req: ReceivePaymentRequest):
|
||
"""统一 payment_receive/receive_payment;真实内部 RPC 完成前明确返回能力状态。"""
|
||
if req.dry_run or not req.confirm:
|
||
return _message_operation_response(
|
||
{
|
||
"code": 200,
|
||
"success": True,
|
||
"verified": True,
|
||
"channel_used": "dry_run",
|
||
"data": {
|
||
"success": True,
|
||
"verified": True,
|
||
"dry_run": True,
|
||
"confirm_required": True,
|
||
"amount": req.amount,
|
||
"payer_id": req.payer_id,
|
||
},
|
||
},
|
||
action="receive_payment",
|
||
target_id=req.payer_id,
|
||
fallback_channel="dry_run",
|
||
)
|
||
blocked = _payment_guard(
|
||
device_id=req.device_id,
|
||
action="receive_payment",
|
||
target_id=req.payer_id,
|
||
amount=req.amount,
|
||
)
|
||
if blocked:
|
||
return blocked
|
||
_check_device_online(req.device_id, req.platform.value, "receive_payment")
|
||
result = await _execute_skill(
|
||
req.device_id,
|
||
req.platform.value,
|
||
"receive_payment",
|
||
{
|
||
"amount": req.amount,
|
||
"desc": req.desc,
|
||
"payer_id": req.payer_id,
|
||
"confirm": True,
|
||
"dry_run": False,
|
||
},
|
||
timeout=45,
|
||
hook_only=True,
|
||
)
|
||
return _payment_operation_response(result, action="receive_payment", target_id=req.payer_id)
|
||
|
||
@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=MessageOperationResponse, tags=["支付"])
|
||
async def receive_red_packet(req: RedPacketReceiveRequest):
|
||
"""领取红包;默认仅做确认前演练,confirm=true且dry_run=false时执行。"""
|
||
if req.dry_run or not req.confirm:
|
||
return _message_operation_response(
|
||
{
|
||
"code": 200,
|
||
"success": True,
|
||
"verified": True,
|
||
"channel_used": "dry_run",
|
||
"data": {
|
||
"success": True,
|
||
"verified": True,
|
||
"dry_run": True,
|
||
"confirm_required": True,
|
||
"msg_svr_id": req.msg_svr_id,
|
||
"note": "领取参数校验通过;confirm=true且dry_run=false后进入真实红包领取流程",
|
||
},
|
||
},
|
||
action="receive_red_packet",
|
||
target_id=req.from_id or req.msg_svr_id,
|
||
fallback_channel="dry_run",
|
||
)
|
||
blocked = _payment_guard(
|
||
device_id=req.device_id,
|
||
action="receive_red_packet",
|
||
target_id=req.from_id,
|
||
)
|
||
if blocked:
|
||
return blocked
|
||
_check_device_online(req.device_id, req.platform.value, "receive_red_packet")
|
||
result = await _execute_skill(
|
||
req.device_id,
|
||
req.platform.value,
|
||
"receive_red_packet",
|
||
{
|
||
"msg_svr_id": req.msg_svr_id,
|
||
"from_id": req.from_id,
|
||
"confirm": req.confirm,
|
||
"dry_run": req.dry_run,
|
||
},
|
||
)
|
||
return _payment_operation_response(
|
||
result,
|
||
action="receive_red_packet",
|
||
target_id=req.from_id or req.msg_svr_id,
|
||
)
|
||
|
||
|
||
@router.post("/payment/receive-incoming-batch", response_model=dict, tags=["支付"])
|
||
async def receive_incoming_payment_batch(req: IncomingPaymentBatchReceiveRequest):
|
||
"""逐条识别并领取红包/确认转账,返回每次结果与汇总计数。"""
|
||
_check_device_online(req.device_id, req.platform.value, "get_messages")
|
||
ids = list(dict.fromkeys(str(item).strip() for item in req.msg_svr_ids if str(item).strip()))
|
||
message_result = await _execute_skill(
|
||
req.device_id,
|
||
req.platform.value,
|
||
"get_messages",
|
||
{"limit": 100, "offset": 0},
|
||
)
|
||
payload = _payload_of(message_result)
|
||
messages = _list_from_payload(payload, "messages", "items", "list")
|
||
by_id = {str(item.get("id") or item.get("msg_svr_id") or ""): item for item in messages}
|
||
results = []
|
||
for index, message_id in enumerate(ids, start=1):
|
||
message = by_id.get(message_id)
|
||
content = str((message or {}).get("content") or "")
|
||
lower = content.lower()
|
||
if "<title><![cdata[微信转账]]" in lower or "<wcpayinfo>" in lower:
|
||
payment_type = "transfer"
|
||
action = "receive_transfer"
|
||
elif "nativeurl" in lower or "native_url" in lower or "sendid" in lower or "send_id" in lower:
|
||
payment_type = "red_packet"
|
||
action = "receive_red_packet"
|
||
else:
|
||
results.append({
|
||
"index": index,
|
||
"msg_svr_id": message_id,
|
||
"payment_type": "unknown",
|
||
"success": False,
|
||
"status": "skipped",
|
||
"feedback": "消息未识别为微信红包或微信转账",
|
||
})
|
||
continue
|
||
if req.dry_run or not req.confirm:
|
||
results.append({
|
||
"index": index,
|
||
"msg_svr_id": message_id,
|
||
"payment_type": payment_type,
|
||
"action": action,
|
||
"success": True,
|
||
"status": "dry_run",
|
||
"feedback": f"第{index}次已识别为{payment_type}",
|
||
})
|
||
continue
|
||
result = await _execute_skill(
|
||
req.device_id,
|
||
req.platform.value,
|
||
action,
|
||
{
|
||
"msg_svr_id": message_id,
|
||
"from_id": req.from_id or str((message or {}).get("from_id") or ""),
|
||
"confirm": True,
|
||
"dry_run": False,
|
||
},
|
||
)
|
||
operation_payload = _payload_of(result)
|
||
success = bool(operation_payload.get("success", result.get("success", False)))
|
||
results.append({
|
||
"index": index,
|
||
"msg_svr_id": message_id,
|
||
"payment_type": payment_type,
|
||
"action": action,
|
||
"success": success,
|
||
"status": "verified" if success else "operation_failed",
|
||
"feedback": f"第{index}次{'处理成功' if success else '处理失败'}",
|
||
"receipt": operation_payload,
|
||
})
|
||
success_count = sum(1 for item in results if item["success"] and item["status"] == "verified")
|
||
dry_run_count = sum(1 for item in results if item["status"] == "dry_run")
|
||
skipped_count = sum(1 for item in results if item["status"] == "skipped")
|
||
failed_count = len(results) - success_count - dry_run_count - skipped_count
|
||
return {
|
||
"code": 200,
|
||
"success": failed_count == 0 and skipped_count == 0,
|
||
"data": {
|
||
"requested_count": len(ids),
|
||
"processed_count": len(results),
|
||
"success_count": success_count,
|
||
"failed_count": failed_count,
|
||
"skipped_count": skipped_count,
|
||
"dry_run_count": dry_run_count,
|
||
"results": results,
|
||
},
|
||
"feedback": {
|
||
"level": "success" if failed_count == 0 and skipped_count == 0 else "warning",
|
||
"title": "批量领取处理完成",
|
||
"message": f"请求{len(ids)}次,成功{success_count}次,失败{failed_count}次,跳过{skipped_count}次",
|
||
"action": "none" if failed_count == 0 and skipped_count == 0 else "check_results",
|
||
},
|
||
"channel_used": str(message_result.get("_channel_used") or "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, image_path: str = ""):
|
||
"""设置朋友圈封面"""
|
||
_check_device_online(device_id)
|
||
result = await _execute_skill(
|
||
device_id, platform.value, "set_moments_cover", {"image_path": image_path}
|
||
)
|
||
payload = result.get("data") if isinstance(result.get("data"), dict) else result
|
||
channel = result.get("_channel_used") or result.get("channel") or "sdk_control"
|
||
return {"code": 200, "success": bool(payload.get("success")) if isinstance(payload, dict) else False, "data": payload, "trace_id": result.get("trace_id") or payload.get("trace_id"), "raw_rpc_receipt": result.get("raw_rpc_receipt") or payload, "readback": result.get("readback") or payload.get("readback"), "channel_used": "frida_rpc" if "frida" in channel else channel}
|
||
|
||
@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 isinstance(result.get("data"), dict) else result
|
||
channel = result.get("_channel_used") or result.get("channel") or "sdk_control"
|
||
return {"code": 200, "success": bool(payload.get("success")) if isinstance(payload, dict) else False, "data": payload, "trace_id": result.get("trace_id") or payload.get("trace_id"), "raw_rpc_receipt": result.get("raw_rpc_receipt") or payload, "readback": result.get("readback") or payload.get("readback"), "channel_used": "frida_rpc" if "frida" in channel else channel}
|
||
|
||
@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 isinstance(result.get("data"), dict) else result
|
||
channel = result.get("_channel_used") or result.get("channel") or "sdk_control"
|
||
return {"code": 200, "success": bool(payload.get("success")) if isinstance(payload, dict) else False, "data": payload, "trace_id": result.get("trace_id") or payload.get("trace_id"), "raw_rpc_receipt": result.get("raw_rpc_receipt") or payload, "readback": result.get("readback") or payload.get("readback"), "channel_used": "frida_rpc" if "frida" in channel else channel}
|
||
|
||
|
||
# =============================================================================
|
||
# 二十七、语音消息
|
||
# =============================================================================
|
||
|
||
@router.post(
|
||
"/message/voice",
|
||
response_model=MessageOperationResponse,
|
||
response_model_exclude_none=True,
|
||
tags=["消息管理"],
|
||
summary="发送微信语音消息",
|
||
description="支持设备端现场录音或指定voice_path;返回真实执行通道与错误。",
|
||
responses=MESSAGE_WRITE_RESPONSES,
|
||
)
|
||
async def send_voice_message(req: SendVoiceMessageRequest):
|
||
"""发送语音消息"""
|
||
_check_device_online(req.device_id)
|
||
tid = (req.to_id or req.user_id).strip()
|
||
if not tid:
|
||
return _message_operation_response(
|
||
{"code": 422, "success": False, "error": "to_id与user_id至少填写一个", "error_code": "validation_failed"},
|
||
action="send_voice_message",
|
||
fallback_channel="none",
|
||
)
|
||
result = await _execute_skill(req.device_id, req.platform.value, "send_voice_message", {
|
||
"to_id": tid,
|
||
"user_id": tid,
|
||
"duration": req.duration,
|
||
"voice_path": req.voice_path,
|
||
})
|
||
return _message_operation_response(
|
||
result,
|
||
action="send_voice_message",
|
||
target_id=tid,
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# 二十八、文件管理
|
||
# =============================================================================
|
||
|
||
@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),
|
||
}
|
||
if req.media_url:
|
||
hook_params["media_url"] = req.media_url
|
||
if req.at_list:
|
||
hook_params["at_list"] = req.at_list
|
||
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
|
||
import base64
|
||
import io
|
||
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": "configured", "max_steps": 30}
|
||
}, timeout=120)
|
||
if result.get("code") == 200 and result.get("data"):
|
||
payload = result.get("data", {})
|
||
success = payload.get("success") is True
|
||
return {
|
||
"success": success,
|
||
"message_id": payload.get("message_id") or payload.get("msg_svr_id"),
|
||
"error": None if success else payload.get("error") or "AI Agent执行失败",
|
||
"channel_used": "ai_agent",
|
||
}
|
||
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": None,
|
||
"verified": False,
|
||
"channel_used": "ai_agent/adb",
|
||
}
|
||
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", "")),
|
||
}
|