1. 新增WOC_DISABLE_OPENCV环境变量支持纯坐标定位模式 2. 优化xdotool文本输入:替换换行符为空格、添加30ms输入延迟 3. 新增输入框清空逻辑,避免内容残留
482 lines
21 KiB
Python
482 lines
21 KiB
Python
"""L4 SendTextFlow:发送文本消息状态机。
|
||
|
||
8 个 transition:activate → click_search_box → type_query → open_session →
|
||
focus_input → type_content → send → cleanup。
|
||
|
||
会话缓存命中时跳过前 4 步,仅做 _verify_session_still_open。
|
||
异常时调 _reset_to_idle 恢复 UI 状态。
|
||
|
||
发送结果校验:通过 SSEVerifyBus 注册等待,MessageStreamer 广播消息时
|
||
notify 匹配 talker + content。无 DB 轮询,无熔断器,无雪崩风险。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
import random
|
||
import time
|
||
from typing import Optional
|
||
|
||
from woc_bridge.ui.backends.base import WindowGeometry
|
||
from woc_bridge.ui.errors import FlowError, PermanentError
|
||
from woc_bridge.ui.flow import (
|
||
Flow,
|
||
FlowContext,
|
||
FlowResult,
|
||
FlowState,
|
||
_FLOW_TIMEOUT_SEC,
|
||
)
|
||
|
||
logger = logging.getLogger("woc-bridge")
|
||
|
||
# 微信窗口标题
|
||
_WECHAT_WINDOW_TITLE = "微信"
|
||
|
||
|
||
class SendTextFlow(Flow):
|
||
"""发送文本消息 Flow。"""
|
||
|
||
def __init__(self, actions, db_reader=None, session_cache=None,
|
||
verify_bus=None) -> None:
|
||
"""初始化 SendTextFlow。
|
||
|
||
Args:
|
||
actions: L3 Actions 实例
|
||
db_reader: 保留参数兼容性(不再用于 verify,仅供 base 类)
|
||
session_cache: 会话缓存(SessionCache 实例)
|
||
verify_bus: SSE 发送结果校验总线(SSEVerifyBus 实例)。
|
||
None 时 verified 永远为 False(不阻塞发送)。
|
||
"""
|
||
super().__init__(actions, db_reader)
|
||
self.session_cache = session_cache
|
||
self.verify_bus = verify_bus
|
||
|
||
async def run(self, ctx: FlowContext) -> FlowResult:
|
||
"""执行发送文本流程。"""
|
||
started_at = time.monotonic()
|
||
|
||
logger.info(
|
||
"[flow:send_text] START to_wxid=%s display_name=%s content_len=%d client_request_id=%s",
|
||
ctx.to_wxid, ctx.display_name, len(ctx.content), ctx.client_request_id,
|
||
)
|
||
try:
|
||
async with asyncio.timeout(_FLOW_TIMEOUT_SEC):
|
||
# 检查会话缓存(用 to_wxid 做 key,display_name 非唯一)
|
||
cache_hit = False
|
||
if self.session_cache is not None:
|
||
cache_hit = self.session_cache.get(ctx.to_wxid)
|
||
if cache_hit:
|
||
logger.info(
|
||
"[flow:send_text] session cache hit for %s",
|
||
ctx.to_wxid,
|
||
)
|
||
|
||
if cache_hit:
|
||
# 跳过 click_search_box ~ open_session,但仍需激活窗口
|
||
# (30s 内用户可能切到其他应用,微信失去焦点)
|
||
t0 = time.perf_counter()
|
||
# 仅激活窗口 + 获取几何,跳过 Esc 清场(会话已打开)
|
||
await self.actions.backend.activate_window(_WECHAT_WINDOW_TITLE)
|
||
ctx.win_geom = self._ensure_valid_geometry(
|
||
await self.actions.backend.get_window_geometry(
|
||
_WECHAT_WINDOW_TITLE
|
||
)
|
||
)
|
||
# 校验会话仍打开;失败时回退到完整流程,不直接返回失败
|
||
try:
|
||
await self._verify_session_still_open(ctx)
|
||
ctx.current_state = FlowState.SESSION_OPENED
|
||
logger.info(
|
||
"[flow:send_text] cache_hit branch done geom=%dx%d (%.0fms)",
|
||
ctx.win_geom.width, ctx.win_geom.height,
|
||
(time.perf_counter() - t0) * 1000,
|
||
)
|
||
except PermanentError as verify_exc:
|
||
logger.info(
|
||
"[flow:send_text] cache_hit verify failed, fallback to full flow: %s (%.0fms)",
|
||
verify_exc, (time.perf_counter() - t0) * 1000,
|
||
)
|
||
# 失效缓存,走完整流程
|
||
if self.session_cache is not None:
|
||
self.session_cache.invalidate(ctx.to_wxid)
|
||
cache_hit = False
|
||
|
||
if not cache_hit:
|
||
# 完整流程
|
||
await self._activate(ctx)
|
||
await self._click_search_box(ctx)
|
||
await self._type_query(ctx)
|
||
await self._open_session(ctx)
|
||
|
||
await self._focus_input(ctx)
|
||
await self._type_content(ctx)
|
||
await self._send(ctx)
|
||
await self._cleanup(ctx)
|
||
|
||
# 成功:更新会话缓存(用 to_wxid 做 key)
|
||
# SSE verify 通过才缓存,避免发错会话被缓存
|
||
if self.session_cache is not None and ctx.verified:
|
||
self.session_cache.set(ctx.to_wxid)
|
||
|
||
logger.info(
|
||
"[flow:send_text] DONE success=%s verified=%s local_id=%s state=%s (%.0fms)",
|
||
True, ctx.verified, ctx.local_send_id, ctx.current_state,
|
||
(time.monotonic() - started_at) * 1000,
|
||
)
|
||
|
||
return FlowResult(
|
||
success=True,
|
||
local_id=ctx.local_send_id,
|
||
verified=ctx.verified,
|
||
state=ctx.current_state,
|
||
image_confidence=ctx.image_confidence,
|
||
duration_ms=(time.monotonic() - started_at) * 1000,
|
||
)
|
||
|
||
except (FlowError, asyncio.TimeoutError) as exc:
|
||
logger.warning(
|
||
"[flow:send_text] failed at state=%s: %s: %s",
|
||
ctx.current_state, type(exc).__name__, exc,
|
||
)
|
||
# 尝试恢复
|
||
try:
|
||
await self._reset_to_idle(ctx.win_geom)
|
||
except Exception as reset_exc:
|
||
logger.warning(
|
||
"[flow:send_text] reset_to_idle failed: %s", reset_exc
|
||
)
|
||
# 失败时仅失效当前联系人会话缓存
|
||
if self.session_cache is not None:
|
||
self.session_cache.invalidate(ctx.to_wxid)
|
||
|
||
error_code = "SEND_FAILED"
|
||
error_msg = str(exc)
|
||
if isinstance(exc, FlowError) and exc.code:
|
||
error_code = exc.code
|
||
elif isinstance(exc, asyncio.TimeoutError):
|
||
error_code = "SEND_TIMEOUT"
|
||
|
||
return FlowResult(
|
||
success=False,
|
||
local_id=ctx.local_send_id,
|
||
verified=False,
|
||
error=error_msg,
|
||
error_code=error_code,
|
||
state=FlowState.FAILED,
|
||
duration_ms=(time.monotonic() - started_at) * 1000,
|
||
)
|
||
except Exception as exc:
|
||
logger.exception("[flow:send_text] unexpected exception")
|
||
try:
|
||
await self._reset_to_idle(ctx.win_geom)
|
||
except Exception:
|
||
pass
|
||
if self.session_cache is not None:
|
||
self.session_cache.invalidate(ctx.to_wxid)
|
||
return FlowResult(
|
||
success=False,
|
||
error=str(exc),
|
||
error_code="BRIDGE_INTERNAL_ERROR",
|
||
state=FlowState.FAILED,
|
||
duration_ms=(time.monotonic() - started_at) * 1000,
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Transitions
|
||
# ------------------------------------------------------------------
|
||
async def _activate(self, ctx: FlowContext) -> None:
|
||
"""激活窗口 + Esc 清场(关闭残留搜索框/弹窗)。"""
|
||
t0 = time.perf_counter()
|
||
logger.info("[flow:send_text] step=activate start")
|
||
await self.actions.backend.activate_window(_WECHAT_WINDOW_TITLE)
|
||
ctx.win_geom = self._ensure_valid_geometry(
|
||
await self.actions.backend.get_window_geometry(_WECHAT_WINDOW_TITLE)
|
||
)
|
||
# 点击 session_manager_icon 强制回到会话列表首页,清除搜索覆盖层
|
||
await self.actions.click_element("session_manager_icon", ctx.win_geom)
|
||
# Esc 关闭可能的弹窗/搜索框
|
||
await self.actions.backend.key_press("Escape")
|
||
await asyncio.sleep(0.3)
|
||
ctx.current_state = FlowState.WINDOW_ACTIVATED
|
||
logger.info(
|
||
"[flow:send_text] activated window geom=%dx%d (%.0fms)",
|
||
ctx.win_geom.width, ctx.win_geom.height,
|
||
(time.perf_counter() - t0) * 1000,
|
||
)
|
||
|
||
async def _click_search_box(self, ctx: FlowContext) -> None:
|
||
"""点击搜索框。"""
|
||
t0 = time.perf_counter()
|
||
logger.info("[flow:send_text] step=search_box click start")
|
||
await self.actions.click_element("search_box", ctx.win_geom)
|
||
ctx.current_state = FlowState.SEARCH_BOX_CLICKED
|
||
logger.info(
|
||
"[flow:send_text] step=search_box click done (%.0fms)",
|
||
(time.perf_counter() - t0) * 1000,
|
||
)
|
||
|
||
async def _type_query(self, ctx: FlowContext) -> None:
|
||
"""输入搜索查询(display_name)。"""
|
||
t0 = time.perf_counter()
|
||
logger.info(
|
||
"[flow:send_text] step=type_query start display_name=%r",
|
||
ctx.display_name,
|
||
)
|
||
# 清空可能的残留:Ctrl+A 全选 + BackSpace 删除,比 BackSpace×30 更可靠
|
||
await self.actions.backend.key_press("ctrl+a")
|
||
await self.actions.backend.key_press("BackSpace")
|
||
await asyncio.sleep(0.2)
|
||
await self.actions.backend.type_text(ctx.display_name)
|
||
ctx.current_state = FlowState.QUERY_TYPED
|
||
# 纯坐标模式下无法用 OpenCV 验证搜索结果是否渲染,
|
||
# 改为固定等待 0.5s 让微信渲染搜索结果列表。
|
||
await asyncio.sleep(0.5)
|
||
logger.info(
|
||
"[flow:send_text] step=type_query done (fixed wait 0.5s, %.0fms)",
|
||
(time.perf_counter() - t0) * 1000,
|
||
)
|
||
|
||
async def _open_session(self, ctx: FlowContext) -> None:
|
||
"""按 Enter 打开搜索结果第一项(比点击坐标更可靠)。"""
|
||
t0 = time.perf_counter()
|
||
logger.info(
|
||
"[flow:send_text] step=open_session press Enter to open first search result"
|
||
)
|
||
# 微信 4.x 搜索框输入 display_name 后,直接按 Enter 即可打开第一项搜索结果。
|
||
# 这比 click search_result_first 的固定坐标更稳定,不受"搜索网络结果" /
|
||
# "最近在搜" / 联系人结果排序变化的影响。
|
||
await self.actions.backend.key_press("Return")
|
||
ctx.current_state = FlowState.SESSION_OPENED
|
||
# 等待会话切换完成、输入框自动聚焦。
|
||
await asyncio.sleep(0.5)
|
||
logger.info(
|
||
"[flow:send_text] step=open_session done (fixed wait 0.5s, %.0fms)",
|
||
(time.perf_counter() - t0) * 1000,
|
||
)
|
||
|
||
async def _focus_input(self, ctx: FlowContext) -> None:
|
||
"""聚焦输入框。"""
|
||
t0 = time.perf_counter()
|
||
logger.info("[flow:send_text] step=focus_input click input_box start")
|
||
await self.actions.click_element("input_box", ctx.win_geom)
|
||
ctx.current_state = FlowState.INPUT_FOCUSED
|
||
await asyncio.sleep(0.1)
|
||
logger.info(
|
||
"[flow:send_text] step=focus_input done (%.0fms)",
|
||
(time.perf_counter() - t0) * 1000,
|
||
)
|
||
|
||
async def _type_content(self, ctx: FlowContext) -> None:
|
||
"""输入消息内容。"""
|
||
t0 = time.perf_counter()
|
||
# 1. 清理首尾空白
|
||
cleaned_content = ctx.content.strip()
|
||
# 2. 把换行符(含 \r\n / \n / \r)替换为空格,避免 xdotool type 把 \n
|
||
# 解析为 Return 键,导致消息在输入过程中被提前分段发送。
|
||
# 微信输入框本身支持 Shift+Enter 换行,但 xdotool type 无法区分。
|
||
normalized_content = cleaned_content.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
|
||
# 3. 合并连续空格为一个,保持内容整洁
|
||
normalized_content = " ".join(normalized_content.split())
|
||
if normalized_content != ctx.content:
|
||
logger.info(
|
||
"[flow:send_text] step=type_content normalized: "
|
||
"original_len=%d cleaned_len=%d",
|
||
len(ctx.content), len(normalized_content),
|
||
)
|
||
ctx.content = normalized_content
|
||
logger.info(
|
||
"[flow:send_text] step=type_content start content_len=%d",
|
||
len(ctx.content),
|
||
)
|
||
# 4. 输入前清空输入框,避免上次残留内容与本次内容混合
|
||
await self.actions.backend.key_press("ctrl+a")
|
||
await self.actions.backend.key_press("Delete")
|
||
await asyncio.sleep(0.05)
|
||
await self.actions.backend.type_text(ctx.content)
|
||
ctx.current_state = FlowState.TEXT_TYPED
|
||
logger.info(
|
||
"[flow:send_text] step=type_content done (%.0fms)",
|
||
(time.perf_counter() - t0) * 1000,
|
||
)
|
||
|
||
async def _send(self, ctx: FlowContext) -> None:
|
||
"""触发发送 + SSE 校验。
|
||
|
||
纯坐标模式(WOC_DISABLE_OPENCV=1)下用 Enter 键发送,
|
||
避免发送按钮坐标偏移导致点空。微信 4.x 默认 Enter 即发送。
|
||
OpenCV 模式保留点击 send_button(坐标+模板双重保障)。
|
||
|
||
关键:register 必须在 send 之前,否则存在竞态条件:
|
||
- T0: 按 Enter 发送
|
||
- T0+0.05: 微信写 DB
|
||
- T0+0.05~T0+1.0: MessageStreamer 轮询 + 广播 + notify(无 waiter → 丢失)
|
||
- T0+0.1: register(已晚于 notify)
|
||
先注册 future,发送后等待 notify 匹配,超时(2.5s)未匹配则
|
||
verified=False,不阻塞发送成功语义(success=True),调用方按需重试。
|
||
"""
|
||
t0 = time.perf_counter()
|
||
from woc_bridge.ui.actions import _DISABLE_OPENCV
|
||
|
||
# 1. 先注册 SSE 校验等待,避免 send 后 broadcast 已发生的竞态
|
||
verify_future = None
|
||
if self.verify_bus is not None:
|
||
verify_future = self.verify_bus.register(
|
||
to_wxid=ctx.to_wxid,
|
||
content=ctx.content,
|
||
)
|
||
|
||
# 2. 触发发送
|
||
if _DISABLE_OPENCV:
|
||
logger.info("[flow:send_text] step=send press Enter start")
|
||
# 短暂等待输入框聚焦稳定,避免 type_text 后焦点未完全落位
|
||
await asyncio.sleep(0.1)
|
||
await self.actions.backend.key_press("Return")
|
||
logger.info("[flow:send_text] step=send press Enter done")
|
||
else:
|
||
logger.info("[flow:send_text] step=send click send_button start")
|
||
await self.actions.click_element("send_button", ctx.win_geom)
|
||
ctx.current_state = FlowState.SENT
|
||
ctx.local_send_id = f"local_{int(time.time())}_{random.randint(1000,9999)}"
|
||
|
||
# 3. 等待 SSE 校验结果
|
||
ctx.verified = await self._wait_verify(verify_future, ctx)
|
||
logger.info(
|
||
"[flow:send_text] step=send done local_id=%s verified=%s (%.0fms)",
|
||
ctx.local_send_id, ctx.verified,
|
||
(time.perf_counter() - t0) * 1000,
|
||
)
|
||
|
||
async def _cleanup(self, ctx: FlowContext) -> None:
|
||
"""清理:Esc。"""
|
||
t0 = time.perf_counter()
|
||
logger.info("[flow:send_text] step=cleanup Esc start")
|
||
await self.actions.backend.key_press("Escape")
|
||
await asyncio.sleep(0.2)
|
||
ctx.current_state = FlowState.CLEANED_UP
|
||
logger.info(
|
||
"[flow:send_text] step=cleanup done (%.0fms)",
|
||
(time.perf_counter() - t0) * 1000,
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 校验:SSE 推送匹配
|
||
# ------------------------------------------------------------------
|
||
async def _wait_verify(self, future: Optional[asyncio.Future], ctx: FlowContext) -> bool:
|
||
"""等待 SSEVerifyBus 的 future 完成。
|
||
|
||
future 由 _send 在触发发送前 register,确保 MessageStreamer
|
||
广播消息时 notify 能匹配到 waiter。匹配到返回 True,超时返回 False。
|
||
|
||
无 DB 轮询、无截图校验、无熔断器,避免雪崩。
|
||
future 为 None 时(verify_bus 未注入)直接返回 False,不阻塞发送。
|
||
"""
|
||
if future is None:
|
||
logger.warning(
|
||
"[flow:send_text] no verify_bus, skip SSE verify (verified=False)"
|
||
)
|
||
return False
|
||
t0 = time.perf_counter()
|
||
try:
|
||
result = await self.verify_bus.wait(future)
|
||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||
if result:
|
||
logger.info(
|
||
"[flow:send_text] SSE verify OK (talker=%s content matched, %.0fms)",
|
||
ctx.to_wxid, elapsed_ms,
|
||
)
|
||
else:
|
||
logger.warning(
|
||
"[flow:send_text] SSE verify timeout (talker=%s no matching broadcast, %.0fms)",
|
||
ctx.to_wxid, elapsed_ms,
|
||
)
|
||
return result
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"[flow:send_text] SSE verify exception: %s (%.0fms)",
|
||
exc, (time.perf_counter() - t0) * 1000,
|
||
)
|
||
return False
|
||
|
||
async def _verify_session_still_open(self, ctx: FlowContext) -> None:
|
||
"""会话缓存命中时校验会话仍打开。
|
||
|
||
纯坐标模式(WOC_DISABLE_OPENCV=1)下 wait_for("input_box") 纯算术
|
||
返回,无法真正校验 UI 状态。此时改用 is_window_active 校验窗口焦点:
|
||
若微信窗口已失焦(用户切到其他应用),cache 失效走完整流程。
|
||
会话是否真的打开仍靠后续 SSE verify 兜底(content 不匹配则 invalidate)。
|
||
|
||
OpenCV 模式下保留原逻辑(wait_for input_box 模板匹配)。
|
||
|
||
异常处理:get_window_geometry 的 RuntimeError(窗口消失)也转为
|
||
PermanentError,触发 cache_hit 回退到完整流程,避免冒泡为
|
||
BRIDGE_INTERNAL_ERROR。
|
||
"""
|
||
t0 = time.perf_counter()
|
||
# 获取窗口几何(缓存命中时未走 _activate),并校验/窄化类型
|
||
try:
|
||
ctx.win_geom = self._ensure_valid_geometry(
|
||
await self.actions.backend.get_window_geometry(_WECHAT_WINDOW_TITLE)
|
||
)
|
||
except (RuntimeError, PermanentError) as exc:
|
||
logger.warning(
|
||
"[flow:send_text] session_still_open FAIL: get_window_geometry failed: %s (%.0fms)",
|
||
exc, (time.perf_counter() - t0) * 1000,
|
||
)
|
||
raise PermanentError(
|
||
"WINDOW_NOT_FOUND",
|
||
f"session cache verify failed: window not found: {exc}",
|
||
)
|
||
from woc_bridge.ui.actions import _DISABLE_OPENCV
|
||
|
||
if _DISABLE_OPENCV:
|
||
# 纯坐标模式:校验窗口焦点,避免命中已失焦的会话
|
||
is_active = await self.actions.backend.is_window_active(
|
||
_WECHAT_WINDOW_TITLE
|
||
)
|
||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||
if not is_active:
|
||
logger.warning(
|
||
"[flow:send_text] session_still_open FAIL: window not active (%.0fms)",
|
||
elapsed_ms,
|
||
)
|
||
raise PermanentError(
|
||
"ELEMENT_NOT_FOUND",
|
||
"session cache verify failed: window not active",
|
||
)
|
||
logger.info(
|
||
"[flow:send_text] session_still_open OK (window active, %.0fms)",
|
||
elapsed_ms,
|
||
)
|
||
return
|
||
|
||
# OpenCV 模式:用 input_box 模板匹配校验
|
||
try:
|
||
elem = await self.actions.wait_for(
|
||
"input_box", ctx.win_geom, timeout=2.0, interval=0.3
|
||
)
|
||
if elem is None:
|
||
logger.warning(
|
||
"[flow:send_text] session_still_open FAIL: input_box not found (%.0fms)",
|
||
(time.perf_counter() - t0) * 1000,
|
||
)
|
||
raise PermanentError(
|
||
"ELEMENT_NOT_FOUND",
|
||
"session cache verify failed: input_box not found",
|
||
)
|
||
logger.info(
|
||
"[flow:send_text] session_still_open OK (%.0fms)",
|
||
(time.perf_counter() - t0) * 1000,
|
||
)
|
||
except PermanentError:
|
||
raise
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"[flow:send_text] session_still_open exception: %s (%.0fms)",
|
||
exc, (time.perf_counter() - t0) * 1000,
|
||
)
|
||
raise PermanentError(
|
||
"ELEMENT_NOT_FOUND",
|
||
f"session cache verify failed: {exc}",
|
||
)
|