WechatOnCloud/bridge/woc_bridge/ui/flows/send_text.py
Kris 054c4676aa refactor: 重构发送校验逻辑,移除DB轮询与熔断器,改用SSE校验
主要变更:
1. 新增SSEVerifyBus实现基于推送的发送结果校验,替代原DB轮询方案
2. 移除FlowOrchestrator中的DB熔断器,避免雪崩风险
3. 重构SendTextFlow,改用SSE校验流程,删除原DB校验相关代码
4. 新增RoiSpec与ROI搜索支持,优化UI元素匹配精度
5. 更新所有分辨率配置文件,添加ROI配置与调整搜索框坐标
6. 新增WOC_DISABLE_OPENCV环境变量开关,支持纯几何定位模式
7. 清理FlowContext中废弃的before_msg_id字段
2026-07-17 23:50:03 +08:00

412 lines
17 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
)
)
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,
)
else:
# 完整流程
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()
# 清理开头/结尾的空白和换行,避免输入框视觉为空、发送按钮灰色
cleaned_content = ctx.content.strip()
if cleaned_content != ctx.content:
logger.info(
"[flow:send_text] step=type_content stripped leading/trailing whitespace: "
"original_len=%d cleaned_len=%d",
len(ctx.content), len(cleaned_content),
)
ctx.content = cleaned_content
logger.info(
"[flow:send_text] step=type_content start content_len=%d",
len(ctx.content),
)
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:
"""会话缓存命中时校验会话仍打开input_box 存在)。"""
t0 = time.perf_counter()
# 获取窗口几何(缓存命中时未走 _activate并校验/窄化类型
ctx.win_geom = self._ensure_valid_geometry(
await self.actions.backend.get_window_geometry(_WECHAT_WINDOW_TITLE)
)
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 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}",
)