WechatOnCloud/bridge/woc_bridge/routes/moments.py
Kris 15fc62d478 feat: 完成微信UI自动化新架构全量开发与集成
- 新增六层UI自动化架构:从Backend到Capabilities的完整分层实现
- 添加WeChat 4.0分辨率适配Profile与图像模板资源
- 实现幂等缓存、熔断器、重试策略、链路追踪与监控指标
- 新增头像下载安全校验、发布朋友圈路径白名单防护
- 优化密钥缓存、DB校验逻辑与初始化流程
- 补充完整错误码体系与启动清场机制
2026-07-16 01:19:47 +08:00

505 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import asyncio
import logging
import os
from fastapi import APIRouter
from woc_bridge.config import _require_send_queue, _require_xdotool, _require_db_reader
from woc_bridge.db.coordinator import with_db_retry
from woc_bridge.models import (
BridgeError,
MomentPublishRequest,
MomentPublishResponse,
MomentShareArticleRequest,
MomentShareArticleResponse,
MomentsTimelineResponse,
MomentLikeRequest,
MomentLikeResponse,
MomentCommentRequest,
MomentCommentResponse,
MomentDeleteRequest,
MomentDeleteResponse,
MomentPublishImageRequest,
MomentPublishImageResponse,
)
logger = logging.getLogger("woc-bridge")
router = APIRouter()
# 路径白名单:仅允许容器内安全目录的图片(用于 publish_image 防路径穿越)
_SAFE_IMAGE_DIRS = (
"/config/",
"/tmp/",
"/data/",
)
# ---------------------------------------------------------------------------
# 路由GET /api/moments/timeline
# ---------------------------------------------------------------------------
@router.get("/api/moments/timeline", response_model=MomentsTimelineResponse)
@with_db_retry
async def get_moments_timeline(
cursor: int = 0,
limit: int = 20,
) -> MomentsTimelineResponse:
"""读取朋友圈时间线。
WeChat 4.x 朋友圈 schema 未公开,本接口做容错探测:
1. 探测 moment/sns DB 文件
2. 探测朋友圈表名与列名
3. 按 create_time 降序返回
因 schema 不确定,响应中含 status 字段标识探测结果:
- ok正常读取
- db_not_found未找到朋友圈 DB可能该账号从未发过/看过朋友圈)
- table_not_found / no_columns / no_time_columnDB 存在但结构不匹配
- db_encryptedDB 加密且无法解密
- query_error查询异常
Args:
cursor: 游标,首次传 0取 create_time < cursor 的更旧朋友圈
limit: 最多返回条数1~50默认 20
Returns:
MomentsTimelineResponse含 moments / next_cursor / has_more / status
Raises:
BridgeError(INVALID_PARAMS): limit 越界HTTP 400
BridgeError(DB_ENCRYPTED): DB 加密且 key 提取失败HTTP 503
由 with_db_retry 触发重试)
Notes:
- 不抛 DB_NOT_FOUND朋友圈 DB 不存在属正常情况(新账号),
通过 status=db_not_found 告知客户端
- content 仅为文字内容;图片/视频等媒体字段暂未解析
- author_wxid 可能为空DB 中无对应列或值为空)
"""
if limit < 1 or limit > 50:
raise BridgeError(
code="INVALID_PARAMS",
message=f"limit 必须在 1~50 之间,收到 {limit}",
)
if cursor < 0:
raise BridgeError(
code="INVALID_PARAMS",
message=f"cursor 必须 >= 0收到 {cursor}",
)
db_reader = _require_db_reader()
result = await asyncio.to_thread(
db_reader.get_moments_timeline, cursor, limit
)
moment_count = len(result.get("moments", []))
logger.info(
"moments/timeline: cursor=%d limit=%d → 返回 %d 条, status=%s",
cursor, limit, moment_count, result.get("status"),
)
return MomentsTimelineResponse(**result)
# ---------------------------------------------------------------------------
# 路由POST /api/moments/publish
# ---------------------------------------------------------------------------
@router.post("/api/moments/publish", response_model=MomentPublishResponse)
async def publish_moment(req: MomentPublishRequest) -> MomentPublishResponse:
"""发表纯文字朋友圈。
通过 xdotool 模拟 UI 操作发表朋友圈:激活窗口 → 点击朋友圈入口 →
点相机 → 选发表文字 → 粘贴内容 → 发表 → 返回主界面。经 send_queue
串行执行,避免与发消息等 UI 操作竞态。
Args:
req: MomentPublishRequestcontent 朋友圈文字内容)
Returns:
MomentPublishResponsesuccess=true / local_moment_id
/ placeholder=false真实执行 UI 发表操作)
Raises:
BridgeError(INVALID_PARAMS): content 为空HTTP 400
BridgeError(WECHAT_NOT_LOGGED_IN): 当前未登录HTTP 401
BridgeError(WINDOW_NOT_FOUND): 微信窗口未找到HTTP 503
BridgeError(SEND_FAILED): UI 操作失败或超时HTTP 500
Notes:
- 不走 with_db_retry发朋友圈是纯 UI 操作,不读 DB
- 经 send_queue 串行执行:朋友圈与发消息共享同一 X 会话,
必须串行避免窗口焦点竞态
- UI 路径(朋友圈入口/相机/发表按钮坐标)基于微信 4.0 Linux 默认
布局估算,需在目标分辨率实测调优
- local_moment_id 由 bridge 本地生成,不对应微信原生朋友圈 ID
仅用于客户端幂等去重
"""
xdotool = _require_xdotool()
send_queue = _require_send_queue()
# 校验请求体
if not req.content:
raise BridgeError(code="INVALID_PARAMS", message="content 不能为空")
# 检查登录态
login_state = await xdotool.detect_login_state()
if login_state != "logged_in":
raise BridgeError(
code="WECHAT_NOT_LOGGED_IN",
message=f"当前登录态为 {login_state},无法发表朋友圈",
)
# 经发送队列串行执行(避免与发消息 UI 竞态)
try:
local_moment_id = await send_queue.enqueue(
lambda: xdotool.publish_moment(req.content)
)
except BridgeError:
raise
except Exception as e:
raise BridgeError(
code="SEND_FAILED",
message=f"发表朋友圈失败: {e}",
)
logger.info(
"moments/publish: content_len=%d → 成功 local_moment_id=%s",
len(req.content), local_moment_id,
)
return MomentPublishResponse(
success=True,
local_moment_id=local_moment_id,
placeholder=False,
error=None,
)
# ---------------------------------------------------------------------------
# 路由POST /api/moments/share_article
# ---------------------------------------------------------------------------
@router.post("/api/moments/share_article", response_model=MomentShareArticleResponse)
async def share_article_moment(req: MomentShareArticleRequest) -> MomentShareArticleResponse:
"""分享公众号文章到朋友圈。
通过搜索公众号名称进入会话,点击第 N 篇推送文章卡片打开文章页,
再经「分享到朋友圈」UI 路径生成带卡片预览的朋友圈。经 send_queue
串行执行,避免与发消息等 UI 操作竞态。
Args:
req: MomentShareArticleRequestpublic_account + article_index + 可选 comment
Returns:
MomentShareArticleResponsesuccess=true / local_moment_id
/ placeholder=false真实执行 UI 分享操作)
Raises:
BridgeError(INVALID_PARAMS): public_account 为空或 article_index < 1HTTP 400
BridgeError(WECHAT_NOT_LOGGED_IN): 当前未登录HTTP 401
BridgeError(WINDOW_NOT_FOUND): 微信窗口未找到HTTP 503
BridgeError(SEND_FAILED): UI 操作失败或超时HTTP 500
Notes:
- 不走 with_db_retry分享是纯 UI 操作,不读 DB
- 经 send_queue 串行执行:与发消息共享同一 X 会话,必须串行避免
窗口焦点竞态
- UI 路径(文章卡片位置、「⋯」菜单、分享弹窗坐标)基于微信 4.0
Linux 默认布局估算,需在目标分辨率实测调优
- article_index=1 表示公众号会话顶部最新一篇推送文章
- local_moment_id 由 bridge 本地生成,不对应微信原生朋友圈 ID
仅用于客户端幂等去重
"""
xdotool = _require_xdotool()
send_queue = _require_send_queue()
# 校验请求体
if not req.public_account:
raise BridgeError(code="INVALID_PARAMS", message="public_account 不能为空")
if req.article_index < 1:
raise BridgeError(
code="INVALID_PARAMS",
message=f"article_index 必须 >= 1收到 {req.article_index}",
)
# 检查登录态
login_state = await xdotool.detect_login_state()
if login_state != "logged_in":
raise BridgeError(
code="WECHAT_NOT_LOGGED_IN",
message=f"当前登录态为 {login_state},无法分享文章",
)
# 经发送队列串行执行(避免与发消息 UI 竞态)
try:
local_moment_id = await send_queue.enqueue(
lambda: xdotool.share_article_moment(
req.public_account,
article_index=req.article_index,
comment=req.comment,
)
)
except BridgeError:
raise
except Exception as e:
raise BridgeError(
code="SEND_FAILED",
message=f"分享文章失败: {e}",
)
logger.info(
"moments/share_article: public_account=%s index=%d comment_len=%d → 成功 local_moment_id=%s",
req.public_account, req.article_index,
len(req.comment) if req.comment else 0,
local_moment_id,
)
return MomentShareArticleResponse(
success=True,
local_moment_id=local_moment_id,
placeholder=False,
error=None,
)
# ---------------------------------------------------------------------------
# 路由POST /api/moments/like (experimental)
# ---------------------------------------------------------------------------
@router.post("/api/moments/like", response_model=MomentLikeResponse)
async def moment_like(req: MomentLikeRequest) -> MomentLikeResponse:
"""给朋友圈点赞experimental
自动进入朋友圈页面后执行点赞操作。
Args:
req: MomentLikeRequestmoment_index1=最新)
Returns:
MomentLikeResponse
Raises:
BridgeError(INVALID_PARAMS): moment_index < 1HTTP 400
BridgeError(WECHAT_NOT_LOGGED_IN): 未登录HTTP 401
BridgeError(WINDOW_NOT_FOUND / SEND_FAILED)
Notes:
- experimental朋友圈"赞/评论"按钮坐标为估算值
- 自动进入朋友圈页面,无需调用方预先进入
"""
if req.moment_index < 1:
raise BridgeError(
code="INVALID_PARAMS",
message=f"moment_index 必须 >= 1收到 {req.moment_index}",
)
xdotool = _require_xdotool()
send_queue = _require_send_queue()
login_state = await xdotool.detect_login_state()
if login_state != "logged_in":
raise BridgeError(
code="WECHAT_NOT_LOGGED_IN",
message=f"当前登录态为 {login_state},无法点赞",
)
try:
await send_queue.enqueue(lambda: xdotool.moment_like(req.moment_index))
except BridgeError:
raise
except Exception as e:
raise BridgeError(
code="SEND_FAILED",
message=f"点赞失败: {e}",
)
logger.info("moments/like: index=%d → UI 操作完成", req.moment_index)
return MomentLikeResponse(success=True, error=None)
# ---------------------------------------------------------------------------
# 路由POST /api/moments/comment (experimental)
# ---------------------------------------------------------------------------
@router.post("/api/moments/comment", response_model=MomentCommentResponse)
async def moment_comment(req: MomentCommentRequest) -> MomentCommentResponse:
"""评论朋友圈experimental
自动进入朋友圈页面后执行评论操作。
Args:
req: MomentCommentRequest
Returns:
MomentCommentResponse
Raises:
BridgeError(INVALID_PARAMS): comment 为空或 moment_index < 1HTTP 400
BridgeError(WECHAT_NOT_LOGGED_IN): 未登录HTTP 401
BridgeError(WINDOW_NOT_FOUND / SEND_FAILED)
Notes:
- experimental坐标为估算值
- 自动进入朋友圈页面,无需调用方预先进入
"""
if not req.comment:
raise BridgeError(code="INVALID_PARAMS", message="comment 不能为空")
if req.moment_index < 1:
raise BridgeError(
code="INVALID_PARAMS",
message=f"moment_index 必须 >= 1收到 {req.moment_index}",
)
xdotool = _require_xdotool()
send_queue = _require_send_queue()
login_state = await xdotool.detect_login_state()
if login_state != "logged_in":
raise BridgeError(
code="WECHAT_NOT_LOGGED_IN",
message=f"当前登录态为 {login_state},无法评论",
)
try:
await send_queue.enqueue(
lambda: xdotool.moment_comment(req.comment, req.moment_index)
)
except BridgeError:
raise
except Exception as e:
raise BridgeError(
code="SEND_FAILED",
message=f"评论失败: {e}",
)
logger.info(
"moments/comment: index=%d comment_len=%d → UI 操作完成",
req.moment_index, len(req.comment),
)
return MomentCommentResponse(success=True, error=None)
# ---------------------------------------------------------------------------
# 路由POST /api/moments/delete (experimental)
# ---------------------------------------------------------------------------
@router.post("/api/moments/delete", response_model=MomentDeleteResponse)
async def moment_delete(req: MomentDeleteRequest) -> MomentDeleteResponse:
"""删除自己的朋友圈experimental
自动进入朋友圈页面后执行删除操作,避免在聊天界面误删消息。
moment_index 应指向自己发的朋友圈。
Args:
req: MomentDeleteRequest
Returns:
MomentDeleteResponse
Raises:
BridgeError(INVALID_PARAMS): moment_index < 1HTTP 400
BridgeError(WECHAT_NOT_LOGGED_IN): 未登录HTTP 401
BridgeError(WINDOW_NOT_FOUND / SEND_FAILED)
Notes:
- experimental长按位置与菜单项坐标为估算值
- 自动进入朋友圈页面,避免在聊天界面误删消息
- 只能删除自己发的朋友圈;长按别人的朋友圈不会出现"删除"
"""
if req.moment_index < 1:
raise BridgeError(
code="INVALID_PARAMS",
message=f"moment_index 必须 >= 1收到 {req.moment_index}",
)
xdotool = _require_xdotool()
send_queue = _require_send_queue()
login_state = await xdotool.detect_login_state()
if login_state != "logged_in":
raise BridgeError(
code="WECHAT_NOT_LOGGED_IN",
message=f"当前登录态为 {login_state},无法删除朋友圈",
)
try:
await send_queue.enqueue(lambda: xdotool.moment_delete(req.moment_index))
except BridgeError:
raise
except Exception as e:
raise BridgeError(
code="SEND_FAILED",
message=f"删除朋友圈失败: {e}",
)
logger.info("moments/delete: index=%d → UI 操作完成", req.moment_index)
return MomentDeleteResponse(success=True, error=None)
# ---------------------------------------------------------------------------
# 路由POST /api/moments/publish_image (experimental)
# ---------------------------------------------------------------------------
@router.post("/api/moments/publish_image", response_model=MomentPublishImageResponse)
async def publish_moment_with_image(
req: MomentPublishImageRequest,
) -> MomentPublishImageResponse:
"""发表带图片的朋友圈experimental
UI 自动化路径:进入朋友圈 → 点相机 → 选"发表图片" → 文件选择器
粘贴图片路径 → 可选输入文字 → 点"发表" → 返回主界面。
Args:
req: MomentPublishImageRequest
Returns:
MomentPublishImageResponse
Raises:
BridgeError(INVALID_PARAMS): image_path 为空或文件不存在HTTP 400
BridgeError(WECHAT_NOT_LOGGED_IN): 未登录HTTP 401
BridgeError(WINDOW_NOT_FOUND / SEND_FAILED)
Notes:
- experimental相机按钮、文件选择器、发表按钮坐标均为估算值
- image_path 必须是容器内可访问的绝对路径
"""
if not req.image_path:
raise BridgeError(code="INVALID_PARAMS", message="image_path 不能为空")
# 路径白名单:仅允许容器内安全目录的图片(防止 /etc/shadow 等敏感文件被读取)
_real = os.path.realpath(req.image_path)
if not _real.startswith(_SAFE_IMAGE_DIRS):
raise BridgeError(
code="INVALID_PARAMS",
message=f"image_path 必须位于安全目录 {_SAFE_IMAGE_DIRS} 内,收到 {_real}",
)
if not os.path.isfile(req.image_path) or not os.access(req.image_path, os.R_OK):
raise BridgeError(
code="INVALID_PARAMS",
message=f"图片不存在或不可读: {req.image_path}",
)
xdotool = _require_xdotool()
send_queue = _require_send_queue()
login_state = await xdotool.detect_login_state()
if login_state != "logged_in":
raise BridgeError(
code="WECHAT_NOT_LOGGED_IN",
message=f"当前登录态为 {login_state},无法发表朋友圈",
)
try:
local_moment_id = await send_queue.enqueue(
lambda: xdotool.publish_moment_with_image(req.image_path, req.content)
)
except BridgeError:
raise
except Exception as e:
raise BridgeError(
code="SEND_FAILED",
message=f"发表图片朋友圈失败: {e}",
)
logger.info(
"moments/publish_image: image=%s content_len=%d → 成功 local_moment_id=%s",
req.image_path, len(req.content) if req.content else 0, local_moment_id,
)
return MomentPublishImageResponse(
success=True,
local_moment_id=local_moment_id,
error=None,
)