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

419 lines
17 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 状态
"""
from __future__ import annotations
import asyncio
import logging
import random
import time
from typing import Awaitable, Callable, Optional
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 = "微信"
# DB 校验参数
_DB_VERIFY_TIMEOUT_SEC = 3.0
_DB_VERIFY_INTERVAL_SEC = 0.2
class SendTextFlow(Flow):
"""发送文本消息 Flow。"""
def __init__(self, actions, db_reader=None, session_cache=None) -> None:
super().__init__(actions, db_reader)
self.session_cache = session_cache
# 基线消息的 local_id与 before_msg_idcreate_time配合用于 DB 游标查询
self._before_msg_local_id: int = 0
async def run(self, ctx: FlowContext) -> FlowResult:
"""执行发送文本流程。"""
started_at = time.monotonic()
# 后台 task获取 before_msg_id与 _activate 并行)
before_msg_task: Optional[asyncio.Task] = None
try:
async with asyncio.timeout(_FLOW_TIMEOUT_SEC):
# 检查会话缓存(用 to_wxid 做 keydisplay_name 非唯一)
cache_hit = False
if self.session_cache is not None:
cached = self.session_cache.get()
if cached is not None and cached == ctx.to_wxid:
cache_hit = True
logger.info(
"[flow:send_text] session cache hit for %s",
ctx.to_wxid,
)
if cache_hit:
# 跳过 click_search_box ~ open_session但仍需激活窗口
# 30s 内用户可能切到其他应用,微信失去焦点)
before_msg_task = asyncio.create_task(
self._get_latest_create_time(ctx.to_wxid)
)
# 仅激活窗口 + 获取几何,跳过 Esc 清场(会话已打开)
await self.actions.backend.activate_window(_WECHAT_WINDOW_TITLE)
ctx.win_geom = await self.actions.backend.get_window_geometry(
_WECHAT_WINDOW_TITLE
)
await self._verify_session_still_open(ctx)
ctx.current_state = FlowState.SESSION_OPENED
else:
# 完整流程
await self._activate(ctx)
# 并行启动 before_msg_id 获取
before_msg_task = asyncio.create_task(
self._get_latest_create_time(ctx.to_wxid)
)
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)
# 等待 before_msg_task 完成(如果已启动)
if before_msg_task is not None:
ct, lid = await before_msg_task
ctx.before_msg_id = ct
self._before_msg_local_id = lid
await self._send(ctx)
await self._cleanup(ctx)
# 成功:更新会话缓存(用 to_wxid 做 key
if self.session_cache is not None and ctx.verified:
self.session_cache.set(ctx.to_wxid)
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",
ctx.current_state, 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()
error_code = ""
error_msg = str(exc)
if isinstance(exc, FlowError):
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()
return FlowResult(
success=False,
error=str(exc),
error_code="BRIDGE_INTERNAL_ERROR",
state=FlowState.FAILED,
duration_ms=(time.monotonic() - started_at) * 1000,
)
finally:
# 兜底:确保 before_msg_task 被取消CancelledError 路径也走这里)
# Python 3.8+ 中 CancelledError 继承自 BaseException
# except Exception 无法捕获finally 块保证清理执行
if before_msg_task is not None and not before_msg_task.done():
before_msg_task.cancel()
try:
await before_msg_task
except (asyncio.CancelledError, Exception):
pass
# ------------------------------------------------------------------
# Transitions
# ------------------------------------------------------------------
async def _activate(self, ctx: FlowContext) -> None:
"""激活窗口 + 获取几何 + Esc。"""
await self.actions.backend.activate_window(_WECHAT_WINDOW_TITLE)
ctx.win_geom = await self.actions.backend.get_window_geometry(
_WECHAT_WINDOW_TITLE
)
await self.actions.backend.key_press("Escape")
ctx.current_state = FlowState.WINDOW_ACTIVATED
logger.info(
"[flow:send_text] activated window geom=%dx%d",
ctx.win_geom.width, ctx.win_geom.height,
)
async def _click_search_box(self, ctx: FlowContext) -> None:
"""点击搜索框。"""
await self.actions.click_element("search_box", ctx.win_geom)
ctx.current_state = FlowState.SEARCH_BOX_CLICKED
# 简化:点击后立即可输入,等待 0.3s 让 UI 响应
await asyncio.sleep(0.3)
async def _type_query(self, ctx: FlowContext) -> None:
"""输入搜索查询display_name"""
# 清空可能的残留
await self.actions.backend.key_press("BackSpace", repeat=30)
await self.actions.backend.type_text(ctx.display_name)
ctx.current_state = FlowState.QUERY_TYPED
# 自适应等待搜索结果
appeared = await self._wait_for_condition(
lambda: self._check_search_result_appeared(ctx),
timeout=3.0,
interval=0.3,
)
if not appeared:
logger.warning(
"[flow:send_text] search result not appeared in 3s, continue"
)
async def _open_session(self, ctx: FlowContext) -> None:
"""点击搜索结果第一项打开会话。"""
await self.actions.click_element("search_result_first", ctx.win_geom)
ctx.current_state = FlowState.SESSION_OPENED
# 自适应等待 input_box 出现
appeared = await self._wait_for_condition(
lambda: self._check_input_box_appeared(ctx),
timeout=3.0,
interval=0.3,
)
if not appeared:
logger.warning(
"[flow:send_text] input_box not appeared in 3s, continue"
)
async def _focus_input(self, ctx: FlowContext) -> None:
"""聚焦输入框。"""
await self.actions.click_element("input_box", ctx.win_geom)
ctx.current_state = FlowState.INPUT_FOCUSED
async def _type_content(self, ctx: FlowContext) -> None:
"""输入消息内容。"""
await self.actions.backend.type_text(ctx.content)
ctx.current_state = FlowState.TEXT_TYPED
async def _send(self, ctx: FlowContext) -> None:
"""点击发送按钮 + 校验。"""
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)}"
ctx.verified = await self._verify_sent(ctx)
logger.info(
"[flow:send_text] sent local_id=%s verified=%s",
ctx.local_send_id, ctx.verified,
)
async def _cleanup(self, ctx: FlowContext) -> None:
"""清理Esc。"""
await self.actions.backend.key_press("Escape")
ctx.current_state = FlowState.CLEANED_UP
# ------------------------------------------------------------------
# 校验
# ------------------------------------------------------------------
async def _verify_sent(self, ctx: FlowContext) -> bool:
"""发送结果校验DB 校验优先,失败降级截图校验。"""
if self.db_reader is None:
logger.info("[flow:send_text] no db_reader, fall back to screenshot")
return await self._verify_by_screenshot(ctx)
# before_msg_id=0 表示无法建立基线DB 不可用或会话无历史消息),
# 直接降级截图校验,避免 cursor=0 查询返回所有历史消息导致假阳性
if ctx.before_msg_id <= 0:
logger.warning(
"[flow:send_text] before_msg_id=%d, skip DB verify, fall back to screenshot",
ctx.before_msg_id,
)
return await self._verify_by_screenshot(ctx)
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,
ctx.to_wxid,
cursor=ctx.before_msg_id,
cursor_local_id=self._before_msg_local_id,
limit=3,
direction="after",
is_sender=None,
)
messages = result.get("messages", [])
if messages:
# 防串号:校验是否有消息内容匹配发送的 content
# 注意get_messages_by_session 按 talker 分表查询talker 字段恒等于 to_wxid
# 无法通过 talker 字段检测串号。改为内容匹配校验。
for msg in messages:
msg_content = msg.get("content", "")
# 微信消息内容可能包含发送者前缀(群消息),用 in 匹配
if ctx.content and ctx.content in msg_content:
logger.info(
"[flow:send_text] DB verify OK (attempt=%d, content matched, is_sender=%s)",
attempts, msg.get("is_sender"),
)
return True
# 有新消息但内容不匹配 → 可能串号或消息未到达
logger.warning(
"[flow:send_text] DB verify: %d new msgs but content not matched (attempt=%d)",
len(messages), attempts,
)
# 继续轮询,可能是 DB 写入延迟
except Exception as exc:
logger.debug(
"[flow:send_text] DB verify attempt=%d exception: %s",
attempts, exc,
)
await asyncio.sleep(_DB_VERIFY_INTERVAL_SEC)
logger.warning(
"[flow:send_text] DB verify timeout, fall back to screenshot"
)
return await self._verify_by_screenshot(ctx)
async def _verify_by_screenshot(self, ctx: FlowContext) -> bool:
"""截图校验:等待 input_box_empty 模板出现。"""
if ctx.win_geom is None:
return False
try:
elem = await self.actions.wait_for(
"input_box_empty", ctx.win_geom, timeout=3.0, interval=0.3
)
if elem is not None:
logger.info("[flow:send_text] screenshot verify OK")
return True
except Exception as exc:
logger.debug(
"[flow:send_text] screenshot verify exception: %s", exc
)
logger.warning("[flow:send_text] screenshot verify failed")
return False
async def _verify_session_still_open(self, ctx: FlowContext) -> None:
"""会话缓存命中时校验会话仍打开input_box 存在)。"""
# 获取窗口几何(缓存命中时未走 _activate
ctx.win_geom = 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:
raise PermanentError(
"ELEMENT_NOT_FOUND",
"session cache hit but input_box not found",
)
except PermanentError:
raise
except Exception as exc:
raise PermanentError(
"ELEMENT_NOT_FOUND",
f"session cache verify failed: {exc}",
)
# ------------------------------------------------------------------
# 自适应轮询
# ------------------------------------------------------------------
async def _wait_for_condition(
self,
condition_fn: Callable[[], Awaitable[bool]],
timeout: float,
interval: float,
) -> bool:
"""自适应轮询condition_fn 返回 True 时立即返回,超时返回 False。"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
if await condition_fn():
return True
except Exception:
pass
await asyncio.sleep(interval)
return False
async def _check_search_result_appeared(self, ctx: FlowContext) -> bool:
"""检查搜索结果是否出现。"""
try:
elem = await self.actions.find_element(
"search_result_first", ctx.win_geom
)
return elem is not None
except Exception:
return False
async def _check_input_box_appeared(self, ctx: FlowContext) -> bool:
"""检查输入框是否出现。"""
try:
elem = await self.actions.find_element("input_box", ctx.win_geom)
return elem is not None
except Exception:
return False
# ------------------------------------------------------------------
# DB 辅助
# ------------------------------------------------------------------
async def _get_latest_create_time(self, to_wxid: str) -> tuple[int, int]:
"""获取目标会话当前最新消息的 (create_time, local_id)。"""
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:
ct = int(messages[0].get("create_time", 0))
lid = int(messages[0].get("local_id", 0))
return (ct, lid)
except Exception as exc:
logger.debug(
"[flow:send_text] get_latest_create_time exception: %s", exc
)
return (0, 0)