WechatOnCloud/bridge/woc_bridge/ui/flows/send_text.py

482 lines
21 KiB
Python
Raw Normal View History

"""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}",
)