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 _state, _require_xdotool, _require_send_queue, _require_db_reader from woc_bridge.db import DbReader from woc_bridge.db.coordinator import with_db_retry from woc_bridge.models import ( SendTextRequest, SendFileRequest, SendResponse, RevokeMessageRequest, RevokeMessageResponse, ForwardMessageRequest, ForwardMessageResponse, BridgeError, ERROR_CODES, ) from woc_bridge.ui.flows.send_file import _validate_file_path logger = logging.getLogger("woc-bridge") router = APIRouter() # --------------------------------------------------------------------------- # 路由:POST /api/send/text # --------------------------------------------------------------------------- # display_name 解析缓存:wxid -> (display_name, timestamp) # 联系人备注/昵称不常变,5 分钟 TTL 足够;最大 1024 条防内存膨胀 _display_name_cache: dict[str, tuple[str, float]] = {} _DISPLAY_NAME_CACHE_TTL = 300.0 # 5 分钟 _DISPLAY_NAME_CACHE_MAX = 1024 _DISPLAY_NAME_WARMUP_LIMIT = 1000 # 启动时预热最近联系人数量 async def _resolve_display_name( db_reader: Optional[DbReader], to_wxid: str, display_name: Optional[str], ) -> str: """解析用于微信搜索框定位会话的显示名。 优先级: 1. 调用方显式传入的 display_name(strip 后非空才用) 2. 缓存命中(5 分钟 TTL) 3. 从 contact.db 读取该 wxid 的备注(remark) 4. 从 contact.db 读取该 wxid 的昵称(nickname) 5. 回退到 wxid 本身 DB 不可用时静默回退到 wxid,避免阻塞发送流程。 """ # 显式传入优先 if display_name and display_name.strip(): return display_name.strip() # 缓存命中 cached = _display_name_cache.get(to_wxid) if cached and time.monotonic() - cached[1] < _DISPLAY_NAME_CACHE_TTL: return cached[0] if db_reader is None: return to_wxid try: contact = await asyncio.to_thread(db_reader.get_contact_detail, to_wxid) except Exception: return to_wxid if contact: resolved = contact.get("remark") or contact.get("nickname") or to_wxid else: resolved = to_wxid # 写入缓存(简单 LRU:超限时删最旧条目) if len(_display_name_cache) >= _DISPLAY_NAME_CACHE_MAX: oldest = min(_display_name_cache, key=lambda k: _display_name_cache[k][1]) del _display_name_cache[oldest] _display_name_cache[to_wxid] = (resolved, time.monotonic()) return resolved async def warm_display_name_cache(db_reader: Optional[DbReader]) -> int: """启动时异步预热 display_name 缓存。 从高并发场景看,首批 30-100 个请求可能同时到达,若缓存为空会并发查询 contact.db。预热最近 1000 个联系人可显著降低启动初期的 DB 压力。 Args: db_reader: DbReader 实例 Returns: 预热写入的条目数 """ if db_reader is None: return 0 t0 = time.perf_counter() try: result = await asyncio.to_thread( db_reader.get_contacts, "", _DISPLAY_NAME_WARMUP_LIMIT ) except Exception as exc: logger.warning("warm_display_name_cache: 预热失败 %s", exc) return 0 contacts = result.get("contacts", []) warmed = 0 now = time.monotonic() for contact in contacts: wxid = contact.get("username") or contact.get("wxid") if not wxid: continue resolved = contact.get("remark") or contact.get("nickname") or wxid # 启动时批量写入,暂不触发 LRU 淘汰 _display_name_cache[wxid] = (resolved, now) warmed += 1 logger.info( "warm_display_name_cache: 预热 %d 条联系人 (%.0fms)", warmed, (time.perf_counter() - t0) * 1000, ) return warmed async def _get_latest_create_time_for_talker( db_reader: Optional[DbReader], to_wxid: str, ) -> tuple[int, int]: """返回目标 talker 当前最新消息的 (create_time, local_id),DB 不可用时返回 (0, 0)。""" if db_reader is None: return (0, 0) try: result = await asyncio.to_thread( db_reader.get_messages_by_session, to_wxid, cursor=0, limit=1, direction="before", is_sender=None, ) messages = result.get("messages", []) if messages: ct = int(messages[0].get("create_time", 0)) lid = int(messages[0].get("local_id", 0)) return (ct, lid) except Exception: pass return (0, 0) async def _verify_sent_to_talker( db_reader: Optional[DbReader], to_wxid: str, before_ct: int, before_local_id: int = 0, content: str = "", max_wait_sec: float = 3.0, ) -> bool: """轮询 DB,确认目标 talker 在 (before_ct, before_local_id) 之后出现了新消息。 用于发送后校验消息是否真的落到了目标会话。DB 不可用时静默跳过。 默认最多等待 3 秒(P1 收紧,原 10s 过长会拖垮 L4 HTTP 超时), 轮询间隔 0.2s。 防串号校验(P1 关键修复): - 复合游标 (before_ct, before_local_id) 避免匹配到基线消息本身 (原 cursor_local_id=0 默认值会让 SQL `local_id > 0` 恒真,误把 基线消息当新消息)。 - 内容匹配校验:get_messages_by_session 按 talker 分表查询,返回消息的 talker 字段恒等于 to_wxid,无法通过 talker 字段检测串号。改为校验 是否有消息内容包含发送的 content;无 content 参数时回退到仅检查 新消息存在。 Note: P3 阶段该校验已迁移到 SendTextFlow._wait_verify(基于 SSE 广播), 本函数仅 legacy 路径使用。 """ if db_reader is None: return True if before_ct <= 0: # 无基线时间戳(DB 不可用或会话无历史消息),无法准确校验 # 返回 True 避免假阳性(不阻塞发送),但记录 warning logger.warning( "send/text: DB 校验跳过,before_ct=%d 无法建立基线 (to_wxid=%s)", before_ct, to_wxid, ) return True deadline = time.monotonic() + max_wait_sec attempts = 0 while time.monotonic() < deadline: attempts += 1 try: result = await asyncio.to_thread( db_reader.get_messages_by_session, to_wxid, cursor=before_ct, cursor_local_id=before_local_id, limit=3, direction="after", is_sender=None, ) messages = result.get("messages", []) if messages: # 内容匹配校验(防串号) if content: for msg in messages: msg_content = msg.get("content", "") if content in msg_content: logger.info( "send/text: DB 校验成功,内容匹配 (attempt=%d, %d msgs)", attempts, len(messages), ) return True logger.warning( "send/text: DB 校验:%d 条新消息但内容不匹配 (attempt=%d)", len(messages), attempts, ) # 继续轮询,可能是 DB 写入延迟 else: # 无 content 参数,回退到仅检查新消息存在 logger.info( "send/text: DB 校验成功,目标 talker 发现 %d 条新消息 (attempt=%d)", len(messages), attempts, ) return True logger.debug("send/text: DB 校验第 %d 次轮询未找到新消息", attempts) except Exception as e: logger.debug("send/text: DB 校验第 %d 次轮询异常: %s", attempts, e) await asyncio.sleep(0.2) logger.warning( "send/text: DB 校验超时,%ds 内目标 talker 未出现新消息 (attempts=%d)", max_wait_sec, attempts, ) return False @router.post("/api/send/text", response_model=SendResponse) @with_db_retry async def send_text(req: SendTextRequest) -> SendResponse: """发送文本消息。""" # P3:根据 ui_backend 分支 ui_backend = _state.config.ui_backend if _state.config else "legacy" if ui_backend == "flow" and _state.orchestrator is not None: # 新架构:走 FlowOrchestrator return await _send_text_via_flow(req) # 旧架构:走 xdotool_driver(保持原有逻辑不变) return await _send_text_via_legacy(req) async def _send_text_via_legacy(req: SendTextRequest) -> SendResponse: """旧架构发送:走 xdotool_driver + send_queue + DB 校验。 流程: 1. 校验 to_wxid / content 非空 2. 解析 display_name:显式传入 > 备注 > 昵称 > wxid 3. 检测登录态,非 logged_in 直接拒绝(避免 UI 操作引发不可预期行为) 4. 记录目标会话当前最新 create_time 5. 经 send_queue 串行执行:activate_window → 鼠标点击左侧搜索框 → 输入 display_name → 点击第一个搜索结果 → 点击输入框 → 输入 content → 回车发送 6. 发送后轮询 DB 校验目标会话是否出现新消息,未出现则抛 SEND_FAILED 7. 返回本地生成的 local_send_id(不等待微信发送回执) Args: req: SendTextRequest(to_wxid + content + 可选 display_name) Returns: SendResponse:success=true / local_send_id(格式 local__<随机>) / placeholder=false(文本发送为真实发送,非占位) Raises: BridgeError(INVALID_PARAMS): to_wxid 或 content 为空(HTTP 400) BridgeError(WECHAT_NOT_LOGGED_IN): 当前未登录(HTTP 401) BridgeError(SEND_FAILED): xdotool/xclip 操作失败(HTTP 500) Notes: - 发送队列串行化避免搜索框/鼠标点击竞态 - local_send_id 由 bridge 本地生成,不对应微信真实 msg_id; 调用方应用它做幂等去重,**禁止用它到 /api/media/{msg_id} 查媒体** - 发送队列的发送间隔与限频由 .env 的 WOC_BRIDGE_SEND_DELAY_MS / WOC_BRIDGE_MAX_CALLS_PER_SEC 控制 - DB 校验失败时消息可能已经发出(可能到错误会话),调用方需人工排查 """ xdotool = _require_xdotool() send_queue = _require_send_queue() t_total = time.perf_counter() # 校验请求体 if not req.to_wxid: logger.warning("send/text: 参数校验失败 — to_wxid 为空") raise BridgeError(code="INVALID_PARAMS", message="to_wxid 不能为空") if not req.content: logger.warning("send/text: 参数校验失败 — content 为空 (to_wxid=%s)", req.to_wxid) raise BridgeError(code="INVALID_PARAMS", message="content 不能为空") logger.info( "send/text: 收到请求 to_wxid=%s content_len=%d display_name=%s", req.to_wxid, len(req.content), req.display_name or "(未提供)", ) # 检查登录态 t0 = time.perf_counter() login_state = await xdotool.detect_login_state() logger.info( "send/text: 登录态检测 → %s (%.0fms)", login_state, (time.perf_counter() - t0) * 1000, ) if login_state != "logged_in": logger.warning("send/text: 拒绝发送,登录态=%s", login_state) raise BridgeError( code="WECHAT_NOT_LOGGED_IN", message=f"当前登录态为 {login_state},无法发送消息", ) # 解析显示名(优先备注/昵称,不再直接用 wxid 搜索) t0 = time.perf_counter() display_name = await _resolve_display_name( _state.db_reader, req.to_wxid, req.display_name ) logger.info( "send/text: display_name 解析 → %s (%.0fms)", display_name, (time.perf_counter() - t0) * 1000, ) # 发送前记录目标会话最新消息时间,用于发送后校验 before_ct, before_local_id = await _get_latest_create_time_for_talker(_state.db_reader, req.to_wxid) logger.info("send/text: 目标会话当前最新 create_time=%d local_id=%d", before_ct, before_local_id) # 经发送队列串行执行 logger.info("send/text: 入队 send_queue (pending=%d)", send_queue.pending_count()) try: t0 = time.perf_counter() local_send_id = await send_queue.enqueue( lambda: xdotool.send_text(req.to_wxid, req.content, display_name) ) logger.info( "send/text: 队列执行完成 → local_send_id=%s (%.0fms)", local_send_id, (time.perf_counter() - t0) * 1000, ) except BridgeError as e: logger.warning( "send/text: 发送失败 BridgeError code=%s msg=%s (%.0fms)", e.code, e.message, (time.perf_counter() - t0) * 1000, ) raise except Exception as e: logger.error( "send/text: 发送失败 %s: %s (%.0fms)", type(e).__name__, e, (time.perf_counter() - t0) * 1000, ) raise BridgeError( code="SEND_FAILED", message=f"发送失败: {e}", ) # 发送后校验:目标会话是否出现了新消息 verified = await _verify_sent_to_talker(_state.db_reader, req.to_wxid, before_ct, before_local_id, req.content) if not verified: logger.error( "send/text: 发送后 DB 校验失败,消息可能未进入目标会话 to=%s before_ct=%d", req.to_wxid, before_ct, ) raise BridgeError( code="SEND_FAILED", message="发送后校验失败:消息未到达目标会话", ) logger.info( "send/text: ✓ to=%s display_name=%s content_len=%d → local_send_id=%s (总耗时 %.0fms)", req.to_wxid, display_name, len(req.content), local_send_id, (time.perf_counter() - t_total) * 1000, ) return SendResponse( success=True, local_send_id=local_send_id, placeholder=False, verified=verified, error=None, ) async def _send_text_via_flow(req: SendTextRequest) -> SendResponse: """新架构发送:经 FlowOrchestrator 编排(幂等/会话缓存/SSE 校验)。""" orchestrator = _state.orchestrator if orchestrator is None: raise BridgeError( code="BRIDGE_INTERNAL_ERROR", message="orchestrator not initialized" ) t_total = time.perf_counter() # 校验参数 if not req.to_wxid: logger.warning("send/text: 参数校验失败 — to_wxid 为空 (flow 路径)") raise BridgeError(code="INVALID_PARAMS", message="to_wxid 不能为空") if not req.content: logger.warning("send/text: 参数校验失败 — content 为空 (to_wxid=%s, flow 路径)", req.to_wxid) raise BridgeError(code="INVALID_PARAMS", message="content 不能为空") logger.info( "send/text: 收到请求 (flow) to_wxid=%s content_len=%d display_name=%s client_request_id=%s", req.to_wxid, len(req.content), req.display_name or "(未提供)", req.client_request_id or "(none)", ) # 检查登录态(与 legacy 路径一致) t0 = time.perf_counter() xdotool = _require_xdotool() login_state = await xdotool.detect_login_state() logger.info( "send/text: 登录态检测 (flow) → %s (%.0fms)", login_state, (time.perf_counter() - t0) * 1000, ) if login_state != "logged_in": logger.warning("send/text: 拒绝发送(flow 路径),登录态=%s", login_state) raise BridgeError( code="WECHAT_NOT_LOGGED_IN", message=f"当前登录态为 {login_state},无法发送消息", ) # 解析 display_name(复用现有 _resolve_display_name) t0 = time.perf_counter() display_name = await _resolve_display_name( _state.db_reader, req.to_wxid, req.display_name ) logger.info( "send/text: display_name 解析 (flow) → %s (%.0fms)", display_name, (time.perf_counter() - t0) * 1000, ) # 调用 FlowOrchestrator result = await orchestrator.send_text( to_wxid=req.to_wxid, content=req.content, display_name=display_name, client_request_id=req.client_request_id, ) # FlowResult 转 SendResponse if result.success: logger.info( "send/text: ✓ (flow) to=%s display_name=%s local_send_id=%s verified=%s skipped=%s (总耗时 %.0fms)", req.to_wxid, display_name, result.local_id, result.verified, result.skipped, (time.perf_counter() - t_total) * 1000, ) return SendResponse( success=True, local_send_id=result.local_id, placeholder=False, verified=result.verified, skipped=result.skipped, ) # 失败:抛 BridgeError(保持与 legacy 一致的错误语义) error_code = result.error_code or "SEND_FAILED" logger.warning( "send/text: ✗ (flow) to=%s failed code=%s error=%s (总耗时 %.0fms)", req.to_wxid, error_code, result.error, (time.perf_counter() - t_total) * 1000, ) # 已知错误码直接透传(含可重试错误码 RATE_LIMITED/SEND_TIMEOUT 等), # ERROR_CODES 表已补全所有 Flow 错误码的 HTTP 映射 if error_code in ERROR_CODES: raise BridgeError( code=error_code, message=result.error or error_code.lower(), details=result.details, # 传递 retry_after 等 ) # 未知错误码回落到 SEND_FAILED raise BridgeError(code="SEND_FAILED", message=result.error or "send failed") # --------------------------------------------------------------------------- # 路由:POST /api/send/image # --------------------------------------------------------------------------- @router.post("/api/send/image", response_model=SendResponse) async def send_image(req: SendFileRequest) -> SendResponse: """发送图片消息。 flow 架构下走真实文件发送(系统文件选择器路径输入 + DB 校验,placeholder=False), legacy 架构下为占位实现(仅发"[图片] 文件名"提示文本,placeholder=True)。 Args: req: SendFileRequest(to_wxid + file_path + 可选 display_name / client_request_id) Returns: SendResponse:success / placeholder(flow=False, legacy=True) / verified Raises: 同 _send_file:INVALID_PARAMS / WECHAT_NOT_LOGGED_IN / SEND_FAILED """ return await _send_file(req, is_image=True) # --------------------------------------------------------------------------- # 路由:POST /api/send/file # --------------------------------------------------------------------------- @router.post("/api/send/file", response_model=SendResponse) @with_db_retry async def send_file(req: SendFileRequest) -> SendResponse: """发送文件消息。 flow 架构下走真实文件发送(系统文件选择器路径输入 + DB 校验,placeholder=False), legacy 架构下为占位实现(仅发"[文件] 文件名"提示文本,placeholder=True)。 Args: req: SendFileRequest(to_wxid + file_path + 可选 display_name / client_request_id) Returns: SendResponse:success / placeholder(flow=False, legacy=True) / verified Raises: 同 _send_file:INVALID_PARAMS / WECHAT_NOT_LOGGED_IN / SEND_FAILED """ return await _send_file(req, is_image=False) async def _send_file(req: SendFileRequest, is_image: bool) -> SendResponse: """发送文件/图片(自动选择 flow / legacy 路径)。 ui_backend == "flow" 且 orchestrator 可用时走真实文件发送(placeholder=False, 经 SendFileFlow 系统文件选择器路径输入 + DB 校验), 否则回退 legacy 占位实现(placeholder=True,仅发提示文本)。 """ ui_backend = _state.config.ui_backend if _state.config else "legacy" if ui_backend == "flow" and _state.orchestrator is not None: return await _send_file_via_flow(req, is_image) return await _send_file_via_legacy(req, is_image) async def _send_file_via_flow(req: SendFileRequest, is_image: bool) -> SendResponse: """新架构文件发送:经 FlowOrchestrator 编排(真实发送,非 placeholder)。 流程: 1. 校验 to_wxid / file_path 非空 2. 路径白名单校验(提前返回 400,比入队后失败更友好) 3. 文件存在性校验(提前返回 400) 4. 登录态检测,非 logged_in 直接拒绝 5. 调 orchestrator.send_file(幂等 + 熔断 + 入队 + DB 校验) 6. 响应 placeholder=False + verified Args: req: SendFileRequest(to_wxid + file_path + 可选 display_name / client_request_id) is_image: True=图片,False=普通文件(决定 file_type,仅用于日志) Returns: SendResponse:success / placeholder=False / verified / skipped Raises: BridgeError(INVALID_PARAMS): 参数为空 / 路径不在白名单 / 文件不存在(HTTP 400) BridgeError(WECHAT_NOT_LOGGED_IN): 当前未登录(HTTP 401) BridgeError(SEND_FAILED / SEND_TIMEOUT / ...): 发送失败(HTTP 500/503) Notes: - file_path 必须是容器内绝对路径且在白名单目录内 (/config/Desktop/ / /config/woc-uploads/ / /tmp/woc-files/) - 真实文件传输经系统文件选择器(xdotool type 路径 + Return 提交) - local_send_id 为空(文件发送不返回 local_send_id) """ orchestrator = _state.orchestrator if orchestrator is None: raise BridgeError( code="BRIDGE_INTERNAL_ERROR", message="orchestrator not initialized" ) # 校验参数 if not req.to_wxid: raise BridgeError(code="INVALID_PARAMS", message="to_wxid 不能为空") if not req.file_path: raise BridgeError(code="INVALID_PARAMS", message="file_path 不能为空") # 路径白名单校验(SendFileFlow 内部也会校验,路由层提前校验返回 400 更友好) _validate_file_path(req.file_path) # 文件存在性校验(提前返回 400,避免入队后才发现文件不存在) if not os.path.isfile(req.file_path): raise BridgeError( code="INVALID_PARAMS", message=f"文件不存在: {req.file_path}" ) # 检查登录态 xdotool = _require_xdotool() login_state = await xdotool.detect_login_state() if login_state != "logged_in": logger.warning( "send/file: 拒绝发送(flow 路径),登录态=%s", login_state ) raise BridgeError( code="WECHAT_NOT_LOGGED_IN", message=f"当前登录态为 {login_state},无法发送消息", ) file_type = "image" if is_image else "file" kind = "image" if is_image else "file" logger.info( "send/%s: 收到请求 to_wxid=%s file=%s (flow 路径)", kind, req.to_wxid, req.file_path, ) # P0 修复:解析 display_name 供 SendFileFlow 搜索会话使用。 # 部分联系人无法通过 wxid 搜索定位(微信搜索框对 wxid 不总是命中), # 必须用备注/昵称搜索。_resolve_display_name 会查 contact.db 并缓存。 db_reader = _state.db_reader if _state else None display_name = await _resolve_display_name( db_reader, req.to_wxid, getattr(req, "display_name", None) ) # 调用 FlowOrchestrator(幂等 + 熔断 + 入队 + DB 校验) result = await orchestrator.send_file( to_wxid=req.to_wxid, file_path=req.file_path, file_type=file_type, client_request_id=req.client_request_id, display_name=display_name, ) if result["success"]: logger.info( "send/%s: to=%s file=%s → 成功 verified=%s (placeholder=False)", kind, req.to_wxid, req.file_path, result["verified"], ) return SendResponse( success=True, local_send_id="", # 文件发送无 local_send_id placeholder=False, verified=result["verified"], skipped=result.get("skipped", False), error=None, ) # 失败:抛 BridgeError(保持与 _send_text_via_flow 一致的错误语义) error_code = result.get("error_code") or "SEND_FAILED" if error_code in ERROR_CODES: raise BridgeError( code=error_code, message=result.get("error") or error_code.lower(), ) raise BridgeError( code="SEND_FAILED", message=result.get("error") or "send file failed", ) async def _send_file_via_legacy(req: SendFileRequest, is_image: bool) -> SendResponse: """send_image / send_file 共用实现(legacy 占位路径)。 流程: 1. 校验 to_wxid / file_path 非空,文件存在 2. 解析 display_name:显式传入 > 备注 > 昵称 > wxid 3. 检测登录态,非 logged_in 直接拒绝 4. 经 send_queue 串行执行:activate → Ctrl+F 搜索会话(按 display_name) → 选中并进入 → xclip 粘贴"[图片/文件] 文件名" → 回车发送 Args: req: SendFileRequest(to_wxid + file_path + 可选 display_name) is_image: True=图片,False=普通文件(仅影响提示文本前缀) Returns: SendResponse:success=true / local_send_id / placeholder=true (legacy 路径为占位实现,仅发提示文本;flow 路径返回 placeholder=false) Raises: BridgeError(INVALID_PARAMS): to_wxid/file_path 为空或文件不存在(HTTP 400) BridgeError(WECHAT_NOT_LOGGED_IN): 当前未登录(HTTP 401) BridgeError(SEND_FAILED): xdotool/xclip 操作失败(HTTP 500) Notes: - file_path 必须是容器内绝对路径(不是宿主机路径) - legacy 路径不传输真实文件内容,仅发提示文本 - local_send_id 不对应微信原生 msg_id,禁止用于 /api/media/{msg_id} """ xdotool = _require_xdotool() send_queue = _require_send_queue() if not req.to_wxid: raise BridgeError(code="INVALID_PARAMS", message="to_wxid 不能为空") if not req.file_path: raise BridgeError(code="INVALID_PARAMS", message="file_path 不能为空") if not os.path.isfile(req.file_path) or not os.access(req.file_path, os.R_OK): raise BridgeError( code="INVALID_PARAMS", message=f"文件不存在或不可读: {req.file_path}", ) # 检查登录态 login_state = await xdotool.detect_login_state() if login_state != "logged_in": raise BridgeError( code="WECHAT_NOT_LOGGED_IN", message=f"当前登录态为 {login_state},无法发送消息", ) # 解析显示名(优先备注/昵称,不再直接用 wxid 搜索) display_name = await _resolve_display_name( _state.db_reader, req.to_wxid, req.display_name ) try: local_send_id = await send_queue.enqueue( lambda: xdotool.send_file( req.to_wxid, req.file_path, is_image=is_image, display_name=display_name ) ) except BridgeError: raise except Exception as e: raise BridgeError( code="SEND_FAILED", message=f"发送失败: {e}", ) kind = "image" if is_image else "file" logger.info( "send/%s: to=%s display_name=%s file=%s → 成功 local_send_id=%s (placeholder)", kind, req.to_wxid, display_name, req.file_path, local_send_id, ) # W-02 完成前,图片/文件发送为占位实现(仅发提示文本) return SendResponse(success=True, local_send_id=local_send_id, placeholder=True, error=None) # --------------------------------------------------------------------------- # 路由:POST /api/messages/revoke (experimental) # --------------------------------------------------------------------------- @router.post("/api/messages/revoke", response_model=RevokeMessageResponse) async def revoke_message(req: RevokeMessageRequest) -> RevokeMessageResponse: """撤回指定会话中的最近消息(experimental)。 UI 自动化路径:定位会话 → 长按最近消息 → 选择撤回 → 确认。 微信撤回时限:发送后 2 分钟内。 Args: req: RevokeMessageRequest(talker + create_time) Returns: RevokeMessageResponse Raises: BridgeError(INVALID_PARAMS): talker 为空(HTTP 400) BridgeError(WECHAT_NOT_LOGGED_IN): 未登录(HTTP 401) BridgeError(REVOKE_WINDOW_EXPIRED): 超过 2 分钟撤回时限(HTTP 409) BridgeError(WINDOW_NOT_FOUND): 找不到微信窗口(HTTP 503) BridgeError(SEND_FAILED): UI 操作失败或超时(HTTP 500) Notes: - experimental:消息气泡坐标为估算值,需在目标分辨率实测调优 - 无法校验撤回是否真正生效(无 UI 元素检测) """ if not req.talker: raise BridgeError(code="INVALID_PARAMS", message="talker 不能为空") if req.create_time <= 0: raise BridgeError( code="INVALID_PARAMS", message=f"create_time 必须 > 0,收到 {req.create_time}", ) # 微信撤回时限:发送后 2 分钟内(Task 11 收紧到 110s 留 10s UI 操作余量) _REVOKE_WINDOW_SEC = 110 now_ts = int(time.time()) if now_ts - req.create_time > _REVOKE_WINDOW_SEC: raise BridgeError( code="REVOKE_WINDOW_EXPIRED", message=f"消息已超过 2 分钟撤回时限(发送于 {req.create_time},当前 {now_ts}," f"已为 UI 操作预留 10s 余量,阈值收紧到 {_REVOKE_WINDOW_SEC}s)", ) 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},无法撤回消息", ) # 解析 display_name(优先 req.display_name,其次 DB 查 remark/nickname,回退 wxid) display_name = await _resolve_display_name( db_reader, req.talker, req.display_name ) try: await send_queue.enqueue( lambda: xdotool.revoke_message( req.talker, req.create_time, display_name ) ) except BridgeError: raise except Exception as e: raise BridgeError( code="SEND_FAILED", message=f"撤回失败: {e}", ) logger.info( "messages/revoke: talker=%s create_time=%d → UI 操作完成", req.talker, req.create_time, ) # Task 11: post_verify 校验撤回是否真正生效(DB 中出现撤回系统消息) verified: bool | None = None try: verified = await xdotool._message.post_verify_revoke_message( db_reader, req.talker, req.create_time ) except Exception as exc: logger.warning( "messages/revoke: post_verify 异常 (talker=%s): %s", req.talker, exc, ) verified = False return RevokeMessageResponse(success=True, error=None, verified=verified) # --------------------------------------------------------------------------- # 路由:POST /api/messages/forward (experimental) # --------------------------------------------------------------------------- @router.post("/api/messages/forward", response_model=ForwardMessageResponse) async def forward_message(req: ForwardMessageRequest) -> ForwardMessageResponse: """转发消息到指定会话(experimental)。 UI 自动化路径:定位源会话 → 长按消息 → 选择转发 → 搜索目标 → 确认。 Args: req: ForwardMessageRequest Returns: ForwardMessageResponse Raises: BridgeError(INVALID_PARAMS): 参数为空(HTTP 400) BridgeError(WECHAT_NOT_LOGGED_IN): 未登录(HTTP 401) BridgeError(WINDOW_NOT_FOUND / SEND_FAILED) Notes: - experimental:坐标均为估算值 - 转发目标选择界面是独立窗口,坐标可能与主窗口不同 """ if not req.talker: raise BridgeError(code="INVALID_PARAMS", message="talker 不能为空") if not req.target_display_name: raise BridgeError(code="INVALID_PARAMS", message="target_display_name 不能为空") 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},无法转发消息", ) # 解析源会话 display_name(优先 req.source_display_name,其次 DB 查,回退 wxid) source_display_name = await _resolve_display_name( db_reader, req.talker, req.source_display_name ) # P0 修复:UI 操作前捕获目标会话消息基线 (create_time, local_id), # 供 post_verify 用 direction="after" 只查新消息,避免历史 type=49 误报。 baseline_create_time = 0 baseline_local_id = 0 try: baseline_create_time, baseline_local_id = ( await xdotool._message.capture_forward_baseline( db_reader, req.target_display_name ) ) except Exception as exc: logger.warning( "messages/forward: 捕获基线异常 (target=%s): %s", req.target_display_name, exc, ) try: await send_queue.enqueue( lambda: xdotool.forward_message( req.talker, req.target_display_name, source_display_name ) ) except BridgeError: raise except Exception as e: raise BridgeError( code="SEND_FAILED", message=f"转发失败: {e}", ) logger.info( "messages/forward: talker=%s target=%s → UI 操作完成", req.talker, req.target_display_name, ) # Task 11: post_verify 校验转发是否生效(目标会话出现 type=49 消息) verified: bool | None = None try: verified = await xdotool._message.post_verify_forward_message( db_reader, req.target_display_name, baseline_create_time=baseline_create_time, baseline_local_id=baseline_local_id, ) except Exception as exc: logger.warning( "messages/forward: post_verify 异常 (target=%s): %s", req.target_display_name, exc, ) verified = False return ForwardMessageResponse(success=True, error=None, verified=verified)