WechatOnCloud/bridge/woc_bridge/ui/flows/send_file.py

388 lines
16 KiB
Python
Raw Normal View History

"""文件发送 Flow基于文件选择器路径输入实现真实文件发送。
复用 publish_moment_with_image 已验证机制系统文件选择器接受 xdotool 输入
Flow 流程定位会话 点击"+" "发送文件" xdotool type 路径 Return
post_verifysleep 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.type3=图片 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",
to_wxid, file_name, file_size, file_type, timeout_sec,
)
# 3. 获取基线时间戳post_verify 游标,仅查发送之后的新消息)
# 复合游标 (create_time, local_id) 避免基线消息本身被重复匹配
baseline_time, baseline_local_id = await self._get_baseline_time(to_wxid)
try:
async with asyncio.timeout(timeout_sec):
# 4. 激活窗口 + 获取几何 + Esc 清场
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)
# 5. 定位会话(搜索框 → 输入 display_name → 点击搜索结果第一项)
await self._open_session(search_query, win_geom)
# 6. 点击 "+" → 选 "发送文件" 菜单项
await self._click_plus_and_send_file(win_geom)
# 7. 系统文件选择器xdotool type 路径 → Return 提交
await self._input_path_in_chooser(file_path)
# 8. 等待文件加载到输入区
await asyncio.sleep(1.0)
except asyncio.TimeoutError as exc:
# 兜底清场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_verifyDB 校验文件消息是否落库
verified = await self._post_verify(
to_wxid, file_name, baseline_time, baseline_local_id
)
logger.info(
"[flow:send_file] done file=%s verified=%s",
file_name, verified,
)
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: 窗口几何信息
"""
await self._actions.click_element("search_box", win_geom)
await asyncio.sleep(0.3)
# 清空可能残留的查询
await self._actions.backend.key_press("BackSpace", repeat=30)
await self._actions.backend.type_text(search_query)
# 等待搜索结果出现
await asyncio.sleep(0.5)
# 点击搜索结果第一项打开会话
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
await self._actions.backend.click(plus_x, plus_y)
await asyncio.sleep(0.6) # 等待菜单弹出
# 键盘导航选 "发送文件"(参考 publish_moment_with_image 的 Down + Return 范式)
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 保证每字符被对话框处理
"""
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"
raise BridgeError(
"SEND_FAILED",
f"xdotool type 路径失败 (code={rc}): {err}",
)
await asyncio.sleep(0.3) # 等待输入完成
# Return 提交选择
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_idtie-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 不可用或会话无历史。
# 此时若仍查 DBcursor=0 + direction=after会扫描全部历史消息
# 同名文件的历史消息会被误匹配(假阳性)。返回 None 表示"无法校验"
# orchestrator 不计熔断失败,避免 DB 不可用时熔断器雪崩。
if baseline_time == 0:
logger.warning(
"[flow:send_file] baseline_time=0DB 不可用或无历史),"
"跳过 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