本次提交包含多项核心更新: 1. 新增微信4.0浅色主题UI模板资源与说明文档,补充了联系人图标、新的朋友入口等四个UI元素的图像匹配模板 2. 重构UI驱动层,将原2375行的单文件拆分为5个职责清晰的子驱动类,提升代码可维护性 3. 新增批量群发消息与聊天记录导出的完整数据模型、路由与后台协程,支持幂等校验与风控配置 4. 为所有业务接口新增post_verify校验逻辑,补充了verified字段返回操作结果 5. 优化媒体文件解密逻辑,修复了导出功能中的数据库错误处理与分辨率硬编码问题 6. 更新版本配置,将媒体发送功能标记为已落地,调整了能力列表与配置项
565 lines
20 KiB
Python
565 lines
20 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
import os
|
||
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()
|
||
|
||
# 校验请求体
|
||
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},无法点赞",
|
||
)
|
||
|
||
# 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
|
||
try:
|
||
verified = await send_queue.enqueue(_like_and_verify)
|
||
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, 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:坐标为估算值
|
||
- 自动进入朋友圈页面,无需调用方预先进入
|
||
"""
|
||
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()
|
||
db_reader = _require_db_reader()
|
||
|
||
login_state = await xdotool.detect_login_state()
|
||
if login_state != "logged_in":
|
||
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
|
||
try:
|
||
verified = await send_queue.enqueue(_comment_and_verify)
|
||
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, 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:长按位置与菜单项坐标为估算值
|
||
- 自动进入朋友圈页面,避免在聊天界面误删消息
|
||
- 只能删除自己发的朋友圈;长按别人的朋友圈不会出现"删除"项
|
||
"""
|
||
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()
|
||
db_reader = _require_db_reader()
|
||
|
||
login_state = await xdotool.detect_login_state()
|
||
if login_state != "logged_in":
|
||
raise BridgeError(
|
||
code="WECHAT_NOT_LOGGED_IN",
|
||
message=f"当前登录态为 {login_state},无法删除朋友圈",
|
||
)
|
||
|
||
# P0 修复:删除前捕获朋友圈数量基线,供 post_verify 比对。
|
||
# 原实现 post_verify 只查 status=ok 就返回 True,完全未校验删除生效。
|
||
baseline_count: Optional[int] = None
|
||
try:
|
||
result = await asyncio.to_thread(db_reader.get_moments_timeline, 0, 50)
|
||
if result.get("status") == "ok":
|
||
baseline_count = len(result.get("moments", []))
|
||
except Exception:
|
||
pass
|
||
|
||
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)
|
||
|
||
# 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 必须是容器内可访问的绝对路径
|
||
"""
|
||
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,
|
||
)
|