WechatOnCloud/bridge/woc_bridge/routes/moments.py

491 lines
17 KiB
Python
Raw Normal View History

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()
# ---------------------------------------------------------------------------
# 路由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 不能为空")
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,
)