WechatOnCloud/bridge/woc_bridge/routes/moments.py

717 lines
27 KiB
Python
Raw Normal View History

from __future__ import annotations
import asyncio
import logging
import os
import time
from typing import Optional
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()
t_total = time.perf_counter()
# 校验请求体
if not req.content:
logger.warning("moments/publish: 参数校验失败 — content 为空")
raise BridgeError(code="INVALID_PARAMS", message="content 不能为空")
logger.info("moments/publish: 收到请求 content_len=%d", len(req.content))
# 检查登录态
t0 = time.perf_counter()
login_state = await xdotool.detect_login_state()
logger.info(
"moments/publish: 登录态检测 → %s (%.0fms)",
login_state, (time.perf_counter() - t0) * 1000,
)
if login_state != "logged_in":
logger.warning("moments/publish: 拒绝,登录态=%s", login_state)
raise BridgeError(
code="WECHAT_NOT_LOGGED_IN",
message=f"当前登录态为 {login_state},无法发表朋友圈",
)
# 经发送队列串行执行(避免与发消息 UI 竞态)
logger.info("moments/publish: 入队 send_queue (pending=%d)", send_queue.pending_count())
try:
t0 = time.perf_counter()
local_moment_id = await send_queue.enqueue(
lambda: xdotool.publish_moment(req.content)
)
logger.info(
"moments/publish: 队列执行完成 → local_moment_id=%s (%.0fms)",
local_moment_id, (time.perf_counter() - t0) * 1000,
)
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 (总耗时 %.0fms)",
len(req.content), local_moment_id,
(time.perf_counter() - t_total) * 1000,
)
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()
t_total = time.perf_counter()
# 校验请求体
if not req.public_account:
logger.warning("moments/share_article: 参数校验失败 — public_account 为空")
raise BridgeError(code="INVALID_PARAMS", message="public_account 不能为空")
if req.article_index < 1:
logger.warning(
"moments/share_article: 参数校验失败 — article_index=%d (public_account=%s)",
req.article_index, req.public_account,
)
raise BridgeError(
code="INVALID_PARAMS",
message=f"article_index 必须 >= 1收到 {req.article_index}",
)
logger.info(
"moments/share_article: 收到请求 public_account=%s index=%d comment_len=%d",
req.public_account, req.article_index,
len(req.comment) if req.comment else 0,
)
# 检查登录态
t0 = time.perf_counter()
login_state = await xdotool.detect_login_state()
logger.info(
"moments/share_article: 登录态检测 → %s (%.0fms)",
login_state, (time.perf_counter() - t0) * 1000,
)
if login_state != "logged_in":
logger.warning("moments/share_article: 拒绝,登录态=%s", login_state)
raise BridgeError(
code="WECHAT_NOT_LOGGED_IN",
message=f"当前登录态为 {login_state},无法分享文章",
)
# 经发送队列串行执行(避免与发消息 UI 竞态)
logger.info("moments/share_article: 入队 send_queue (pending=%d)", send_queue.pending_count())
try:
t0 = time.perf_counter()
local_moment_id = await send_queue.enqueue(
lambda: xdotool.share_article_moment(
req.public_account,
article_index=req.article_index,
comment=req.comment,
)
)
logger.info(
"moments/share_article: 队列执行完成 → local_moment_id=%s (%.0fms)",
local_moment_id, (time.perf_counter() - t0) * 1000,
)
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 (总耗时 %.0fms)",
req.public_account, req.article_index,
len(req.comment) if req.comment else 0,
local_moment_id,
(time.perf_counter() - t_total) * 1000,
)
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朋友圈"赞/评论"按钮坐标为估算值
- 自动进入朋友圈页面无需调用方预先进入
"""
t_total = time.perf_counter()
if req.moment_index < 1:
logger.warning(
"moments/like: 参数校验失败 — moment_index=%d",
req.moment_index,
)
raise BridgeError(
code="INVALID_PARAMS",
message=f"moment_index 必须 >= 1收到 {req.moment_index}",
)
xdotool = _require_xdotool()
send_queue = _require_send_queue()
logger.info("moments/like: 收到请求 index=%d", req.moment_index)
t0 = time.perf_counter()
login_state = await xdotool.detect_login_state()
logger.info(
"moments/like: 登录态检测 → %s (%.0fms)",
login_state, (time.perf_counter() - t0) * 1000,
)
if login_state != "logged_in":
logger.warning("moments/like: 拒绝,登录态=%s", login_state)
raise BridgeError(
code="WECHAT_NOT_LOGGED_IN",
message=f"当前登录态为 {login_state},无法点赞",
)
# P0 修复post_verify 与 UI 操作一起在 send_queue 内串行执行,
# 避免 after 截图被下一个 UI 操作污染send_queue 积压时 post_verify
# 的 sleep 期间会有新 UI 操作开始,导致 after 截图失真)。
async def _like_and_verify() -> bool:
await xdotool.moment_like(req.moment_index)
# Task 11: post_verify 截图 absdiff 校验点赞是否生效
try:
return await xdotool._moment.post_verify_moment_like(
None, req.moment_index
)
except Exception as exc:
logger.warning(
"moments/like: post_verify 异常 (index=%d): %s",
req.moment_index, exc,
)
return False
verified: bool | None = None
logger.info("moments/like: 入队 send_queue (pending=%d)", send_queue.pending_count())
try:
t0 = time.perf_counter()
verified = await send_queue.enqueue(_like_and_verify)
logger.info(
"moments/like: 队列执行完成 → verified=%s (%.0fms)",
verified, (time.perf_counter() - t0) * 1000,
)
except BridgeError:
raise
except Exception as e:
raise BridgeError(
code="SEND_FAILED",
message=f"点赞失败: {e}",
)
logger.info(
"moments/like: index=%d → UI 操作完成 (总耗时 %.0fms)",
req.moment_index, (time.perf_counter() - t_total) * 1000,
)
return MomentLikeResponse(success=True, error=None, verified=verified)
# ---------------------------------------------------------------------------
# 路由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坐标为估算值
- 自动进入朋友圈页面无需调用方预先进入
"""
t_total = time.perf_counter()
if not req.comment:
logger.warning("moments/comment: 参数校验失败 — comment 为空")
raise BridgeError(code="INVALID_PARAMS", message="comment 不能为空")
if req.moment_index < 1:
logger.warning(
"moments/comment: 参数校验失败 — moment_index=%d (comment_len=%d)",
req.moment_index, len(req.comment),
)
raise BridgeError(
code="INVALID_PARAMS",
message=f"moment_index 必须 >= 1收到 {req.moment_index}",
)
xdotool = _require_xdotool()
send_queue = _require_send_queue()
db_reader = _require_db_reader()
logger.info(
"moments/comment: 收到请求 index=%d comment_len=%d",
req.moment_index, len(req.comment),
)
t0 = time.perf_counter()
login_state = await xdotool.detect_login_state()
logger.info(
"moments/comment: 登录态检测 → %s (%.0fms)",
login_state, (time.perf_counter() - t0) * 1000,
)
if login_state != "logged_in":
logger.warning("moments/comment: 拒绝,登录态=%s", login_state)
raise BridgeError(
code="WECHAT_NOT_LOGGED_IN",
message=f"当前登录态为 {login_state},无法评论",
)
# P0 修复post_verify 与 UI 操作一起在 send_queue 内串行执行,
# 避免 after 截图被下一个 UI 操作污染(与 moment_like 同理)。
async def _comment_and_verify() -> bool:
await xdotool.moment_comment(req.comment, req.moment_index)
# Task 11: post_verify 校验评论是否写入 DBMomentsComment 表存在时)
# 或截图 absdiff 兜底(表不存在时)
try:
return await xdotool._moment.post_verify_moment_comment(
db_reader, req.comment, req.moment_index
)
except Exception as exc:
logger.warning(
"moments/comment: post_verify 异常 (index=%d): %s",
req.moment_index, exc,
)
return False
verified: bool | None = None
logger.info("moments/comment: 入队 send_queue (pending=%d)", send_queue.pending_count())
try:
t0 = time.perf_counter()
verified = await send_queue.enqueue(_comment_and_verify)
logger.info(
"moments/comment: 队列执行完成 → verified=%s (%.0fms)",
verified, (time.perf_counter() - t0) * 1000,
)
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 操作完成 (总耗时 %.0fms)",
req.moment_index, len(req.comment),
(time.perf_counter() - t_total) * 1000,
)
return MomentCommentResponse(success=True, error=None, verified=verified)
# ---------------------------------------------------------------------------
# 路由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长按位置与菜单项坐标为估算值
- 自动进入朋友圈页面避免在聊天界面误删消息
- 只能删除自己发的朋友圈长按别人的朋友圈不会出现"删除"
"""
t_total = time.perf_counter()
if req.moment_index < 1:
logger.warning(
"moments/delete: 参数校验失败 — moment_index=%d",
req.moment_index,
)
raise BridgeError(
code="INVALID_PARAMS",
message=f"moment_index 必须 >= 1收到 {req.moment_index}",
)
xdotool = _require_xdotool()
send_queue = _require_send_queue()
db_reader = _require_db_reader()
logger.info("moments/delete: 收到请求 index=%d", req.moment_index)
t0 = time.perf_counter()
login_state = await xdotool.detect_login_state()
logger.info(
"moments/delete: 登录态检测 → %s (%.0fms)",
login_state, (time.perf_counter() - t0) * 1000,
)
if login_state != "logged_in":
logger.warning("moments/delete: 拒绝,登录态=%s", login_state)
raise BridgeError(
code="WECHAT_NOT_LOGGED_IN",
message=f"当前登录态为 {login_state},无法删除朋友圈",
)
# P0 修复:删除前捕获朋友圈数量基线,供 post_verify 比对。
# 原实现 post_verify 只查 status=ok 就返回 True完全未校验删除生效。
baseline_count: Optional[int] = None
t0 = time.perf_counter()
try:
result = await asyncio.to_thread(db_reader.get_moments_timeline, 0, 50)
if result.get("status") == "ok":
baseline_count = len(result.get("moments", []))
logger.info(
"moments/delete: baseline_count 捕获成功 → %d (%.0fms)",
baseline_count, (time.perf_counter() - t0) * 1000,
)
else:
logger.warning(
"moments/delete: baseline_count 捕获失败status=%s (%.0fms)",
result.get("status"), (time.perf_counter() - t0) * 1000,
)
except Exception as exc:
logger.warning(
"moments/delete: baseline_count 捕获异常: %s (%.0fms)",
exc, (time.perf_counter() - t0) * 1000,
)
logger.info("moments/delete: 入队 send_queue (pending=%d)", send_queue.pending_count())
try:
t0 = time.perf_counter()
await send_queue.enqueue(lambda: xdotool.moment_delete(req.moment_index))
logger.info(
"moments/delete: 队列执行完成 (%.0fms)",
(time.perf_counter() - t0) * 1000,
)
except BridgeError:
raise
except Exception as e:
raise BridgeError(
code="SEND_FAILED",
message=f"删除朋友圈失败: {e}",
)
logger.info(
"moments/delete: index=%d → UI 操作完成 (总耗时 %.0fms)",
req.moment_index, (time.perf_counter() - t_total) * 1000,
)
# Task 11: post_verify 校验 MomentsPost 记录是否已删除
verified: bool | None = None
try:
verified = await xdotool._moment.post_verify_moment_delete(
db_reader, req.moment_index, baseline_count=baseline_count
)
except Exception as exc:
logger.warning(
"moments/delete: post_verify 异常 (index=%d): %s",
req.moment_index, exc,
)
verified = False
return MomentDeleteResponse(success=True, error=None, verified=verified)
# ---------------------------------------------------------------------------
# 路由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 必须是容器内可访问的绝对路径
"""
t_total = time.perf_counter()
if not req.image_path:
logger.warning("moments/publish_image: 参数校验失败 — 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):
logger.warning(
"moments/publish_image: 路径校验失败 — image_path=%s 不在安全目录 %s",
_real, _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):
logger.warning(
"moments/publish_image: 路径校验失败 — 图片不存在或不可读 image_path=%s",
req.image_path,
)
raise BridgeError(
code="INVALID_PARAMS",
message=f"图片不存在或不可读: {req.image_path}",
)
xdotool = _require_xdotool()
send_queue = _require_send_queue()
logger.info(
"moments/publish_image: 收到请求 image=%s content_len=%d",
req.image_path, len(req.content) if req.content else 0,
)
t0 = time.perf_counter()
login_state = await xdotool.detect_login_state()
logger.info(
"moments/publish_image: 登录态检测 → %s (%.0fms)",
login_state, (time.perf_counter() - t0) * 1000,
)
if login_state != "logged_in":
logger.warning("moments/publish_image: 拒绝,登录态=%s", login_state)
raise BridgeError(
code="WECHAT_NOT_LOGGED_IN",
message=f"当前登录态为 {login_state},无法发表朋友圈",
)
logger.info("moments/publish_image: 入队 send_queue (pending=%d)", send_queue.pending_count())
try:
t0 = time.perf_counter()
local_moment_id = await send_queue.enqueue(
lambda: xdotool.publish_moment_with_image(req.image_path, req.content)
)
logger.info(
"moments/publish_image: 队列执行完成 → local_moment_id=%s (%.0fms)",
local_moment_id, (time.perf_counter() - t0) * 1000,
)
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 (总耗时 %.0fms)",
req.image_path, len(req.content) if req.content else 0, local_moment_id,
(time.perf_counter() - t_total) * 1000,
)
return MomentPublishImageResponse(
success=True,
local_moment_id=local_moment_id,
error=None,
)