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字段
This commit is contained in:
parent
c45282f094
commit
054c4676aa
@ -149,6 +149,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
await asyncio.wait_for(_state.message_streamer.stop(), timeout=5.0)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
logger.warning("[lifespan] message_streamer.stop() 超时或异常,强制继续")
|
||||
# 清理 SSE 发送结果校验总线:resolve 所有 pending 等待为 False
|
||||
if _state.verify_bus is not None:
|
||||
try:
|
||||
_state.verify_bus.clear()
|
||||
except Exception:
|
||||
logger.warning("[lifespan] verify_bus.clear() 异常,强制继续")
|
||||
# 停止群发后台协程(LIFO:先于 send_queue 停止,避免新任务继续入队)
|
||||
if _state.batch_worker is not None:
|
||||
try:
|
||||
@ -380,11 +386,19 @@ def _init_state(cfg: BridgeConfig) -> None:
|
||||
max_queue_size=cfg.max_queue_size,
|
||||
)
|
||||
|
||||
# SSE 发送结果校验总线:send_text 发送后注册等待,
|
||||
# MessageStreamer 广播消息时 notify 匹配 talker + content。
|
||||
# 替代 DB 轮询校验,避免熔断器雪崩。
|
||||
from woc_bridge.messaging.verify_bus import SSEVerifyBus
|
||||
_state.verify_bus = SSEVerifyBus(default_timeout_sec=2.5)
|
||||
|
||||
# 消息流推送器:内部轮询 DB + SSE 广播(lifespan 中 start/stop)
|
||||
# 注入 verify_bus:广播 messages 事件时 notify 匹配 pending 的发送等待
|
||||
_state.message_streamer = MessageStreamer(
|
||||
db_reader=_state.db_reader,
|
||||
resolve_db_state=_resolve_db_state_tuple,
|
||||
extract_key=lambda: _auto_extract_with_lock(force=True),
|
||||
verify_bus=_state.verify_bus,
|
||||
)
|
||||
|
||||
# 读取 WOC_DB_KEY 环境变量,注入 DbReader
|
||||
@ -461,6 +475,7 @@ def _init_state(cfg: BridgeConfig) -> None:
|
||||
db_reader=_state.db_reader,
|
||||
config=cfg,
|
||||
send_file_flow=send_file_flow,
|
||||
verify_bus=_state.verify_bus,
|
||||
)
|
||||
|
||||
# 构造 Capabilities
|
||||
|
||||
@ -241,6 +241,8 @@ class AppState:
|
||||
self.send_queue: SendQueue | None = None
|
||||
self.qr_capture: QrCapture | None = None
|
||||
self.message_streamer: MessageStreamer | None = None
|
||||
# SSE 发送结果校验总线(send_text 校验,避免 DB 轮询雪崩)
|
||||
self.verify_bus: Optional[Any] = None # SSEVerifyBus
|
||||
self.start_time: float = 0.0
|
||||
# DB 解密密钥缓存(env / api / auto_extract / file 注入)
|
||||
self.key_cache = KeyCache()
|
||||
|
||||
@ -97,6 +97,7 @@ class MessageStreamer:
|
||||
db_reader: "DbReader",
|
||||
resolve_db_state: DbStateResolver,
|
||||
extract_key: Optional[Callable[[], Awaitable[Optional[dict[str, str]]]]] = None,
|
||||
verify_bus: Optional["SSEVerifyBus"] = None,
|
||||
) -> None:
|
||||
"""初始化推送器。
|
||||
|
||||
@ -107,10 +108,14 @@ class MessageStreamer:
|
||||
extract_key: 异步回调,强制重新提取 DB 密钥。
|
||||
get_messages_since 遇到 DB_ENCRYPTED 时调用,成功后重试一次。
|
||||
None 时不做重试(仅记日志)
|
||||
verify_bus: SSE 发送结果校验总线(可选)。
|
||||
注入后,广播 messages 事件时会调用 verify_bus.notify,
|
||||
匹配 pending 的发送结果等待,实现发送确认。
|
||||
"""
|
||||
self._db_reader = db_reader
|
||||
self._resolve_db_state = resolve_db_state
|
||||
self._extract_key = extract_key
|
||||
self._verify_bus = verify_bus
|
||||
# value 为订阅时间戳(monotonic),用于在超限时剔除最早订阅者
|
||||
self._subscribers: dict[asyncio.Queue[StreamEvent], float] = {}
|
||||
self._global_cursor: int = 0
|
||||
@ -219,10 +224,25 @@ class MessageStreamer:
|
||||
|
||||
队列满时丢弃最旧事件再放入,避免单客户端消费慢拖垮全局。
|
||||
日志在此处统一打印一次,避免在每订阅者生成器循环内重复打印。
|
||||
|
||||
messages 事件广播前先调用 verify_bus.notify,匹配 pending 的发送
|
||||
结果等待。这样 SendTextFlow._wait_verify 能在消息广播时立即收到
|
||||
通知,无需等待 SSE 订阅者消费事件。
|
||||
"""
|
||||
# sync 事件只发给单个订阅者(在 subscribe 内直接 put),不走广播
|
||||
if event.event != "sync":
|
||||
self._log_broadcast_once(event)
|
||||
# messages 事件:先 notify verify_bus,再广播给订阅者
|
||||
if event.event == "messages" and self._verify_bus is not None:
|
||||
for msg in event.data.get("messages", []) or []:
|
||||
try:
|
||||
self._verify_bus.notify(
|
||||
talker=msg.get("talker", ""),
|
||||
content=msg.get("content", ""),
|
||||
is_sender=bool(msg.get("is_sender", False)),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("verify_bus.notify exception: %s", exc)
|
||||
for q in list(self._subscribers):
|
||||
try:
|
||||
q.put_nowait(event)
|
||||
|
||||
168
bridge/woc_bridge/messaging/verify_bus.py
Normal file
168
bridge/woc_bridge/messaging/verify_bus.py
Normal file
@ -0,0 +1,168 @@
|
||||
"""SSE 发送结果校验总线。
|
||||
|
||||
发送文本后,发送方注册一个 (to_wxid, content) 等待;MessageStreamer
|
||||
广播新消息时调用 notify,匹配到等待即 resolve 为 True。超时自动清理
|
||||
(默认 2.5s),避免内存泄漏。
|
||||
|
||||
设计要点:
|
||||
- 完全异步、单线程事件循环(asyncio.Future)
|
||||
- 精确匹配 talker == to_wxid 且 is_sender=True(自己发出的消息)
|
||||
且 content 包含发送内容(微信可能加前缀/后缀)
|
||||
- 多发送并发时,每个 (to_wxid, content) 独立 Future,互不干扰
|
||||
- 防泄漏:register 后无论是否被 notify,超时都会清理并 resolve False
|
||||
- 不依赖 DB,无熔断器,无雪崩风险
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("woc-bridge")
|
||||
|
||||
# 默认等待超时(秒):SSE 是推送机制,比 DB 轮询快,
|
||||
# 但 MessageStreamer 有 1s 轮询间隔 + DB mtime 检测延迟,
|
||||
# 2.5s 足够覆盖正常场景。
|
||||
_DEFAULT_TIMEOUT_SEC = 2.5
|
||||
|
||||
|
||||
class SSEVerifyBus:
|
||||
"""SSE 发送结果校验总线。
|
||||
|
||||
线程安全说明:asyncio.Future 与 dict 操作都在同一事件循环中,
|
||||
无需加锁。notify 由 MessageStreamer._broadcast 调用(同一 loop),
|
||||
register/wait 由 SendTextFlow 调用(同一 loop)。
|
||||
"""
|
||||
|
||||
def __init__(self, default_timeout_sec: float = _DEFAULT_TIMEOUT_SEC) -> None:
|
||||
self._default_timeout = default_timeout_sec
|
||||
# key: (to_wxid, content_normalized) -> list[(future, expire_at)]
|
||||
# 用 list 支持同 to_wxid + 同 content 的并发发送(极少见,但需支持)
|
||||
self._pending: dict[tuple[str, str], list[tuple[asyncio.Future, float]]] = {}
|
||||
|
||||
def _normalize(self, content: str) -> str:
|
||||
"""规范化内容用于匹配:去除首尾空白。
|
||||
|
||||
微信消息内容与发送内容可能有微小差异(如尾随换行),
|
||||
用 strip 后的子串匹配更鲁棒。
|
||||
"""
|
||||
return (content or "").strip()
|
||||
|
||||
def register(
|
||||
self,
|
||||
to_wxid: str,
|
||||
content: str,
|
||||
timeout_sec: Optional[float] = None,
|
||||
) -> asyncio.Future:
|
||||
"""注册一个发送结果等待。
|
||||
|
||||
Args:
|
||||
to_wxid: 目标联系人 wxid
|
||||
content: 发送的消息内容
|
||||
timeout_sec: 超时秒数,None 用默认值
|
||||
|
||||
Returns:
|
||||
asyncio.Future[bool]:True 表示收到匹配的 SSE 事件,
|
||||
False 表示超时未收到
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
future: asyncio.Future = loop.create_future()
|
||||
timeout = timeout_sec if timeout_sec is not None else self._default_timeout
|
||||
expire_at = time.monotonic() + timeout
|
||||
|
||||
key = (to_wxid, self._normalize(content))
|
||||
self._pending.setdefault(key, []).append((future, expire_at))
|
||||
|
||||
# 超时自动清理:超时后 resolve False 并移除条目
|
||||
def _on_timeout() -> None:
|
||||
if not future.done():
|
||||
future.set_result(False)
|
||||
self._cleanup_done(key)
|
||||
|
||||
loop.call_later(timeout, _on_timeout)
|
||||
return future
|
||||
|
||||
async def wait(self, future: asyncio.Future) -> bool:
|
||||
"""等待 register 返回的 future 完成。
|
||||
|
||||
单独提供 wait 方法便于在 async 上下文中 await。
|
||||
"""
|
||||
return await future
|
||||
|
||||
def notify(
|
||||
self,
|
||||
talker: str,
|
||||
content: str,
|
||||
is_sender: bool,
|
||||
) -> int:
|
||||
"""消息广播时调用,匹配 pending 等待并 resolve。
|
||||
|
||||
一条 SSE 消息只匹配一个等待(FIFO),避免同 (to_wxid, content) 的
|
||||
并发发送被同一条消息全部 resolve。后续相同等待需等下一条 SSE 消息。
|
||||
|
||||
Args:
|
||||
talker: 消息的 talker 字段(会话对方 wxid)
|
||||
content: 消息内容
|
||||
is_sender: 是否是自己发出的消息
|
||||
|
||||
Returns:
|
||||
匹配到的等待数量(0 或 1)
|
||||
"""
|
||||
if not is_sender:
|
||||
return 0
|
||||
normalized = self._normalize(content)
|
||||
# 精确匹配 talker,但 content 用子串匹配(微信可能加前缀)
|
||||
# 遍历所有 pending key,找 talker 匹配且 content 包含目标内容的
|
||||
matched = 0
|
||||
keys_to_check = [
|
||||
k for k in self._pending.keys()
|
||||
if k[0] == talker
|
||||
]
|
||||
for key in keys_to_check:
|
||||
if matched > 0:
|
||||
break # 一条消息只匹配一个等待
|
||||
_, target_content = key
|
||||
# target_content 是发送时规范化的内容
|
||||
# 检查消息内容是否包含目标内容(微信群消息可能加发送者前缀)
|
||||
if target_content and target_content in normalized:
|
||||
waiters = self._pending.get(key, [])
|
||||
if not waiters:
|
||||
self._pending.pop(key, None)
|
||||
continue
|
||||
# FIFO:只 resolve 第一个未完成的等待
|
||||
future, expire_at = waiters[0]
|
||||
if not future.done():
|
||||
future.set_result(True)
|
||||
matched = 1
|
||||
# 移除已 resolve 的条目
|
||||
remaining = waiters[1:]
|
||||
if remaining:
|
||||
self._pending[key] = remaining
|
||||
else:
|
||||
self._pending.pop(key, None)
|
||||
return matched
|
||||
|
||||
def _cleanup_done(self, key: tuple[str, str]) -> None:
|
||||
"""清理已完成的等待条目(超时或已 resolve)。"""
|
||||
waiters = self._pending.get(key)
|
||||
if waiters is None:
|
||||
return
|
||||
remaining = [(f, t) for f, t in waiters if not f.done()]
|
||||
if remaining:
|
||||
self._pending[key] = remaining
|
||||
else:
|
||||
self._pending.pop(key, None)
|
||||
|
||||
def pending_count(self) -> int:
|
||||
"""当前等待中的校验数量(供监控/调试)。"""
|
||||
return sum(len(v) for v in self._pending.values())
|
||||
|
||||
def clear(self) -> None:
|
||||
"""清除所有等待(resolve False)。供 lifespan 停止时调用。"""
|
||||
for key, waiters in list(self._pending.items()):
|
||||
for future, _ in waiters:
|
||||
if not future.done():
|
||||
future.set_result(False)
|
||||
self._pending.clear()
|
||||
@ -175,8 +175,8 @@ async def _verify_sent_to_talker(
|
||||
新消息存在。
|
||||
|
||||
Note:
|
||||
P3 阶段该校验将迁移到 SendTextFlow._verify_sent,本阶段先在
|
||||
routes/send.py 修复防串号问题。
|
||||
P3 阶段该校验已迁移到 SendTextFlow._wait_verify(基于 SSE 广播),
|
||||
本函数仅 legacy 路径使用。
|
||||
"""
|
||||
if db_reader is None:
|
||||
return True
|
||||
@ -384,7 +384,7 @@ async def _send_text_via_legacy(req: SendTextRequest) -> SendResponse:
|
||||
|
||||
|
||||
async def _send_text_via_flow(req: SendTextRequest) -> SendResponse:
|
||||
"""新架构发送:经 FlowOrchestrator 编排(幂等/熔断/会话缓存/DB 校验)。"""
|
||||
"""新架构发送:经 FlowOrchestrator 编排(幂等/会话缓存/SSE 校验)。"""
|
||||
orchestrator = _state.orchestrator
|
||||
if orchestrator is None:
|
||||
raise BridgeError(
|
||||
|
||||
@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
@ -24,6 +25,14 @@ from woc_bridge.ui.locators.registry import LocatorRegistry
|
||||
|
||||
logger = logging.getLogger("woc-bridge")
|
||||
|
||||
# 纯坐标模式开关:WOC_DISABLE_OPENCV=1 时跳过 OpenCV 模板匹配,
|
||||
# find_element 直接走几何兜底(by_geom 比例坐标),不截图不匹配。
|
||||
# 适用于模板图像不可靠的场景,靠窗口几何比例定位元素。
|
||||
# 启动时读取一次,运行时不再重复读环境变量。
|
||||
_DISABLE_OPENCV = os.environ.get("WOC_DISABLE_OPENCV", "").strip() in (
|
||||
"1", "true", "True", "TRUE", "yes",
|
||||
)
|
||||
|
||||
|
||||
class ElementNotFoundError(Exception):
|
||||
"""元素未找到(图像匹配失败且 require_image=True,或几何规格缺失)。
|
||||
@ -90,6 +99,17 @@ class Actions:
|
||||
"""
|
||||
sel = self.locator.get(kind)
|
||||
|
||||
# 纯坐标模式:WOC_DISABLE_OPENCV=1 时直接走几何兜底,
|
||||
# 跳过截图与 OpenCV 模板匹配,按 Selector.by_geom 比例坐标定位。
|
||||
# 返回 strategy="geom",confidence=1.0。
|
||||
if _DISABLE_OPENCV:
|
||||
logger.debug(
|
||||
"[action] opencv disabled (WOC_DISABLE_OPENCV=1), "
|
||||
"use geom directly kind=%s",
|
||||
kind,
|
||||
)
|
||||
return self._geom_find(sel, win_geom)
|
||||
|
||||
# 图像路径(P2 启用,P1 阶段 opencv=None 直接跳过)
|
||||
if (
|
||||
sel.by_image
|
||||
@ -100,7 +120,10 @@ class Actions:
|
||||
if shot is None:
|
||||
shot = await self.backend.screenshot()
|
||||
self._set_cached_screenshot(shot)
|
||||
roi = (win_geom.x, win_geom.y, win_geom.width, win_geom.height)
|
||||
# 计算 OpenCV 搜索 ROI:优先用 Selector.roi(相对窗口的比例矩形),
|
||||
# 无则回退到全窗口。ROI 限制可防止模板误匹配到窗口其他相似元素
|
||||
# (如 input_box 模板错误时匹配到窗口中部,参见 false-positive 事件)。
|
||||
roi = self._compute_roi(sel, win_geom)
|
||||
try:
|
||||
result = await self.locator.opencv.find_template(
|
||||
shot,
|
||||
@ -257,6 +280,42 @@ class Actions:
|
||||
# ------------------------------------------------------------------
|
||||
# 几何兜底
|
||||
# ------------------------------------------------------------------
|
||||
def _compute_roi(
|
||||
self, selector: Selector, win_geom: WindowGeometry
|
||||
) -> tuple[int, int, int, int]:
|
||||
"""计算 OpenCV 搜索 ROI(绝对像素坐标)。
|
||||
|
||||
优先使用 Selector.roi(相对窗口的比例矩形),无则回退到全窗口。
|
||||
输出格式为 (x, y, w, h),与 OpenCVBackend.find_template 的 roi 参数一致。
|
||||
边界由 opencv.py 内部裁剪保护,这里只做基础 clamp 防负值。
|
||||
|
||||
Args:
|
||||
selector: 元素选择器(可能含 roi)
|
||||
win_geom: 当前窗口几何
|
||||
|
||||
Returns:
|
||||
(x, y, w, h) 绝对像素 ROI
|
||||
"""
|
||||
if selector.roi is None:
|
||||
return (win_geom.x, win_geom.y, win_geom.width, win_geom.height)
|
||||
r = selector.roi
|
||||
abs_x = win_geom.x + int(win_geom.width * r.x_ratio)
|
||||
abs_y = win_geom.y + int(win_geom.height * r.y_ratio)
|
||||
abs_w = int(win_geom.width * r.w_ratio)
|
||||
abs_h = int(win_geom.height * r.h_ratio)
|
||||
# 基础保护:防止负值或零尺寸
|
||||
abs_w = max(abs_w, 1)
|
||||
abs_h = max(abs_h, 1)
|
||||
logger.debug(
|
||||
"[action] compute_roi kind=%s win=(%d,%d,%dx%d) "
|
||||
"roi_ratio=(%.3f,%.3f,%.3f,%.3f) → abs=(%d,%d,%dx%d)",
|
||||
selector.kind,
|
||||
win_geom.x, win_geom.y, win_geom.width, win_geom.height,
|
||||
r.x_ratio, r.y_ratio, r.w_ratio, r.h_ratio,
|
||||
abs_x, abs_y, abs_w, abs_h,
|
||||
)
|
||||
return (abs_x, abs_y, abs_w, abs_h)
|
||||
|
||||
def _geom_find(
|
||||
self, selector: Selector, win_geom: WindowGeometry
|
||||
) -> ElementHandle:
|
||||
|
||||
@ -56,7 +56,6 @@ class FlowContext:
|
||||
display_name: str = ""
|
||||
client_request_id: str = ""
|
||||
win_geom: Optional[WindowGeometry] = None
|
||||
before_msg_id: int = 0
|
||||
local_send_id: str = ""
|
||||
verified: bool = False
|
||||
image_confidence: float = 0.0
|
||||
|
||||
@ -5,6 +5,9 @@ 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
|
||||
@ -13,7 +16,7 @@ import asyncio
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from typing import Awaitable, Callable, Optional
|
||||
from typing import Optional
|
||||
|
||||
from woc_bridge.ui.backends.base import WindowGeometry
|
||||
from woc_bridge.ui.errors import FlowError, PermanentError
|
||||
@ -30,25 +33,28 @@ 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:
|
||||
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
|
||||
# 基线消息的 local_id,与 before_msg_id(create_time)配合用于 DB 游标查询
|
||||
self._before_msg_local_id: int = 0
|
||||
self.verify_bus = verify_bus
|
||||
|
||||
async def run(self, ctx: FlowContext) -> FlowResult:
|
||||
"""执行发送文本流程。"""
|
||||
started_at = time.monotonic()
|
||||
# 后台 task:获取 before_msg_id(与 _activate 并行)
|
||||
before_msg_task: Optional[asyncio.Task] = None
|
||||
|
||||
logger.info(
|
||||
"[flow:send_text] START to_wxid=%s display_name=%s content_len=%d client_request_id=%s",
|
||||
@ -70,9 +76,6 @@ class SendTextFlow(Flow):
|
||||
# 跳过 click_search_box ~ open_session,但仍需激活窗口
|
||||
# (30s 内用户可能切到其他应用,微信失去焦点)
|
||||
t0 = time.perf_counter()
|
||||
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 = self._ensure_valid_geometry(
|
||||
@ -90,33 +93,17 @@ class SendTextFlow(Flow):
|
||||
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
|
||||
logger.info(
|
||||
"[flow:send_text] before_msg baseline ct=%d local_id=%d",
|
||||
ct, lid,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"[flow:send_text] before_msg_task is None, baseline unavailable"
|
||||
)
|
||||
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)
|
||||
|
||||
@ -182,52 +169,28 @@ class SendTextFlow(Flow):
|
||||
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
|
||||
# ------------------------------------------------------------------
|
||||
def _ensure_valid_geometry(self, geom: Optional[WindowGeometry]) -> WindowGeometry:
|
||||
"""校验窗口几何有效,防止拿到 1×1 隐藏窗后继续误点 (0,0)。
|
||||
|
||||
返回校验后的 WindowGeometry,方便调用方获得窄化类型。
|
||||
"""
|
||||
if geom is None:
|
||||
raise PermanentError(
|
||||
"WINDOW_GEOMETRY_INVALID",
|
||||
"无法获取微信窗口几何,无法执行 UI 操作",
|
||||
)
|
||||
if geom.width < 200 or geom.height < 200:
|
||||
raise PermanentError(
|
||||
"WINDOW_GEOMETRY_INVALID",
|
||||
f"微信窗口几何异常 {geom.width}x{geom.height},无法执行 UI 操作",
|
||||
)
|
||||
return geom
|
||||
|
||||
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",
|
||||
"[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:
|
||||
@ -236,8 +199,6 @@ class SendTextFlow(Flow):
|
||||
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
|
||||
# 点击后短暂等待 UI 响应,高并发场景下调短以提升吞吐
|
||||
await asyncio.sleep(0.2)
|
||||
logger.info(
|
||||
"[flow:send_text] step=search_box click done (%.0fms)",
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
@ -256,47 +217,31 @@ class SendTextFlow(Flow):
|
||||
await asyncio.sleep(0.2)
|
||||
await self.actions.backend.type_text(ctx.display_name)
|
||||
ctx.current_state = FlowState.QUERY_TYPED
|
||||
# 自适应等待搜索结果渲染;P1 阶段无 OpenCV 时 find_element 的 geom 兜底
|
||||
# 不可信,因此 _check_search_result_appeared 只在 image 命中时返回 True。
|
||||
appeared = await self._wait_for_condition(
|
||||
lambda: self._check_search_result_appeared(ctx),
|
||||
timeout=1.5,
|
||||
interval=0.3,
|
||||
# 纯坐标模式下无法用 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,
|
||||
)
|
||||
if not appeared:
|
||||
logger.warning(
|
||||
"[flow:send_text] search result not appeared in 1.5s, continue (%.0fms)",
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"[flow:send_text] step=type_query done (search result appeared, %.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 click search_result_first start")
|
||||
await self.actions.click_element("search_result_first", ctx.win_geom)
|
||||
ctx.current_state = FlowState.SESSION_OPENED
|
||||
# 自适应等待 input_box 出现;P1 阶段无 OpenCV 时 find_element 的 geom 兜底
|
||||
# 不可信,因此 _check_input_box_appeared 只在 image 命中时返回 True。
|
||||
appeared = await self._wait_for_condition(
|
||||
lambda: self._check_input_box_appeared(ctx),
|
||||
timeout=1.5,
|
||||
interval=0.3,
|
||||
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,
|
||||
)
|
||||
if not appeared:
|
||||
logger.warning(
|
||||
"[flow:send_text] input_box not appeared in 1.5s, continue (%.0fms)",
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"[flow:send_text] step=open_session done (input_box appeared, %.0fms)",
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
|
||||
async def _focus_input(self, ctx: FlowContext) -> None:
|
||||
"""聚焦输入框。"""
|
||||
@ -321,7 +266,7 @@ class SendTextFlow(Flow):
|
||||
"original_len=%d cleaned_len=%d",
|
||||
len(ctx.content), len(cleaned_content),
|
||||
)
|
||||
ctx.content = cleaned_content
|
||||
ctx.content = cleaned_content
|
||||
logger.info(
|
||||
"[flow:send_text] step=type_content start content_len=%d",
|
||||
len(ctx.content),
|
||||
@ -334,13 +279,46 @@ class SendTextFlow(Flow):
|
||||
)
|
||||
|
||||
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()
|
||||
logger.info("[flow:send_text] step=send click send_button start")
|
||||
await self.actions.click_element("send_button", ctx.win_geom)
|
||||
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)}"
|
||||
ctx.verified = await self._verify_sent(ctx)
|
||||
|
||||
# 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,
|
||||
@ -360,144 +338,43 @@ class SendTextFlow(Flow):
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 校验
|
||||
# 校验:SSE 推送匹配
|
||||
# ------------------------------------------------------------------
|
||||
async def _verify_sent(self, ctx: FlowContext) -> bool:
|
||||
"""发送结果校验:DB 校验优先,失败降级截图校验。"""
|
||||
t0 = time.perf_counter()
|
||||
if self.db_reader is None:
|
||||
logger.warning(
|
||||
"[flow:send_text] no db_reader, fall back to screenshot verify"
|
||||
)
|
||||
result = await self._verify_by_screenshot(ctx)
|
||||
logger.info(
|
||||
"[flow:send_text] _verify_sent done (no db_reader) result=%s (%.0fms)",
|
||||
result, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
return result
|
||||
# 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,
|
||||
)
|
||||
result = await self._verify_by_screenshot(ctx)
|
||||
logger.info(
|
||||
"[flow:send_text] _verify_sent done (no baseline) result=%s (%.0fms)",
|
||||
result, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
return result
|
||||
async def _wait_verify(self, future: Optional[asyncio.Future], ctx: FlowContext) -> bool:
|
||||
"""等待 SSEVerifyBus 的 future 完成。
|
||||
|
||||
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, %d new msgs)",
|
||||
attempts, msg.get("is_sender"), len(messages),
|
||||
)
|
||||
return True
|
||||
# 有新消息但内容不匹配 → 可能串号或消息未到达
|
||||
logger.warning(
|
||||
"[flow:send_text] DB verify: %d new msgs but content not matched (attempt=%d, contents=%s)",
|
||||
len(messages), attempts,
|
||||
[m.get("content", "")[:50] for m in messages],
|
||||
)
|
||||
# 继续轮询,可能是 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)
|
||||
future 由 _send 在触发发送前 register,确保 MessageStreamer
|
||||
广播消息时 notify 能匹配到 waiter。匹配到返回 True,超时返回 False。
|
||||
|
||||
logger.warning(
|
||||
"[flow:send_text] DB verify timeout after %ds/%d attempts, fall back to screenshot",
|
||||
_DB_VERIFY_TIMEOUT_SEC, attempts,
|
||||
)
|
||||
result = await self._verify_by_screenshot(ctx)
|
||||
logger.info(
|
||||
"[flow:send_text] _verify_sent done (DB timeout) result=%s (%.0fms)",
|
||||
result, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
return result
|
||||
|
||||
async def _verify_by_screenshot(self, ctx: FlowContext) -> bool:
|
||||
"""截图校验:轮询等待 input_box_empty 模板出现。
|
||||
|
||||
策略说明:
|
||||
- OpenCV image 命中 → 可靠校验通过,返回 True
|
||||
- geom 兜底命中 → 不可靠,继续轮询等待 image 命中;超时后返回 False
|
||||
- 超时/异常 → 返回 False
|
||||
|
||||
geom 兜底永远返回坐标,无法证明输入框已清空。以往乐观返回 True 会掩盖
|
||||
真实发送失败,现改为只有 image 命中才认为校验通过。
|
||||
无 DB 轮询、无截图校验、无熔断器,避免雪崩。
|
||||
future 为 None 时(verify_bus 未注入)直接返回 False,不阻塞发送。
|
||||
"""
|
||||
if ctx.win_geom is None:
|
||||
logger.warning("[flow:send_text] screenshot verify skipped: win_geom is None")
|
||||
return False
|
||||
t0 = time.perf_counter()
|
||||
deadline = time.monotonic() + 3.0
|
||||
attempts = 0
|
||||
last_strategy: Optional[str] = None
|
||||
last_conf: float = 0.0
|
||||
while time.monotonic() < deadline:
|
||||
attempts += 1
|
||||
try:
|
||||
elem = await self.actions.find_element(
|
||||
"input_box_empty", ctx.win_geom
|
||||
)
|
||||
if elem is None:
|
||||
continue
|
||||
if elem.strategy == "image":
|
||||
logger.info(
|
||||
"[flow:send_text] screenshot verify OK (image matched, conf=%.3f, attempts=%d, %.0fms)",
|
||||
elem.confidence, attempts, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
return True
|
||||
# geom 兜底:记录策略,继续轮询看 image 是否能匹配
|
||||
last_strategy = elem.strategy
|
||||
last_conf = elem.confidence
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"[flow:send_text] screenshot verify attempt=%d exception: %s",
|
||||
attempts, exc,
|
||||
)
|
||||
await asyncio.sleep(0.3)
|
||||
# 超时:只有 image 命中才算成功,geom 兜底不再乐观返回
|
||||
if last_strategy == "geom":
|
||||
if future is None:
|
||||
logger.warning(
|
||||
"[flow:send_text] screenshot verify FAIL: only geom fallback matched, "
|
||||
"image not matched in 3s/%d attempts, %.0fms — cannot prove send success",
|
||||
attempts, (time.perf_counter() - t0) * 1000,
|
||||
"[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
|
||||
logger.warning(
|
||||
"[flow:send_text] screenshot verify FAIL: input_box_empty not found in 3s "
|
||||
"(attempts=%d, %.0fms)",
|
||||
attempts, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
return False
|
||||
|
||||
async def _verify_session_still_open(self, ctx: FlowContext) -> None:
|
||||
"""会话缓存命中时校验会话仍打开(input_box 存在)。"""
|
||||
@ -517,14 +394,12 @@ class SendTextFlow(Flow):
|
||||
)
|
||||
raise PermanentError(
|
||||
"ELEMENT_NOT_FOUND",
|
||||
"session cache hit but input_box not found",
|
||||
"session cache verify failed: input_box not found",
|
||||
)
|
||||
logger.info(
|
||||
"[flow:send_text] session_still_open OK (input_box found, strategy=%s, %.0fms)",
|
||||
elem.strategy, (time.perf_counter() - t0) * 1000,
|
||||
"[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)",
|
||||
@ -534,88 +409,3 @@ class SendTextFlow(Flow):
|
||||
"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:
|
||||
"""检查搜索结果是否真正出现。
|
||||
|
||||
P1 阶段:find_element 在 image 匹配失败时会回退到 geom 兜底,
|
||||
而 geom 兜底只是返回 YAML profile 中写死的比例坐标,不能证明
|
||||
搜索结果已渲染。因此只有 image 命中(strategy == "image")
|
||||
才视为可靠出现。
|
||||
"""
|
||||
try:
|
||||
elem = await self.actions.find_element(
|
||||
"search_result_first", ctx.win_geom
|
||||
)
|
||||
return elem is not None and elem.strategy == "image"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _check_input_box_appeared(self, ctx: FlowContext) -> bool:
|
||||
"""检查输入框是否真正出现。
|
||||
|
||||
同 _check_search_result_appeared,只有 image 命中才视为可靠。
|
||||
"""
|
||||
try:
|
||||
elem = await self.actions.find_element("input_box", ctx.win_geom)
|
||||
return elem is not None and elem.strategy == "image"
|
||||
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:
|
||||
logger.debug("[flow:send_text] get_latest_create_time: db_reader is None")
|
||||
return (0, 0)
|
||||
t0 = time.perf_counter()
|
||||
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))
|
||||
logger.info(
|
||||
"[flow:send_text] get_latest_create_time OK to_wxid=%s ct=%d local_id=%d (%.0fms)",
|
||||
to_wxid, ct, lid, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
return (ct, lid)
|
||||
logger.info(
|
||||
"[flow:send_text] get_latest_create_time: no history msgs for to_wxid=%s (%.0fms)",
|
||||
to_wxid, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[flow:send_text] get_latest_create_time exception: %s (%.0fms)",
|
||||
exc, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
return (0, 0)
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
"""L2 Locator 抽象层:GeomSpec + Selector + ElementHandle + LocatorRegistry。
|
||||
"""L2 Locator 抽象层:GeomSpec + RoiSpec + Selector + ElementHandle + LocatorRegistry。
|
||||
|
||||
LocatorRegistry 按 (app_version, resolution) 加载 YAML profile,
|
||||
返回 Selector 给 L3 Actions,Actions 据此调 L1 Backend 执行点击。
|
||||
"""
|
||||
|
||||
from woc_bridge.ui.locators.base import ElementHandle, GeomSpec, Selector
|
||||
from woc_bridge.ui.locators.base import ElementHandle, GeomSpec, RoiSpec, Selector
|
||||
from woc_bridge.ui.locators.registry import LocatorRegistry
|
||||
|
||||
__all__ = ["GeomSpec", "Selector", "ElementHandle", "LocatorRegistry"]
|
||||
__all__ = ["GeomSpec", "RoiSpec", "Selector", "ElementHandle", "LocatorRegistry"]
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
"""L2 Locator 抽象层基类:GeomSpec + Selector + ElementHandle。
|
||||
"""L2 Locator 抽象层基类:GeomSpec + RoiSpec + Selector + ElementHandle。
|
||||
|
||||
GeomSpec:几何定位规格(relative_to / ratio / offset)
|
||||
Selector:UI 元素选择器(kind / by_image / by_geom / threshold / require_image)
|
||||
RoiSpec:图像匹配感兴趣区域(相对窗口的比例矩形,缩小 OpenCV 搜索范围)
|
||||
Selector:UI 元素选择器(kind / by_image / by_geom / roi / threshold / require_image)
|
||||
ElementHandle:定位结果句柄(绝对坐标 + 置信度 + 策略)
|
||||
|
||||
L3 Actions 通过 Selector 描述要找的元素,LocatorRegistry 解析为
|
||||
@ -31,6 +32,32 @@ class GeomSpec:
|
||||
y_offset: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoiSpec:
|
||||
"""图像匹配感兴趣区域(ROI),相对窗口的比例矩形。
|
||||
|
||||
用于将 OpenCV 模板匹配限制在窗口的某个子区域,避免误匹配到
|
||||
窗口内其他视觉相似的 UI 元素。例如 input_box 只在聊天区右下角查找,
|
||||
即使模板图像有缺陷也不会匹配到窗口中部。
|
||||
|
||||
所有字段为相对窗口宽/高的比例(0.0~1.0),由 Actions 按当前窗口几何
|
||||
转换为绝对像素 ROI 传给 OpenCVBackend.find_template。
|
||||
|
||||
字段:
|
||||
x_ratio: ROI 左上角相对窗口左上角的 X 比例
|
||||
y_ratio: ROI 左上角相对窗口左上角的 Y 比例
|
||||
w_ratio: ROI 宽度相对窗口宽度的比例
|
||||
h_ratio: ROI 高度相对窗口高度的比例
|
||||
|
||||
边界由 Actions/opencv.py 做裁剪保护,YAML 中可略微宽松。
|
||||
"""
|
||||
|
||||
x_ratio: float = 0.0
|
||||
y_ratio: float = 0.0
|
||||
w_ratio: float = 1.0
|
||||
h_ratio: float = 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Selector:
|
||||
"""UI 元素选择器。
|
||||
@ -38,6 +65,8 @@ class Selector:
|
||||
kind:元素类别键(如 search_box / send_button / input_box)
|
||||
by_image:图像模板名(P2 启用),非空时优先图像匹配
|
||||
by_geom:几何规格,非空时图像失败或 P1 阶段走几何兜底
|
||||
roi:图像匹配感兴趣区域(可选)。非空时限制 OpenCV 搜索范围;
|
||||
为空时使用全窗口。用于防止模板误匹配到窗口其他相似元素。
|
||||
threshold:图像匹配置信度阈值(默认 0.80)
|
||||
require_image:高风险操作为 True 时,图像失败即拒绝执行
|
||||
description:人类可读描述
|
||||
@ -48,6 +77,7 @@ class Selector:
|
||||
kind: str = ""
|
||||
by_image: Optional[str] = None
|
||||
by_geom: Optional[GeomSpec] = None
|
||||
roi: Optional[RoiSpec] = None
|
||||
threshold: float = 0.80
|
||||
require_image: bool = False
|
||||
description: str = ""
|
||||
|
||||
@ -15,7 +15,7 @@ from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from woc_bridge.ui.locators.base import GeomSpec, Selector
|
||||
from woc_bridge.ui.locators.base import GeomSpec, RoiSpec, Selector
|
||||
|
||||
logger = logging.getLogger("woc-bridge")
|
||||
|
||||
@ -198,6 +198,7 @@ class LocatorRegistry:
|
||||
|
||||
若提供 target_resolution 与 profile_resolution,则对 by_geom 中的
|
||||
绝对偏移 x_offset / y_offset 按分辨率比例缩放(宽高比不变时坐标更接近目标)。
|
||||
roi 为相对窗口的比例矩形,与分辨率无关,不做缩放。
|
||||
|
||||
YAML 结构(每个 key 对应一个 Selector):
|
||||
search_box:
|
||||
@ -208,6 +209,11 @@ class LocatorRegistry:
|
||||
y_ratio: 0.05
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi: # dict,可省略(缺省=全窗口)
|
||||
x_ratio: 0.0
|
||||
y_ratio: 0.0
|
||||
w_ratio: 0.30
|
||||
h_ratio: 0.20
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 搜索框
|
||||
@ -240,10 +246,20 @@ class LocatorRegistry:
|
||||
x_offset=int(int(by_geom_raw.get("x_offset", 0)) * scale_x),
|
||||
y_offset=int(int(by_geom_raw.get("y_offset", 0)) * scale_y),
|
||||
)
|
||||
roi_raw = spec.get("roi")
|
||||
roi: Optional[RoiSpec] = None
|
||||
if isinstance(roi_raw, dict):
|
||||
roi = RoiSpec(
|
||||
x_ratio=float(roi_raw.get("x_ratio", 0.0)),
|
||||
y_ratio=float(roi_raw.get("y_ratio", 0.0)),
|
||||
w_ratio=float(roi_raw.get("w_ratio", 1.0)),
|
||||
h_ratio=float(roi_raw.get("h_ratio", 1.0)),
|
||||
)
|
||||
self.selectors[kind] = Selector(
|
||||
kind=kind,
|
||||
by_image=by_image,
|
||||
by_geom=by_geom,
|
||||
roi=roi,
|
||||
threshold=float(spec.get("threshold", 0.80)),
|
||||
require_image=bool(spec.get("require_image", False)),
|
||||
description=str(spec.get("description", "")),
|
||||
@ -266,6 +282,9 @@ class LocatorRegistry:
|
||||
|
||||
relative_to 字段仅作语义占位,实际坐标计算统一用 ratio + offset
|
||||
(offset 已含正负号区分方向,详见 Actions._geom_find)。
|
||||
|
||||
roi 字段同步与 YAML profile 保持一致,防止无 YAML 时退化为全窗口
|
||||
匹配(全窗口易误匹配到窗口其他相似 UI 元素,参见 input_box 误匹配事件)。
|
||||
"""
|
||||
defaults = {
|
||||
"session_manager_icon": Selector(
|
||||
@ -277,6 +296,9 @@ class LocatorRegistry:
|
||||
x_offset=0,
|
||||
y_offset=0,
|
||||
),
|
||||
roi=RoiSpec(
|
||||
x_ratio=0.0, y_ratio=0.0, w_ratio=0.10, h_ratio=0.25,
|
||||
),
|
||||
description="左侧导航栏顶部微信消息/会话管理图标",
|
||||
),
|
||||
"search_box": Selector(
|
||||
@ -288,6 +310,9 @@ class LocatorRegistry:
|
||||
x_offset=0,
|
||||
y_offset=0,
|
||||
),
|
||||
roi=RoiSpec(
|
||||
x_ratio=0.0, y_ratio=0.0, w_ratio=0.30, h_ratio=0.20,
|
||||
),
|
||||
description="左侧顶部搜索框",
|
||||
),
|
||||
"send_button": Selector(
|
||||
@ -299,6 +324,9 @@ class LocatorRegistry:
|
||||
x_offset=0,
|
||||
y_offset=0,
|
||||
),
|
||||
roi=RoiSpec(
|
||||
x_ratio=0.90, y_ratio=0.85, w_ratio=0.10, h_ratio=0.15,
|
||||
),
|
||||
description="右下角发送按钮",
|
||||
),
|
||||
"input_box": Selector(
|
||||
@ -310,6 +338,9 @@ class LocatorRegistry:
|
||||
x_offset=0,
|
||||
y_offset=0,
|
||||
),
|
||||
roi=RoiSpec(
|
||||
x_ratio=0.55, y_ratio=0.78, w_ratio=0.45, h_ratio=0.22,
|
||||
),
|
||||
description="聊天输入框",
|
||||
),
|
||||
"input_box_empty": Selector(
|
||||
@ -321,6 +352,9 @@ class LocatorRegistry:
|
||||
x_offset=0,
|
||||
y_offset=0,
|
||||
),
|
||||
roi=RoiSpec(
|
||||
x_ratio=0.55, y_ratio=0.78, w_ratio=0.45, h_ratio=0.22,
|
||||
),
|
||||
description="空输入框灰色提示态(发送成功后校验)",
|
||||
),
|
||||
"search_result_first": Selector(
|
||||
@ -332,6 +366,9 @@ class LocatorRegistry:
|
||||
x_offset=0,
|
||||
y_offset=0,
|
||||
),
|
||||
roi=RoiSpec(
|
||||
x_ratio=0.0, y_ratio=0.10, w_ratio=0.30, h_ratio=0.40,
|
||||
),
|
||||
description="搜索结果第一项",
|
||||
),
|
||||
"main_view": Selector(
|
||||
@ -343,6 +380,7 @@ class LocatorRegistry:
|
||||
x_offset=0,
|
||||
y_offset=0,
|
||||
),
|
||||
# main_view 为大区域校验,无需 ROI 限制
|
||||
description="主聊天视图(post_verify 校验用)",
|
||||
),
|
||||
}
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
"""L5 FlowOrchestrator:编排幂等/熔断/会话缓存/队列/Flow。
|
||||
"""L5 FlowOrchestrator:编排幂等/会话缓存/队列/Flow。
|
||||
|
||||
send_text 流程:
|
||||
1. 幂等检查(命中返回 skipped=true)
|
||||
2. DB 校验熔断检查(OPEN 时返回 BRIDGE_CIRCUITED)
|
||||
3. 入队(自适应 delay_ms:同联系人 1000 / 不同 config.send_delay_ms)
|
||||
2. 入队(自适应 delay_ms:同联系人 1000 / 不同 config.send_delay_ms)
|
||||
3. 执行 SendTextFlow(内部用 SSEVerifyBus 校验)
|
||||
4. 成功且 verified 才写幂等缓存
|
||||
5. DB 校验失败计数 + 熔断
|
||||
5. verified=False 不阻塞发送、不熔断(仅记 warning,调用方按需重试)
|
||||
6. 失败时 session_cache.invalidate(to_wxid)(仅失效当前联系人)
|
||||
|
||||
熔断器已移除:SSE 校验超时不会导致雪崩,单次 verify 失败不应阻塞全局发送。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -22,8 +24,7 @@ from woc_bridge.models import BridgeError
|
||||
if TYPE_CHECKING:
|
||||
from woc_bridge.ui.flows.send_file import SendFileFlow
|
||||
from woc_bridge.ui.flows.send_text import SendTextFlow
|
||||
from woc_bridge.ui.circuit_breaker import CircuitBreaker, CircuitState
|
||||
from woc_bridge.ui.errors import FlowError, is_retryable
|
||||
from woc_bridge.ui.errors import FlowError
|
||||
from woc_bridge.ui.flow import FlowContext, FlowResult, FlowState
|
||||
from woc_bridge.ui.idem_cache import IdemCache
|
||||
|
||||
@ -82,7 +83,7 @@ class SessionCache:
|
||||
class FlowOrchestrator:
|
||||
"""L5 Flow 编排器。
|
||||
|
||||
整合幂等缓存、熔断器、会话缓存、发送队列、Flow 执行。
|
||||
整合幂等缓存、会话缓存、发送队列、Flow 执行、SSE 发送结果校验。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@ -94,6 +95,7 @@ class FlowOrchestrator:
|
||||
config,
|
||||
send_text_flow: Optional[SendTextFlow] = None,
|
||||
send_file_flow: Optional[SendFileFlow] = None,
|
||||
verify_bus=None,
|
||||
) -> None:
|
||||
"""初始化编排器。
|
||||
|
||||
@ -106,6 +108,8 @@ class FlowOrchestrator:
|
||||
send_text_flow: 可选的 SendTextFlow 实例(用于测试注入)
|
||||
send_file_flow: 可选的 SendFileFlow 实例(必须由 app._init_state 注入,
|
||||
因 SendFileFlow 依赖 XdotoolDriver,orchestrator 无法惰性创建)
|
||||
verify_bus: SSE 发送结果校验总线(SSEVerifyBus 实例)。
|
||||
惰性创建 SendTextFlow 时注入,用于 SSE 推送校验。
|
||||
"""
|
||||
self.send_queue = send_queue
|
||||
self.idem_cache = idem_cache
|
||||
@ -114,12 +118,9 @@ class FlowOrchestrator:
|
||||
self.config = config
|
||||
# LRU 多会话缓存:最多 16 个会话,30s TTL
|
||||
self.session_cache = SessionCache(ttl=30.0, max_size=16)
|
||||
# DB 校验熔断器:10 次失败 / 30s 恢复(高并发下更宽容)
|
||||
self.db_verify_breaker = CircuitBreaker(
|
||||
"db_verify", failure_threshold=10, recovery_timeout=30.0
|
||||
)
|
||||
self._send_text_flow = send_text_flow
|
||||
self._send_file_flow = send_file_flow
|
||||
self._verify_bus = verify_bus
|
||||
|
||||
def _get_send_text_flow(self):
|
||||
"""获取 SendTextFlow 实例(惰性创建或使用注入的)。"""
|
||||
@ -131,6 +132,7 @@ class FlowOrchestrator:
|
||||
actions=self.actions,
|
||||
db_reader=self.db_reader,
|
||||
session_cache=self.session_cache,
|
||||
verify_bus=self._verify_bus,
|
||||
)
|
||||
|
||||
def _get_send_file_flow(self):
|
||||
@ -158,10 +160,10 @@ class FlowOrchestrator:
|
||||
|
||||
流程:
|
||||
1. 幂等检查
|
||||
2. DB 校验熔断检查
|
||||
3. 自适应延时入队
|
||||
4. 执行 SendTextFlow
|
||||
5. 成功且 verified 写幂等缓存
|
||||
2. 自适应延时入队
|
||||
3. 执行 SendTextFlow(内部用 SSEVerifyBus 校验)
|
||||
4. 成功且 verified 写幂等缓存
|
||||
5. verified=False 不阻塞、不熔断(调用方按需重试)
|
||||
6. 失败时失效会话缓存
|
||||
|
||||
Args:
|
||||
@ -206,21 +208,7 @@ class FlowOrchestrator:
|
||||
duration_ms=(time.monotonic() - started_at) * 1000,
|
||||
)
|
||||
|
||||
# 2. DB 校验熔断检查
|
||||
if not self.db_verify_breaker.allow():
|
||||
logger.warning(
|
||||
"[orchestrator] db_verify breaker OPEN, reject send (to_wxid=%s)",
|
||||
to_wxid,
|
||||
)
|
||||
return FlowResult(
|
||||
success=False,
|
||||
error="db_verify circuit breaker open",
|
||||
error_code="BRIDGE_CIRCUITED",
|
||||
state=FlowState.FAILED,
|
||||
duration_ms=(time.monotonic() - started_at) * 1000,
|
||||
)
|
||||
|
||||
# 3. 自适应延时:同联系人用短延时
|
||||
# 2. 自适应延时:同联系人用短延时
|
||||
is_same_contact = self.session_cache.get(to_wxid)
|
||||
delay_ms = _SAME_CONTACT_DELAY_MS if is_same_contact else None
|
||||
# None 表示用 send_queue 的默认 send_delay_ms
|
||||
@ -229,7 +217,7 @@ class FlowOrchestrator:
|
||||
is_same_contact, delay_ms, to_wxid,
|
||||
)
|
||||
|
||||
# 4. 构造 FlowContext
|
||||
# 3. 构造 FlowContext
|
||||
ctx = FlowContext(
|
||||
to_wxid=to_wxid,
|
||||
content=content,
|
||||
@ -237,7 +225,7 @@ class FlowOrchestrator:
|
||||
client_request_id=client_request_id,
|
||||
)
|
||||
|
||||
# 5. 入队执行(带队列等待超时,避免无界排队)
|
||||
# 4. 入队执行(带队列等待超时,避免无界排队)
|
||||
flow = self._get_send_text_flow()
|
||||
try:
|
||||
result: FlowResult = await self.send_queue.enqueue(
|
||||
@ -270,24 +258,23 @@ class FlowOrchestrator:
|
||||
details=exc_details,
|
||||
)
|
||||
|
||||
# 6. 结果处理
|
||||
# 5. 结果处理
|
||||
if result.success and result.verified:
|
||||
# 成功且 verified:写幂等缓存
|
||||
self.idem_cache.set(
|
||||
"send_text", to_wxid, content, result, client_request_id
|
||||
)
|
||||
self.db_verify_breaker.record_success()
|
||||
logger.info(
|
||||
"[orchestrator] send_text DONE ✓ to_wxid=%s local_id=%s verified=%s (%.0fms)",
|
||||
to_wxid, result.local_id, result.verified,
|
||||
(time.monotonic() - started_at) * 1000,
|
||||
)
|
||||
elif result.success and not result.verified:
|
||||
# 发送成功但校验失败:DB 校验熔断计数
|
||||
self.db_verify_breaker.record_failure()
|
||||
# 发送成功但 SSE 校验未匹配(超时/消息未到/发错会话)
|
||||
# 不熔断、不阻塞:调用方按需重试或通过 SSE 自行确认
|
||||
logger.warning(
|
||||
"[orchestrator] send success but verify failed, breaker fail_count+1 (to_wxid=%s)",
|
||||
to_wxid,
|
||||
"[orchestrator] send success but verify unmatched (to_wxid=%s, local_id=%s)",
|
||||
to_wxid, result.local_id,
|
||||
)
|
||||
else:
|
||||
# 发送失败:仅失效当前联系人会话缓存,避免误伤其它缓存命中
|
||||
@ -307,15 +294,14 @@ class FlowOrchestrator:
|
||||
client_request_id: Optional[str] = None,
|
||||
display_name: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""文件发送 Flow 编排:幂等检查 + 熔断 + 入队 + DB 校验。
|
||||
"""文件发送 Flow 编排:幂等检查 + 入队 + 校验。
|
||||
|
||||
流程:
|
||||
1. 幂等检查(命中返回 skipped=True)
|
||||
2. DB 校验熔断检查(OPEN 时返回 BRIDGE_CIRCUITED)
|
||||
3. 入队执行 SendFileFlow.execute(路径白名单 + 文件选择器输入 + post_verify)
|
||||
4. 成功且 verified 写幂等缓存
|
||||
5. DB 校验失败计数 + 熔断
|
||||
6. 失败时失效会话缓存
|
||||
2. 入队执行 SendFileFlow.execute(路径白名单 + 文件选择器输入 + post_verify)
|
||||
3. 成功且 verified 写幂等缓存
|
||||
4. verified=False 不熔断、不阻塞(仅记 warning)
|
||||
5. 失败时失效会话缓存
|
||||
|
||||
幂等缓存键:(send_file, to_wxid, file_path, client_request_id)
|
||||
(IdemCache API 仅支持 4 段 key,file_path 作为 content 槽位传入)
|
||||
@ -366,23 +352,10 @@ class FlowOrchestrator:
|
||||
"skipped": True, "error": None, "error_code": None,
|
||||
}
|
||||
|
||||
# 2. DB 校验熔断检查
|
||||
if not self.db_verify_breaker.allow():
|
||||
logger.warning(
|
||||
"[orchestrator] send_file: db_verify breaker OPEN, reject (to_wxid=%s)",
|
||||
to_wxid,
|
||||
)
|
||||
return {
|
||||
"success": False, "verified": False, "placeholder": False,
|
||||
"skipped": False,
|
||||
"error": "db_verify circuit breaker open",
|
||||
"error_code": "BRIDGE_CIRCUITED",
|
||||
}
|
||||
|
||||
# 3. 获取 SendFileFlow 实例(未注入时抛 BridgeError)
|
||||
# 2. 获取 SendFileFlow 实例(未注入时抛 BridgeError)
|
||||
flow = self._get_send_file_flow()
|
||||
|
||||
# 4. 入队执行(SendFileFlow.execute 内部完成路径校验 + 文件选择器输入 + post_verify)
|
||||
# 3. 入队执行(SendFileFlow.execute 内部完成路径校验 + 文件选择器输入 + post_verify)
|
||||
logger.info(
|
||||
"[orchestrator] send_file enqueue (pending=%d)",
|
||||
self.send_queue.pending_count(),
|
||||
@ -420,18 +393,16 @@ class FlowOrchestrator:
|
||||
"error_code": "SEND_FAILED",
|
||||
}
|
||||
|
||||
# 5. 结果处理
|
||||
# 4. 结果处理
|
||||
# SendFileFlow.execute 返回 {"success": bool, "verified": Optional[bool], "error": Optional[str]}
|
||||
# verified=None 表示无法校验(DB 不可用或无基线),不计熔断失败
|
||||
success = result.get("success", False)
|
||||
verified = result.get("verified", False)
|
||||
|
||||
if success and verified:
|
||||
# 成功且 verified:写幂等缓存 + 熔断成功
|
||||
# 成功且 verified:写幂等缓存
|
||||
self.idem_cache.set(
|
||||
"send_file", to_wxid, file_path, result, idem_id
|
||||
)
|
||||
self.db_verify_breaker.record_success()
|
||||
# 更新会话缓存(send_file 打开了 to_wxid 会话)
|
||||
self.session_cache.set(to_wxid)
|
||||
logger.info(
|
||||
@ -440,20 +411,17 @@ class FlowOrchestrator:
|
||||
(time.monotonic() - started_at) * 1000,
|
||||
)
|
||||
elif success and verified is False:
|
||||
# 发送成功但校验失败(真正的超时/未匹配):DB 校验熔断计数
|
||||
self.db_verify_breaker.record_failure()
|
||||
# 发送成功但校验失败:不熔断、不阻塞
|
||||
self.session_cache.set(to_wxid)
|
||||
logger.warning(
|
||||
"[orchestrator] send_file success but verify failed, breaker fail_count+1 (to_wxid=%s)",
|
||||
"[orchestrator] send_file success but verify unmatched (to_wxid=%s)",
|
||||
to_wxid,
|
||||
)
|
||||
elif success and verified is None:
|
||||
# P0 修复:发送成功但无法校验(DB 不可用或无基线):
|
||||
# 不计熔断失败,避免 DB 不可用时熔断器雪崩阻塞全部发送。
|
||||
# 也不写幂等缓存(未校验的结果不应被复用)。
|
||||
# 发送成功但无法校验:不写幂等缓存,不熔断
|
||||
self.session_cache.set(to_wxid)
|
||||
logger.info(
|
||||
"[orchestrator] send_file success but verify skipped (DB unavailable), breaker untouched (to_wxid=%s)",
|
||||
"[orchestrator] send_file success but verify skipped (to_wxid=%s)",
|
||||
to_wxid,
|
||||
)
|
||||
else:
|
||||
|
||||
@ -10,8 +10,9 @@
|
||||
search_box:
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.092
|
||||
y_ratio: 0.095
|
||||
# 与 1435x876 共用比例标定:搜索框中心位于左上角约 (0.080,0.052)
|
||||
x_ratio: 0.080
|
||||
y_ratio: 0.052
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
|
||||
@ -4,17 +4,43 @@
|
||||
# 几何兜底按 1280x720 比例计算,与 1920x1080 相同 ratio;
|
||||
# offset 按 2/3 缩放(1280/1920 = 720/1080 = 2/3)。
|
||||
# 图像模板与 1920x1080 共用(多尺度匹配自适应)。
|
||||
# roi 为相对窗口的比例矩形,与分辨率无关,与 1435x876/1920x1080 共用相同比例值。
|
||||
#
|
||||
# 字段说明:参考 wechat_4.0_1920x1080.yaml 顶部注释。
|
||||
# 字段说明:参考 wechat_4.0_1435x876.yaml 顶部注释。
|
||||
|
||||
# 会话管理图标:左侧导航栏最上方(头像下方第一个)微信消息图标。
|
||||
# 每次发送前点击它,强制回到会话列表首页,清除搜索覆盖层/弹窗/非会话页面。
|
||||
session_manager_icon:
|
||||
by_image: session_manager_icon.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.018
|
||||
y_ratio: 0.109
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.0
|
||||
y_ratio: 0.0
|
||||
w_ratio: 0.10
|
||||
h_ratio: 0.25
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 左侧导航栏顶部微信消息/会话管理图标
|
||||
|
||||
search_box:
|
||||
by_image: search_box.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.092
|
||||
y_ratio: 0.095
|
||||
# 与 1435x876 共用比例标定:搜索框中心位于左上角约 (0.080,0.052)
|
||||
x_ratio: 0.080
|
||||
y_ratio: 0.052
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.0
|
||||
y_ratio: 0.0
|
||||
w_ratio: 0.30
|
||||
h_ratio: 0.12
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 左侧顶部搜索框
|
||||
@ -27,6 +53,11 @@ send_button:
|
||||
y_ratio: 0.929
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.90
|
||||
y_ratio: 0.85
|
||||
w_ratio: 0.10
|
||||
h_ratio: 0.15
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 右下角发送按钮
|
||||
@ -39,6 +70,11 @@ input_box:
|
||||
y_ratio: 0.914
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.55
|
||||
y_ratio: 0.78
|
||||
w_ratio: 0.45
|
||||
h_ratio: 0.22
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 聊天输入框
|
||||
@ -51,6 +87,11 @@ input_box_empty:
|
||||
y_ratio: 0.914
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.55
|
||||
y_ratio: 0.78
|
||||
w_ratio: 0.45
|
||||
h_ratio: 0.22
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 空输入框灰色提示态(发送成功后校验用)
|
||||
@ -63,6 +104,11 @@ search_result_first:
|
||||
y_ratio: 0.174
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.0
|
||||
y_ratio: 0.10
|
||||
w_ratio: 0.30
|
||||
h_ratio: 0.40
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 搜索结果第一项
|
||||
@ -76,7 +122,7 @@ main_view:
|
||||
y_offset: 0
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: 主聊天视图(post_verify 校验用,模板匹配不稳定,使用几何兜底)
|
||||
description: 主聊天视图(post_verify 校验用,模板匹配不稳定,使用几何兜底;大区域无需 ROI 限制)
|
||||
|
||||
# 好友申请通过 UI 元素定位(experimental,坐标为估算值需实测校准)
|
||||
contact_icon:
|
||||
@ -87,6 +133,11 @@ contact_icon:
|
||||
y_ratio: 0.171
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.0
|
||||
y_ratio: 0.05
|
||||
w_ratio: 0.10
|
||||
h_ratio: 0.30
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 左侧导航栏通讯录图标
|
||||
@ -99,6 +150,11 @@ new_friend_entry:
|
||||
y_ratio: 0.138
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.05
|
||||
y_ratio: 0.10
|
||||
w_ratio: 0.25
|
||||
h_ratio: 0.20
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 通讯录页"新的朋友"入口
|
||||
@ -110,6 +166,11 @@ friend_request_item:
|
||||
y_ratio: 0.178
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.05
|
||||
y_ratio: 0.15
|
||||
w_ratio: 0.30
|
||||
h_ratio: 0.30
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: '"新的朋友"列表第一条申请项(模板匹配不稳定,使用几何兜底)'
|
||||
@ -122,6 +183,11 @@ verify_button:
|
||||
y_ratio: 0.394
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.40
|
||||
y_ratio: 0.20
|
||||
w_ratio: 0.40
|
||||
h_ratio: 0.40
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: 好友详情页"前往验证"按钮(OpenCV 实测标定)
|
||||
@ -133,6 +199,11 @@ confirm_button:
|
||||
y_ratio: 0.830
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.30
|
||||
y_ratio: 0.65
|
||||
w_ratio: 0.40
|
||||
h_ratio: 0.30
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: '"通过朋友验证"弹窗绿色确定按钮(OpenCV 弹窗出现时实测 666/1435, 727/876)'
|
||||
|
||||
@ -10,6 +10,12 @@
|
||||
# relative_to: window / window_bottom_right(当前统一按 window 处理)
|
||||
# x_ratio / y_ratio: 相对窗口宽高的比例位置
|
||||
# x_offset / y_offset: 绝对像素偏移(含正负号)
|
||||
# roi: 图像匹配感兴趣区域(可选,缺省=全窗口)
|
||||
# 将 OpenCV 模板匹配限制在窗口的某个子区域,避免误匹配到窗口其他
|
||||
# 视觉相似的 UI 元素。所有字段为相对窗口宽/高的比例(0.0~1.0),
|
||||
# 与分辨率无关,3 个 profile 共用相同比例值。
|
||||
# x_ratio / y_ratio: ROI 左上角相对窗口左上角的比例
|
||||
# w_ratio / h_ratio: ROI 宽/高相对窗口宽/高的比例
|
||||
# threshold: 图像匹配置信度阈值
|
||||
# require_image: true 时图像失败拒绝执行(高风险元素)
|
||||
# description: 元素描述
|
||||
@ -24,6 +30,11 @@ session_manager_icon:
|
||||
y_ratio: 0.109
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.0
|
||||
y_ratio: 0.0
|
||||
w_ratio: 0.10
|
||||
h_ratio: 0.25
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 左侧导航栏顶部微信消息/会话管理图标
|
||||
@ -32,10 +43,16 @@ search_box:
|
||||
by_image: search_box.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.092
|
||||
y_ratio: 0.095
|
||||
# 基于 1435x876 实测截图标定:搜索框中心约 (115,45)
|
||||
x_ratio: 0.080
|
||||
y_ratio: 0.052
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.0
|
||||
y_ratio: 0.0
|
||||
w_ratio: 0.30
|
||||
h_ratio: 0.12
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 左侧顶部搜索框
|
||||
@ -48,6 +65,11 @@ send_button:
|
||||
y_ratio: 0.929
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.90
|
||||
y_ratio: 0.85
|
||||
w_ratio: 0.10
|
||||
h_ratio: 0.15
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 右下角发送按钮
|
||||
@ -60,6 +82,11 @@ input_box:
|
||||
y_ratio: 0.914
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.55
|
||||
y_ratio: 0.78
|
||||
w_ratio: 0.45
|
||||
h_ratio: 0.22
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 聊天输入框
|
||||
@ -72,6 +99,11 @@ input_box_empty:
|
||||
y_ratio: 0.914
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.55
|
||||
y_ratio: 0.78
|
||||
w_ratio: 0.45
|
||||
h_ratio: 0.22
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 空输入框灰色提示态(发送成功后校验用)
|
||||
@ -84,6 +116,11 @@ search_result_first:
|
||||
y_ratio: 0.174
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.0
|
||||
y_ratio: 0.10
|
||||
w_ratio: 0.30
|
||||
h_ratio: 0.40
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 搜索结果第一项
|
||||
@ -98,7 +135,7 @@ main_view:
|
||||
y_offset: 0
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: 主聊天视图(post_verify 校验用,阈值略低因区域大)
|
||||
description: 主聊天视图(post_verify 校验用,阈值略低因区域大;大区域无需 ROI 限制)
|
||||
|
||||
# 好友申请通过 UI 元素定位(基于 1435x876 实测标定)
|
||||
contact_icon:
|
||||
@ -109,6 +146,11 @@ contact_icon:
|
||||
y_ratio: 0.171
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.0
|
||||
y_ratio: 0.05
|
||||
w_ratio: 0.10
|
||||
h_ratio: 0.30
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 左侧导航栏通讯录图标
|
||||
@ -121,6 +163,11 @@ new_friend_entry:
|
||||
y_ratio: 0.138
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.05
|
||||
y_ratio: 0.10
|
||||
w_ratio: 0.25
|
||||
h_ratio: 0.20
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 通讯录页"新的朋友"入口
|
||||
@ -133,6 +180,11 @@ friend_request_item:
|
||||
y_ratio: 0.178
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.05
|
||||
y_ratio: 0.15
|
||||
w_ratio: 0.30
|
||||
h_ratio: 0.30
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: '"新的朋友"列表第一条申请项'
|
||||
@ -145,6 +197,11 @@ verify_button:
|
||||
y_ratio: 0.394
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.40
|
||||
y_ratio: 0.20
|
||||
w_ratio: 0.40
|
||||
h_ratio: 0.40
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: 好友详情页"前往验证"按钮(OpenCV 实测标定)
|
||||
@ -156,6 +213,11 @@ confirm_button:
|
||||
y_ratio: 0.830
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.30
|
||||
y_ratio: 0.65
|
||||
w_ratio: 0.40
|
||||
h_ratio: 0.30
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: '"通过朋友验证"弹窗绿色确定按钮(OpenCV 弹窗出现时实测 666/1435, 727/876)'
|
||||
|
||||
@ -2,25 +2,43 @@
|
||||
#
|
||||
# 适用场景:微信窗口在 1920x1080 桌面环境下运行。
|
||||
# 图像模板位于 templates/wechat_4.0/light/,几何兜底基于 1920x1080 比例计算。
|
||||
# roi 为相对窗口的比例矩形,与分辨率无关,与 1435x876/1280x720 共用相同比例值。
|
||||
#
|
||||
# 字段说明:
|
||||
# by_image: 模板文件名(相对 templates/wechat_4.0/light/)
|
||||
# by_geom: 几何兜底规格(图像匹配失败或熔断时使用)
|
||||
# relative_to: 语义占位(window / window_bottom_right)
|
||||
# x_ratio / y_ratio: 相对窗口宽高的比例位置
|
||||
# x_offset / y_offset: 绝对像素偏移(含正负号)
|
||||
# threshold: 图像匹配置信度阈值
|
||||
# require_image: true 时图像失败拒绝执行(高风险元素)
|
||||
# description: 元素描述
|
||||
# 字段说明:参考 wechat_4.0_1435x876.yaml 顶部注释。
|
||||
|
||||
# 会话管理图标:左侧导航栏最上方(头像下方第一个)微信消息图标。
|
||||
# 每次发送前点击它,强制回到会话列表首页,清除搜索覆盖层/弹窗/非会话页面。
|
||||
session_manager_icon:
|
||||
by_image: session_manager_icon.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.018
|
||||
y_ratio: 0.109
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.0
|
||||
y_ratio: 0.0
|
||||
w_ratio: 0.10
|
||||
h_ratio: 0.25
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 左侧导航栏顶部微信消息/会话管理图标
|
||||
|
||||
search_box:
|
||||
by_image: search_box.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.092
|
||||
y_ratio: 0.095
|
||||
# 与 1435x876 共用比例标定:搜索框中心位于左上角约 (0.080,0.052)
|
||||
x_ratio: 0.080
|
||||
y_ratio: 0.052
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.0
|
||||
y_ratio: 0.0
|
||||
w_ratio: 0.30
|
||||
h_ratio: 0.12
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 左侧顶部搜索框
|
||||
@ -33,6 +51,11 @@ send_button:
|
||||
y_ratio: 0.929
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.90
|
||||
y_ratio: 0.85
|
||||
w_ratio: 0.10
|
||||
h_ratio: 0.15
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 右下角发送按钮
|
||||
@ -45,6 +68,11 @@ input_box:
|
||||
y_ratio: 0.914
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.55
|
||||
y_ratio: 0.78
|
||||
w_ratio: 0.45
|
||||
h_ratio: 0.22
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 聊天输入框
|
||||
@ -57,6 +85,11 @@ input_box_empty:
|
||||
y_ratio: 0.914
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.55
|
||||
y_ratio: 0.78
|
||||
w_ratio: 0.45
|
||||
h_ratio: 0.22
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 空输入框灰色提示态(发送成功后校验用)
|
||||
@ -69,6 +102,11 @@ search_result_first:
|
||||
y_ratio: 0.174
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.0
|
||||
y_ratio: 0.10
|
||||
w_ratio: 0.30
|
||||
h_ratio: 0.40
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 搜索结果第一项
|
||||
@ -82,7 +120,7 @@ main_view:
|
||||
y_offset: 0
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: 主聊天视图(post_verify 校验用,模板匹配不稳定,使用几何兜底)
|
||||
description: 主聊天视图(post_verify 校验用,模板匹配不稳定,使用几何兜底;大区域无需 ROI 限制)
|
||||
|
||||
# 好友申请通过 UI 元素定位(experimental,坐标为估算值需实测校准)
|
||||
contact_icon:
|
||||
@ -93,6 +131,11 @@ contact_icon:
|
||||
y_ratio: 0.171
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.0
|
||||
y_ratio: 0.05
|
||||
w_ratio: 0.10
|
||||
h_ratio: 0.30
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 左侧导航栏通讯录图标
|
||||
@ -105,6 +148,11 @@ new_friend_entry:
|
||||
y_ratio: 0.138
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.05
|
||||
y_ratio: 0.10
|
||||
w_ratio: 0.25
|
||||
h_ratio: 0.20
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 通讯录页"新的朋友"入口
|
||||
@ -116,6 +164,11 @@ friend_request_item:
|
||||
y_ratio: 0.178
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.05
|
||||
y_ratio: 0.15
|
||||
w_ratio: 0.30
|
||||
h_ratio: 0.30
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: '"新的朋友"列表第一条申请项(模板匹配不稳定,使用几何兜底)'
|
||||
@ -128,6 +181,11 @@ verify_button:
|
||||
y_ratio: 0.394
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.40
|
||||
y_ratio: 0.20
|
||||
w_ratio: 0.40
|
||||
h_ratio: 0.40
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: 好友详情页"前往验证"按钮(OpenCV 实测标定)
|
||||
@ -139,6 +197,11 @@ confirm_button:
|
||||
y_ratio: 0.830
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
roi:
|
||||
x_ratio: 0.30
|
||||
y_ratio: 0.65
|
||||
w_ratio: 0.40
|
||||
h_ratio: 0.30
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: '"通过朋友验证"弹窗绿色确定按钮(OpenCV 弹窗出现时实测 666/1435, 727/876)'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user