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_column:DB 存在但结构不匹配 - db_encrypted:DB 加密且无法解密 - 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: MomentPublishRequest(content 朋友圈文字内容) Returns: MomentPublishResponse:success=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: MomentShareArticleRequest(public_account + article_index + 可选 comment) Returns: MomentShareArticleResponse:success=true / local_moment_id / placeholder=false(真实执行 UI 分享操作) Raises: BridgeError(INVALID_PARAMS): public_account 为空或 article_index < 1(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 默认布局估算,需在目标分辨率实测调优 - 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: MomentLikeRequest(moment_index,1=最新) Returns: MomentLikeResponse Raises: BridgeError(INVALID_PARAMS): moment_index < 1(HTTP 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 < 1(HTTP 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 校验评论是否写入 DB(MomentsComment 表存在时) # 或截图 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 < 1(HTTP 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, )