WechatOnCloud/bridge/woc_bridge/ui/flows/send_file.py
Kris c45282f094 feat: 新增好友自动通过、UI自动化能力与多分辨率适配
- 新增登录状态守卫后台任务
- 新增好友申请自动通过规则引擎
- 新增多分辨率UI配置与模板资源
- 新增消息拉取复合游标支持
- 优化发送队列与UI自动化逻辑
- 新增批量发送日志与错误处理
- 优化Docker镜像构建与ptrace初始化
- 新增联系人名称缓存预热
2026-07-17 18:10:31 +08:00

449 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""文件发送 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 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_verifyDB 校验文件消息是否落库
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_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