491 lines
17 KiB
Python
491 lines
17 KiB
Python
|
|
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_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()
|
|||
|
|
|
|||
|
|
# 校验请求体
|
|||
|
|
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: 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()
|
|||
|
|
|
|||
|
|
# 校验请求体
|
|||
|
|
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: 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:朋友圈"赞/评论"按钮坐标为估算值
|
|||
|
|
- 自动进入朋友圈页面,无需调用方预先进入
|
|||
|
|
"""
|
|||
|
|
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 < 1(HTTP 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 < 1(HTTP 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,
|
|||
|
|
)
|