WechatOnCloud/bridge/woc_bridge/ui/flows/send_text.py
Kris 3effe1338c feat: 新增纯坐标模式开关与文本输入优化
1. 新增WOC_DISABLE_OPENCV环境变量支持纯坐标定位模式
2. 优化xdotool文本输入:替换换行符为空格、添加30ms输入延迟
3. 新增输入框清空逻辑,避免内容残留
2026-07-18 05:23:06 +08:00

482 lines
21 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.

"""L4 SendTextFlow发送文本消息状态机。
8 个 transitionactivate → 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 做 keydisplay_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}",
)