"""文件发送 Flow:基于文件选择器路径输入实现真实文件发送。 复用 publish_moment_with_image 已验证机制(系统文件选择器接受 xdotool 输入)。 Flow 流程:定位会话 → 点击"+"→ 选"发送文件"→ xdotool type 路径 → Return post_verify:sleep 2s + 轮询 DB 30s,查 type in (3,43,49) 且 content 含文件名。 路径白名单:/config/Desktop/ /config/woc-uploads/ /tmp/woc-files/ 大文件超时自适应:30 + size_mb * 1.5(上限 180s)。 """ from __future__ import annotations import asyncio import logging import os import time from typing import Optional from woc_bridge.models import BridgeError logger = logging.getLogger("woc-bridge") # 微信窗口标题(与 send_text.py 对齐) _WECHAT_WINDOW_TITLE = "微信" # 路径白名单(防路径遍历):仅允许容器内受控目录 _FILE_PATH_WHITELIST = ( "/config/Desktop/", "/config/woc-uploads/", "/tmp/woc-files/", ) # 大文件超时自适应参数 _LARGE_FILE_THRESHOLD_MB = 10.0 _BASE_TIMEOUT_SEC = 30.0 _PER_MB_TIMEOUT_SEC = 1.5 _MAX_TIMEOUT_SEC = 180.0 # DB 校验参数 _DB_VERIFY_TIMEOUT_SEC = 30.0 # DB 校验总超时 _DB_VERIFY_INTERVAL_SEC = 2.0 # 轮询间隔 _DB_VERIFY_WAL_FLUSH_SEC = 2.0 # 等待 WAL 刷盘 # "+" 菜单导航:点击 "+" 后按 Down N 次选中"发送文件",再 Return 确认。 # 默认值基于微信 4.x Linux 菜单布局(发送图片/发送文件/...),实际可能需调优。 _SEND_FILE_MENU_DOWN_COUNT = 1 # 文件类型常量(微信 msg.type):3=图片 43=视频 49=文件/分享 _FILE_MSG_TYPES = (3, 43, 49) def _validate_file_path(file_path: str) -> None: """校验文件路径白名单 + 拒绝路径遍历。 双重校验: 1. 原始路径段不含 ".."(防输入层绕过) 2. os.path.realpath 解析后的真实路径以白名单前缀开头(防 symlink + .. 绕过) Raises: BridgeError(INVALID_PARAMS): 路径含 .. 或不在白名单目录 """ # 段级 ".." 检查(兼容 / 与 \ 分隔符) parts = file_path.replace("\\", "/").split("/") if ".." in parts: raise BridgeError( "INVALID_PARAMS", f"文件路径含 '..' 段,拒绝: {file_path}", http_status=400, ) # realpath 解析 symlink + ..,得到真实绝对路径 real_path = os.path.realpath(file_path) for prefix in _FILE_PATH_WHITELIST: if real_path.startswith(prefix): return raise BridgeError( "INVALID_PARAMS", f"文件路径不在白名单目录内: {file_path}(允许: {list(_FILE_PATH_WHITELIST)})", http_status=400, ) def _compute_timeout(file_size_bytes: int) -> float: """大文件超时自适应:30 + size_mb * 1.5(上限 180s)。 小文件(<=10MB)固定 30s;大文件按大小线性增加,上限 180s。 """ size_mb = file_size_bytes / (1024 * 1024) if size_mb <= _LARGE_FILE_THRESHOLD_MB: return _BASE_TIMEOUT_SEC timeout = _BASE_TIMEOUT_SEC + size_mb * _PER_MB_TIMEOUT_SEC return min(timeout, _MAX_TIMEOUT_SEC) class SendFileFlow: """文件发送 Flow。 依赖注入: - actions: L3 Actions 实例(用于点击元素、窗口激活、坐标点击) - xdotool: XdotoolDriver 实例(用于 xdotool type 路径,因系统文件选择器 需逐字符输入且 actions.backend.type_text 不支持 --delay 参数) - db_reader: DbReader 实例(用于 post_verify) """ def __init__(self, actions, xdotool, db_reader) -> None: self._actions = actions self._xdotool = xdotool self._db_reader = db_reader async def execute( self, to_wxid: str, file_path: str, file_type: str = "file", timeout_sec: Optional[float] = None, display_name: Optional[str] = None, ) -> dict: """执行文件发送流程。 Args: to_wxid: 目标会话 wxid file_path: 文件绝对路径(必须在白名单目录内) file_type: "image" / "file" / "video"(仅用于日志,不影响流程) timeout_sec: 自定义超时(None 时按文件大小自适应) display_name: 用于微信搜索框定位会话的显示名(备注/昵称), None 时回退到 to_wxid(部分联系人会话可能定位失败) Returns: {"success": bool, "verified": bool, "error": Optional[str]} Raises: BridgeError: 路径校验失败 / 文件不存在 / 窗口未找到 / 发送超时 """ # 1. 路径白名单 + 遍历校验 _validate_file_path(file_path) # 2. 文件存在性 + 大小检查 if not os.path.isfile(file_path): raise BridgeError( "INVALID_PARAMS", f"文件不存在: {file_path}", http_status=400, ) file_size = os.path.getsize(file_path) if timeout_sec is None: timeout_sec = _compute_timeout(file_size) file_name = os.path.basename(file_path) # 搜索查询词:优先使用 display_name,回退到 to_wxid search_query = display_name.strip() if display_name and display_name.strip() else to_wxid logger.info( "[flow:send_file] START to_wxid=%s file=%s size=%d type=%s timeout=%.1fs display_name=%s", to_wxid, file_name, file_size, file_type, timeout_sec, display_name or "(none)", ) # 3. 获取基线时间戳(post_verify 游标,仅查发送之后的新消息) # 复合游标 (create_time, local_id) 避免基线消息本身被重复匹配 baseline_time, baseline_local_id = await self._get_baseline_time(to_wxid) logger.info( "[flow:send_file] baseline ct=%d local_id=%d", baseline_time, baseline_local_id, ) try: async with asyncio.timeout(timeout_sec): # 4. 激活窗口 + 获取几何 + Esc 清场 t0 = time.perf_counter() await self._actions.backend.activate_window(_WECHAT_WINDOW_TITLE) win_geom = await self._actions.backend.get_window_geometry( _WECHAT_WINDOW_TITLE ) await self._actions.backend.key_press("Escape") await asyncio.sleep(0.3) logger.info( "[flow:send_file] step=activate done geom=%dx%d@(%d,%d) (%.0fms)", win_geom.width, win_geom.height, win_geom.x, win_geom.y, (time.perf_counter() - t0) * 1000, ) # 5. 定位会话(搜索框 → 输入 display_name → 点击搜索结果第一项) t0 = time.perf_counter() logger.info( "[flow:send_file] step=open_session start query=%r", search_query, ) await self._open_session(search_query, win_geom) logger.info( "[flow:send_file] step=open_session done (%.0fms)", (time.perf_counter() - t0) * 1000, ) # 6. 点击 "+" → 选 "发送文件" 菜单项 t0 = time.perf_counter() logger.info("[flow:send_file] step=click_plus_and_send_file start") await self._click_plus_and_send_file(win_geom) logger.info( "[flow:send_file] step=click_plus_and_send_file done (%.0fms)", (time.perf_counter() - t0) * 1000, ) # 7. 系统文件选择器:xdotool type 路径 → Return 提交 t0 = time.perf_counter() logger.info( "[flow:send_file] step=input_path_in_chooser start path=%s", file_path, ) await self._input_path_in_chooser(file_path) logger.info( "[flow:send_file] step=input_path_in_chooser done (%.0fms)", (time.perf_counter() - t0) * 1000, ) # 8. 等待文件加载到输入区 logger.info("[flow:send_file] step=wait_file_load 1.0s") await asyncio.sleep(1.0) except asyncio.TimeoutError as exc: logger.warning( "[flow:send_file] TIMEOUT file=%s timeout=%.1fs", file_name, timeout_sec, ) # 兜底清场:Esc 关闭可能残留的弹窗/选择器 try: await self._actions.backend.key_press("Escape", repeat=2) except Exception: pass raise BridgeError( "SEND_TIMEOUT", f"文件发送超时: {file_name}", http_status=503, ) from exc # 9. post_verify:DB 校验文件消息是否落库 t0 = time.perf_counter() verified = await self._post_verify( to_wxid, file_name, baseline_time, baseline_local_id ) logger.info( "[flow:send_file] DONE file=%s verified=%s (%.0fms)", file_name, verified, (time.perf_counter() - t0) * 1000, ) return {"success": True, "verified": verified, "error": None} # ------------------------------------------------------------------ # 会话定位(参考 send_text.py 的 _open_session 范式重新实现, # 避免破坏 SendTextFlow 的封装) # ------------------------------------------------------------------ async def _open_session(self, search_query: str, win_geom) -> None: """定位会话:点击搜索框 → 输入查询词 → 点击搜索结果第一项。 Args: search_query: 搜索查询词(display_name 优先,回退到 to_wxid) win_geom: 窗口几何信息 """ logger.info("[flow:send_file] _open_session: click search_box") await self._actions.click_element("search_box", win_geom) await asyncio.sleep(0.3) # 清空可能残留的查询 logger.info("[flow:send_file] _open_session: clear + type query") await self._actions.backend.key_press("BackSpace", repeat=30) await self._actions.backend.type_text(search_query) # 等待搜索结果出现 await asyncio.sleep(0.5) # 点击搜索结果第一项打开会话 logger.info("[flow:send_file] _open_session: click search_result_first") await self._actions.click_element("search_result_first", win_geom) await asyncio.sleep(0.5) # ------------------------------------------------------------------ # 点击 "+" + 选 "发送文件" # ------------------------------------------------------------------ async def _click_plus_and_send_file(self, win_geom) -> None: """点击 "+" 按钮并选择 "发送文件" 菜单项。 plus_button / send_file_menu 未在 locator 注册表注册,用坐标点击 "+" (位于输入框右侧、靠近窗口右下角),菜单弹出后用键盘 Down + Return 选择。 """ # "+" 按钮:靠近窗口右下角(输入框右侧) plus_x = win_geom.x + win_geom.width - 30 plus_y = win_geom.y + win_geom.height - 30 logger.info( "[flow:send_file] _click_plus: click (+) at (%d,%d) geom=%dx%d@(%d,%d)", plus_x, plus_y, win_geom.width, win_geom.height, win_geom.x, win_geom.y, ) await self._actions.backend.click(plus_x, plus_y) await asyncio.sleep(0.6) # 等待菜单弹出 # 键盘导航选 "发送文件"(参考 publish_moment_with_image 的 Down + Return 范式) logger.info( "[flow:send_file] _click_plus: navigate menu Down×%d + Return", _SEND_FILE_MENU_DOWN_COUNT, ) for _ in range(_SEND_FILE_MENU_DOWN_COUNT): await self._actions.backend.key_press("Down") await asyncio.sleep(0.15) await self._actions.backend.key_press("Return") await asyncio.sleep(0.8) # 等待系统文件选择器打开 # ------------------------------------------------------------------ # 文件选择器路径输入 # ------------------------------------------------------------------ async def _input_path_in_chooser(self, file_path: str) -> None: """在系统文件选择器对话框输入路径并回车提交。 关键点:微信自绘 UI 不接受 Ctrl+V,但系统 GTK/Qt 文件选择器对话框 接受 xdotool type 逐字符输入。用 --delay 50 保证每字符被对话框处理。 """ logger.info( "[flow:send_file] _input_path: xdotool type path len=%d delay=50ms", len(file_path), ) rc, _, stderr = await self._xdotool._run( ["xdotool", "type", "--delay", "50", file_path] ) if rc != 0: err = stderr.decode(errors="ignore").strip() if stderr else "unknown" logger.warning( "[flow:send_file] _input_path: xdotool type FAILED rc=%d err=%s", rc, err, ) raise BridgeError( "SEND_FAILED", f"xdotool type 路径失败 (code={rc}): {err}", ) await asyncio.sleep(0.3) # 等待输入完成 # Return 提交选择 logger.info("[flow:send_file] _input_path: Return to submit") await self._xdotool._key("Return") # ------------------------------------------------------------------ # DB 校验 # ------------------------------------------------------------------ async def _get_baseline_time(self, to_wxid: str) -> tuple[int, int]: """获取目标会话当前最新消息的 (create_time, local_id) 作为发送前基线。 post_verify 仅查基线之后的新消息,避免历史消息假阳性。 复合游标 (create_time, local_id) 作为 tie-breaker,避免基线消息本身 在 after 模式下被重复匹配(create_time = baseline AND local_id > 0)。 Returns: (create_time, local_id) 元组。DB 不可用或会话无历史时返回 (0, 0), _post_verify 检测到 (0, 0) 会跳过 DB 校验,避免查询全部历史消息 造成假阳性(历史同名文件消息被误匹配)。 """ if self._db_reader is None: return (0, 0) try: result = await asyncio.to_thread( self._db_reader.get_messages_by_session, to_wxid, cursor=0, limit=1, direction="before", is_sender=None, ) messages = result.get("messages", []) if messages: return ( int(messages[0].get("create_time", 0)), int(messages[0].get("local_id", 0)), ) except Exception as exc: logger.debug( "[flow:send_file] get_baseline_time exception: %s", exc ) return (0, 0) async def _post_verify( self, to_wxid: str, file_name: str, baseline_time: int, baseline_local_id: int = 0, ) -> Optional[bool]: """DB 校验:查 type in (3,43,49) 且 content 含文件名。 Args: to_wxid: 目标会话 wxid file_name: 文件名(用于 content 匹配) baseline_time: 基线时间戳(仅查此时间之后的消息) baseline_local_id: 基线消息 local_id(tie-breaker,避免基线本身被重匹配) Returns: True 表示校验通过;False 表示校验超时未匹配(真正的校验失败); None 表示无法校验(DB 不可用或无基线),orchestrator 不应计入 熔断失败计数,避免 DB 不可用时熔断器雪崩阻塞全部发送 """ if self._db_reader is None: logger.warning("[flow:send_file] no db_reader, skip post_verify") return None # P0 修复:baseline_time=0 表示 DB 不可用或会话无历史。 # 此时若仍查 DB(cursor=0 + direction=after)会扫描全部历史消息, # 同名文件的历史消息会被误匹配(假阳性)。返回 None 表示"无法校验", # orchestrator 不计熔断失败,避免 DB 不可用时熔断器雪崩。 if baseline_time == 0: logger.warning( "[flow:send_file] baseline_time=0(DB 不可用或无历史)," "跳过 DB 校验避免假阳性(返回 None,不计熔断)" ) return None # 等待 WAL 刷盘(微信写入 + SQLite checkpoint) await asyncio.sleep(_DB_VERIFY_WAL_FLUSH_SEC) deadline = time.monotonic() + _DB_VERIFY_TIMEOUT_SEC attempts = 0 while time.monotonic() < deadline: attempts += 1 try: result = await asyncio.to_thread( self._db_reader.get_messages_by_session, to_wxid, cursor=baseline_time, cursor_local_id=baseline_local_id, limit=10, direction="after", is_sender=None, ) messages = result.get("messages", []) for msg in messages: msg_type = int(msg.get("type", 0)) msg_content = msg.get("content", "") # 文件消息 type in (3,43,49) 且 content 含文件名 if msg_type in _FILE_MSG_TYPES and file_name in msg_content: logger.info( "[flow:send_file] post_verify OK " "(attempt=%d, type=%d, is_sender=%s)", attempts, msg_type, msg.get("is_sender"), ) return True if messages: logger.debug( "[flow:send_file] post_verify attempt=%d: %d new msgs but no match", attempts, len(messages), ) except Exception as exc: logger.debug( "[flow:send_file] post_verify attempt=%d exception: %s", attempts, exc, ) await asyncio.sleep(_DB_VERIFY_INTERVAL_SEC) logger.warning( "[flow:send_file] post_verify timeout (attempts=%d, file=%s)", attempts, file_name, ) return False