- 新增登录状态守卫后台任务 - 新增好友申请自动通过规则引擎 - 新增多分辨率UI配置与模板资源 - 新增消息拉取复合游标支持 - 优化发送队列与UI自动化逻辑 - 新增批量发送日志与错误处理 - 优化Docker镜像构建与ptrace初始化 - 新增联系人名称缓存预热
449 lines
18 KiB
Python
449 lines
18 KiB
Python
"""文件发送 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
|