WechatOnCloud/bridge/woc_bridge/ui/flows/send_text.py
Kris 15fc62d478 feat: 完成微信UI自动化新架构全量开发与集成
- 新增六层UI自动化架构:从Backend到Capabilities的完整分层实现
- 添加WeChat 4.0分辨率适配Profile与图像模板资源
- 实现幂等缓存、熔断器、重试策略、链路追踪与监控指标
- 新增头像下载安全校验、发布朋友圈路径白名单防护
- 优化密钥缓存、DB校验逻辑与初始化流程
- 补充完整错误码体系与启动清场机制
2026-07-16 01:19:47 +08:00

419 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 状态。
"""
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)