diff --git a/.gitignore b/.gitignore index 70b9819..a560beb 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,7 @@ __pycache__/ *.so *.egg-info/ *.egg + +.trae/ +trae/ + diff --git a/bridge/s6/woc-bridge/run b/bridge/s6/woc-bridge/run index d73004e..86fb619 100644 --- a/bridge/s6/woc-bridge/run +++ b/bridge/s6/woc-bridge/run @@ -1,22 +1,65 @@ #!/usr/bin/with-contenv bash -# 等待微信窗口出现,最多等 5 分钟;超时也启动 bridge(/api/status 会返回 not_running) +# 等待微信主界面登录完成(检测到足够大的主窗口),避免未登录时 bridge +# 狂刷日志/任务失败。超时 10 分钟仍未登录则放弃启动。 WAITED=0 -MAX_WAIT=300 -while ! xdotool search --name "微信" >/dev/null 2>&1; do - sleep 2 - WAITED=$((WAITED + 2)) - if [ "$WAITED" -ge "$MAX_WAIT" ]; then - echo "woc-bridge: 等待微信窗口超时(${WAITED}s),仍然启动 bridge" >&2 +MAX_WAIT=600 +while true; do + # 通过 wechat 进程 PID 查找窗口,过滤出面积足够大的主窗口 + wid="" + area=0 + pid=$(pgrep -f "/config/wechat/opt/wechat/wechat" | head -1) + if [ -n "$pid" ]; then + for candidate in $(xdotool search --pid "$pid" 2>/dev/null); do + info=$(xdotool getwindowgeometry --shell "$candidate" 2>/dev/null) + if [ -n "$info" ]; then + eval "$info" + a=$((WIDTH * HEIGHT)) + if [ "$a" -gt "$area" ]; then + area=$a + wid=$candidate + fi + fi + done + fi + + # 登录后主窗口面积通常 > 300000;登录窗口/扫码窗口面积较小 + if [ "$area" -ge 300000 ]; then + echo "woc-bridge: 检测到微信已登录主窗口(area=${area}),等待 60s 让微信完全就绪后再启动 bridge" >&2 + sleep 60 + echo "woc-bridge: 等待结束,启动 bridge" >&2 break fi + + sleep 3 + WAITED=$((WAITED + 3)) + if [ "$WAITED" -ge "$MAX_WAIT" ]; then + echo "woc-bridge: 等待微信登录超时(${WAITED}s),暂不启动 bridge" >&2 + # s6 服务退出码 0 会被视为正常结束并自动重启;用 sleep + 退出 0 + # 让服务进入休眠,用户登录后可通过重启容器或手动 s6-svc -u 唤醒 + sleep infinity + exit 0 + fi done # 以 abc 用户身份运行(与微信同 X 会话,才能操作微信窗口) # s6-setuidgid 不接受 VAR=value 参数,须先用 s6-env 设环境变量再切用户 # 校验 ptrace 权限(key_extractor 读微信进程内存需要) -if [ -w /proc/sys/kernel/yama/ptrace_scope ] 2>/dev/null; then - echo 0 > /proc/sys/kernel/yama/ptrace_scope 2>/dev/null || true +PTRACE_SCOPE="/proc/sys/kernel/yama/ptrace_scope" +if [ -e "$PTRACE_SCOPE" ]; then + if echo 0 > "$PTRACE_SCOPE" 2>/dev/null; then + echo "woc-bridge: ptrace_scope 已设置为 0" >&2 + else + echo "woc-bridge: 无法写入 ptrace_scope(可能需要 --privileged 或 SYS_PTRACE capability)" >&2 + fi +else + # 通过 CapEff 检查是否持有 SYS_PTRACE (bit 12) + CAP_EFF=$(awk '/^CapEff:/{print $2}' /proc/self/status 2>/dev/null) + if [ -n "$CAP_EFF" ] && [ $((0x${CAP_EFF} & 0x1000)) -ne 0 ]; then + echo "woc-bridge: 宿主机未启用 YAMA,但容器已持有 SYS_PTRACE,key 提取仍可尝试" >&2 + else + echo "woc-bridge: 宿主机未启用 YAMA 且容器未持有 SYS_PTRACE,自动 key 提取可能不可用" >&2 + fi fi exec s6-env DISPLAY=${DISPLAY:-:1} XAUTHORITY=/config/.Xauthority \ diff --git a/bridge/woc_bridge/app.py b/bridge/woc_bridge/app.py index 3b0efe0..c505842 100644 --- a/bridge/woc_bridge/app.py +++ b/bridge/woc_bridge/app.py @@ -81,6 +81,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: await _state.send_queue.start() if _state.message_streamer is not None: await _state.message_streamer.start() + # 异步预热 display_name 缓存,降低高并发首批请求的 DB 压力 + try: + from woc_bridge.routes.send import warm_display_name_cache + asyncio.create_task(warm_display_name_cache(_state.db_reader)) + except Exception as e: + logger.warning("[lifespan] warm_display_name_cache 启动失败: %s", e) # P3:启动 HA 模块(watchdog + resource_reaper) if _state.watchdog is not None: try: @@ -103,6 +109,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: logger.warning("[lifespan] 启动清场 3 次失败,可能需要人工 VNC 接入") except Exception as e: logger.warning("[lifespan] 启动清场异常(不阻塞启动): %s", e) + # 启动登录守卫(在 friend_watcher 之前,提供登录状态检测基础) + if _state.login_guard is not None: + try: + await _state.login_guard.start() + except Exception as e: + logger.warning("[lifespan] login_guard.start() 失败: %s", e) # 启动好友申请监听器(仅当 auto_accept 开启时) # 放在 message_streamer 之后,保持"基础设施先于业务"顺序 if _state.friend_watcher is not None and _state.config.auto_accept_enabled: @@ -116,7 +128,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: try: yield finally: - # 先停 friend_watcher(业务),再停基础设施 + # 先停 friend_watcher(业务),再停 login_guard,最后停基础设施 if _state.friend_watcher is not None: try: await asyncio.wait_for( @@ -125,6 +137,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: except Exception: # Exception 已涵盖 TimeoutError,无需单独列 logger.warning("[lifespan] friend_watcher.stop() 超时或异常,强制继续") + # 停止登录守卫(friend_watcher 之后,LIFO) + if _state.login_guard is not None: + try: + await asyncio.wait_for(_state.login_guard.stop(), timeout=5.0) + except (asyncio.TimeoutError, Exception): + logger.warning("[lifespan] login_guard.stop() 超时或异常,强制继续") # 停止消息流推送器(带超时,防止 worker 卡住阻塞关闭) if _state.message_streamer is not None: try: @@ -359,6 +377,7 @@ def _init_state(cfg: BridgeConfig) -> None: _state.send_queue = SendQueue( send_delay_ms=cfg.send_delay_ms, max_calls_per_sec=cfg.max_calls_per_sec, + max_queue_size=cfg.max_queue_size, ) # 消息流推送器:内部轮询 DB + SSE 广播(lifespan 中 start/stop) @@ -376,7 +395,9 @@ def _init_state(cfg: BridgeConfig) -> None: _state.db_reader.set_default_key(cfg.db_key) logger.info("从 WOC_DB_KEY 环境变量加载默认 DB 密钥(未验证,首次查询时验证)") - # P3:UI 自动化新架构组件(仅 ui_backend != "legacy" 时构造) + # P3:UI 自动化新架构组件(ui_backend != "legacy" 时构造完整能力; + # legacy 模式仍构造最小 LocatorRegistry + Actions,使 ContactDriver 能使用 + # YAML profile 的几何兜底,改善好友申请通过等硬编码坐标准确性)。 if cfg.ui_backend != "legacy": try: from woc_bridge.ui.backends import XdotoolBackend, OpenCVBackend @@ -411,12 +432,16 @@ def _init_state(cfg: BridgeConfig) -> None: _state.actions = Actions(backend=_state.xdotool_backend, locator=_state.locator) - # P0 修复:将 actions 注入 ContactDriver,启用 OpenCV 优先策略。 - # 门面构造 ContactDriver 时未传 actions(因 actions 在此之后才构造), - # 导致 _click_element_or_fallback 永远走 _click_at 硬编码坐标, - # OpenCV 模板匹配 / 熔断器完全失效。此处回填 actions。 - if _state.xdotool is not None and hasattr(_state.xdotool, '_contact'): - _state.xdotool._contact._actions = _state.actions + # P0 修复:将 actions 注入 ContactDriver 与 MessageDriver,启用 OpenCV/ + # LocatorRegistry 优先策略。门面构造子 driver 时 actions 尚未构造, + # 导致 _click_element_or_fallback / _open_session_by_name 永远走硬编码坐标。 + if _state.xdotool is not None and hasattr(_state.xdotool, 'set_actions'): + _state.xdotool.set_actions(_state.actions) + else: + if _state.xdotool is not None and hasattr(_state.xdotool, '_contact'): + _state.xdotool._contact._actions = _state.actions + if _state.xdotool is not None and hasattr(_state.xdotool, '_message'): + _state.xdotool._message._actions = _state.actions # 构造 SendFileFlow(注入 actions + xdotool + db_reader) # SendFileFlow 依赖 XdotoolDriver(用于 xdotool type 路径输入文件选择器), @@ -478,11 +503,42 @@ def _init_state(cfg: BridgeConfig) -> None: _state.watchdog = None _state.resource_reaper = None _state.batch_worker = None + else: + # legacy 模式:至少加载 YAML profile 并构造无 OpenCV 的 Actions, + # 让 ContactDriver.accept_friend_request 使用 profile 几何兜底而非硬编码坐标。 + try: + from woc_bridge.ui.backends import XdotoolBackend + from woc_bridge.ui.locators.registry import LocatorRegistry + from woc_bridge.ui.actions import Actions + + _state.xdotool_backend = XdotoolBackend(display=cfg.display) + profile_dir = os.path.join(os.path.dirname(__file__), "ui", "profiles") + _state.locator = LocatorRegistry(profile_dir=profile_dir, opencv=None) + real_resolution = _detect_display_resolution(cfg.display) + _state.locator.load("4.0", real_resolution) + _state.actions = Actions(backend=_state.xdotool_backend, locator=_state.locator) + if _state.xdotool is not None and hasattr(_state.xdotool, 'set_actions'): + _state.xdotool.set_actions(_state.actions) + else: + if _state.xdotool is not None and hasattr(_state.xdotool, '_contact'): + _state.xdotool._contact._actions = _state.actions + if _state.xdotool is not None and hasattr(_state.xdotool, '_message'): + _state.xdotool._message._actions = _state.actions + logger.info( + "[init] legacy 模式已加载 UI profile 几何兜底: wechat_4.0 %s", + real_resolution, + ) + except Exception as exc: + logger.warning("[init] legacy 模式加载 UI profile 失败: %s", exc) + _state.actions = None + _state.locator = None + _state.xdotool_backend = None # 好友自动通过组件(不依赖 P3 UI 架构,独立构造) try: from woc_bridge.models import AcceptRuleConfig, AcceptRuleEngine from woc_bridge.messaging.friend_watcher import FriendRequestWatcher + from woc_bridge.messaging.login_guard import LoginGuard from woc_bridge.ui.circuit_breaker import CircuitBreaker from woc_bridge.ui.idem_cache import IdemCache @@ -507,7 +563,14 @@ def _init_state(cfg: BridgeConfig) -> None: # (好友自动通过不依赖 P3 UI 架构,自持 IdemCache 实例) accept_idem_cache = IdemCache(ttl=300, max_size=1000) - # 好友申请监听器(注入 6 个依赖) + # 登录守卫:后台检测微信登录状态,供 friend_watcher 做登录前提保护 + # 未登录时 watcher 跳过轮询(不推进游标),登录后自然从上次位置继续 + _state.login_guard = LoginGuard( + xdotool_driver=_state.xdotool, + check_interval=5.0, + ) + + # 好友申请监听器(注入 7 个依赖,含 login_guard) _state.friend_watcher = FriendRequestWatcher( db_reader=_state.db_reader, rule_engine=_state.rule_engine, @@ -516,6 +579,7 @@ def _init_state(cfg: BridgeConfig) -> None: xdotool_driver=_state.xdotool, breaker=_state.accept_breaker, poll_interval=cfg.auto_accept_poll_interval, + login_guard=_state.login_guard, ) logger.info("[init] 好友自动通过组件构造完成 (enabled=%s)", cfg.auto_accept_enabled) @@ -525,3 +589,4 @@ def _init_state(cfg: BridgeConfig) -> None: _state.rule_engine = None _state.friend_watcher = None _state.accept_breaker = None + _state.login_guard = None diff --git a/bridge/woc_bridge/config.py b/bridge/woc_bridge/config.py index 75b170d..4f33565 100644 --- a/bridge/woc_bridge/config.py +++ b/bridge/woc_bridge/config.py @@ -134,6 +134,8 @@ class BridgeConfig: # 环境变量 send_delay_ms: int = 3000 max_calls_per_sec: int = 10 + max_queue_size: int = 100 + send_queue_wait_timeout_ms: int = 15000 max_batch_size: int = 50 poll_interval_ms: int = 2000 db_key: str = "" @@ -171,6 +173,8 @@ class BridgeConfig: display=args.display, send_delay_ms=int(os.environ.get("WOC_BRIDGE_SEND_DELAY_MS", "3000")), max_calls_per_sec=int(os.environ.get("WOC_BRIDGE_MAX_CALLS_PER_SEC", "10")), + max_queue_size=int(os.environ.get("WOC_BRIDGE_MAX_QUEUE_SIZE", "100")), + send_queue_wait_timeout_ms=int(os.environ.get("WOC_BRIDGE_SEND_QUEUE_WAIT_TIMEOUT_MS", "15000")), max_batch_size=int(os.environ.get("WOC_BRIDGE_MAX_BATCH_SIZE", "50")), poll_interval_ms=int(os.environ.get("WOC_BRIDGE_POLL_INTERVAL_MS", "2000")), db_key=os.environ.get("WOC_DB_KEY", "").strip(), @@ -260,6 +264,8 @@ class AppState: self.friend_watcher: Optional[Any] = None # FriendRequestWatcher self.rule_engine: Optional[Any] = None # AcceptRuleEngine self.accept_breaker: Optional[Any] = None # CircuitBreaker + # 登录状态守卫(后台检测微信登录状态,供后台任务做登录前提保护) + self.login_guard: Optional[Any] = None # LoginGuard # 群发消息后台协程(Task 13-16) self.batch_worker: Optional[Any] = None # BatchSendWorker diff --git a/bridge/woc_bridge/db/reader.py b/bridge/woc_bridge/db/reader.py index 21f87e0..06448f1 100644 --- a/bridge/woc_bridge/db/reader.py +++ b/bridge/woc_bridge/db/reader.py @@ -787,10 +787,14 @@ class DbReader: try: table_to_talker = self._resolve_msg_table_talkers(conn) if not table_to_talker: - raise BridgeError( - code="DB_NOT_FOUND", - message="未找到 Msg_* 消息分片表", - ) + # 微信未登录或尚无消息时,Msg_* 分片表可能不存在;返回空列表 + # 而不是抛 DB_NOT_FOUND(500),避免前端轮询进入错误重试循环。 + return { + "messages": [], + "next_cursor": cursor, + "next_cursor_local_id": cursor_local_id, + "has_more": False, + } id_to_username = self._load_name2id(conn) self_wxid = self._get_current_wxid() or "" @@ -1227,11 +1231,11 @@ class DbReader: messages = all_rows[:limit] return {"messages": messages, "total": total} - def get_max_create_time(self) -> Optional[int]: - """返回所有 Msg_* 分片表中最大的 create_time,用于初始化推送游标。 + def get_max_create_time(self) -> Optional[tuple[int, int]]: + """返回所有 Msg_* 分片表中最大的 (create_time, local_id),用于初始化推送游标。 DB 不可读或无消息表时返回 None。供 MessageStreamer 在启动时 - 把全局游标对齐到当前最新消息,避免向新连接推送全量历史。 + 把全局游标对齐到当前最新消息,避免同秒消息在启动后被重复推送。 """ try: db_path = self._ensure_decrypted("message/message_0.db") @@ -1243,18 +1247,26 @@ class DbReader: if not table_to_talker: return None max_ct: Optional[int] = None + max_local_id: int = 0 for table_name in table_to_talker: try: row = conn.execute( - f"SELECT MAX(create_time) FROM [{table_name}]" + f"SELECT create_time, local_id FROM [{table_name}] " + f"ORDER BY create_time DESC, local_id DESC LIMIT 1" ).fetchone() if row and row[0] is not None: ct = int(row[0]) - if max_ct is None or ct > max_ct: + lid = int(row[1]) if row[1] is not None else 0 + if max_ct is None or ct > max_ct or ( + ct == max_ct and lid > max_local_id + ): max_ct = ct + max_local_id = lid except sqlite3.Error: continue - return max_ct + if max_ct is None: + return None + return (max_ct, max_local_id) except Exception: return None finally: @@ -1265,7 +1277,8 @@ class DbReader: ) -> Optional[dict]: """查询 fmessage 会话中指定游标之后的好友申请消息。 - 直接查 Msg_ 单表,避免遍历所有分片。 + 优先查 Msg_ 单表;若 fmessage 表无数据, + 则 fallback 到 contact.db 的 ticket_info / stranger 表读取待处理申请。 复合游标 (create_time, local_id) 作为 tie-breaker,避免同秒消息丢失。 Args: @@ -1338,34 +1351,171 @@ class DbReader: next_ct = row["create_time"] next_lid = row["local_id"] - return { + result = { "requests": requests, "next_create_time": next_ct, "next_local_id": next_lid, } + + # fallback:fmessage 无新申请时,尝试从 contact.db 的 ticket_info 读取 + if not requests and cursor_create_time == 0: + ticket_requests = self.get_pending_requests_from_ticket_info(limit) + if ticket_requests: + result["requests"] = ticket_requests + # ticket_info 无 create_time 时统一用 1 作为占位,确保 watcher 能处理 + result["next_create_time"] = max( + (r["create_time"] for r in ticket_requests), default=1 + ) + result["next_local_id"] = max( + (r["local_id"] for r in ticket_requests), default=0 + ) + + return result except sqlite3.Error: return empty finally: conn.close() + def get_pending_requests_from_ticket_info( + self, limit: int = 50, since_id: int = 0 + ) -> list[dict]: + """从 contact.db 的 ticket_info / stranger 表读取待处理好友申请。 + + 微信 4.x Linux 中,fmessage 表可能不包含好友申请消息, + 但 contact.db 的 ticket_info 表中会留存待验证的 ticket(形如 wxid_xxx@stranger)。 + 本方法作为 get_friend_requests_since 的兜底数据源。 + + Args: + limit: 单次最大返回条数。 + since_id: 只返回 id > since_id 的记录,用于跳过历史数据。 + + Returns: + [{"local_id": int, "create_time": int, "content": str}, ...] + """ + try: + db_path = self._ensure_decrypted("contact/contact.db") + except BridgeError: + return [] + + conn = sqlite3.connect(db_path, isolation_level=None) + conn.row_factory = sqlite3.Row + requests: list[dict] = [] + try: + # 1. 尝试 ticket_info 表 + tables = {r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall()} + + if "ticket_info" in tables: + cols = self._table_columns(conn, "ticket_info") + ticket_col = self._pick_column(cols, ["ticket"]) + id_col = self._pick_column(cols, ["id", "rowid"]) + if ticket_col and id_col: + cur = conn.execute( + f"SELECT {id_col} AS local_id, {ticket_col} AS ticket " + f"FROM ticket_info WHERE {ticket_col} LIKE '%@%' " + f"AND {id_col} > ? " + f"LIMIT ?", + (since_id, limit), + ) + for row in cur.fetchall(): + ticket = row["ticket"] or "" + if "@" not in ticket: + continue + # 构造最小 sysmsg XML,供 parse_friend_request 解析 + xml = ( + f'' + f'{ticket}' + f'' + ) + requests.append({ + "local_id": row["local_id"], + "create_time": 1, + "content": xml, + }) + + # 2. 尝试 stranger / stranger_ticket_info 表 + if not requests and "stranger" in tables: + cols = self._table_columns(conn, "stranger") + username_col = self._pick_column(cols, ["username", "wxid", "stranger"]) + id_col = self._pick_column(cols, ["id", "rowid"]) + if username_col and id_col: + cur = conn.execute( + f"SELECT {id_col} AS local_id, {username_col} AS username " + f"FROM stranger WHERE {username_col} LIKE '%@%' " + f"AND {id_col} > ? " + f"LIMIT ?", + (since_id, limit), + ) + for row in cur.fetchall(): + username = row["username"] or "" + if "@" not in username: + username = f"{username}@stranger" + xml = ( + f'' + f'{username}' + f'' + ) + requests.append({ + "local_id": row["local_id"], + "create_time": 1, + "content": xml, + }) + except sqlite3.Error as e: + logger.warning("_get_pending_requests_from_ticket_info 异常: %s", e) + finally: + conn.close() + + return requests + + def get_max_ticket_info_id(self) -> Optional[int]: + """获取 ticket_info 表当前最大 id(用于初始化增量游标)。 + + Returns: + 最大 id;表不存在或为空时返回 0;DB 不可读时返回 None。 + """ + try: + db_path = self._ensure_decrypted("contact/contact.db") + except BridgeError: + return None + + conn = sqlite3.connect(db_path, isolation_level=None) + conn.row_factory = sqlite3.Row + try: + tables = {r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall()} + if "ticket_info" not in tables: + return 0 + cols = self._table_columns(conn, "ticket_info") + id_col = self._pick_column(cols, ["id", "rowid"]) + if id_col is None: + return 0 + cur = conn.execute( + f"SELECT COALESCE(MAX({id_col}), 0) AS max_id FROM ticket_info" + ) + row = cur.fetchone() + return int(row["max_id"]) if row else 0 + except sqlite3.Error as e: + logger.warning("get_max_ticket_info_id 异常: %s", e) + return None + finally: + conn.close() + def verify_friend_accepted(self, stranger_wxid: str) -> bool: - """校验好友申请是否已通过:contact 表中 @stranger 后缀消失。 + """校验好友申请是否已通过。 - 通过前:username = "wxid_xxx@stranger"(后缀名需环境验证) - 通过后:username = "wxid_xxx"(后缀被移除,local_type 可能变为 1) - - 策略:用 base_wxid 查 contact 表,若存在 base_wxid 记录且不存在 - base_wxid@stranger 记录,则判定已通过。合并为单条 SQL 避免 2 次查询。 + 判定逻辑: + 1. contact 表中存在 base_wxid 记录(已变为好友) + 2. contact 表中不存在 base_wxid@stranger 残留 + 3. ticket_info 表中不存在该 stranger 的待处理 ticket + 任一条件不满足均视为未通过。 Args: stranger_wxid: 含 @ 后缀的 wxid Returns: True 表示已通过,False 表示仍待验证或 DB 不可读 - - Notes: - - _ensure_decrypted 抛 BridgeError 时返回 False(不影响 UI 操作结果) - - @stranger 后缀是假设,若真实后缀不同需调整 SQL LIKE 模式 """ try: db_path = self._ensure_decrypted("contact/contact.db") @@ -1400,25 +1550,42 @@ class DbReader: return False base_cnt = row["base_cnt"] or 0 stranger_cnt = row["stranger_cnt"] or 0 - # base_wxid 存在(已变为好友)且无 @stranger 残留 - return base_cnt > 0 and stranger_cnt == 0 + contact_passed = base_cnt > 0 and stranger_cnt == 0 + + # 同时检查 ticket_info 表是否还有该 stranger 的待处理 ticket + ticket_pending = False + tables = {r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall()} + if "ticket_info" in tables: + ticket_cols = self._table_columns(conn, "ticket_info") + ticket_col = self._pick_column(ticket_cols, ["ticket"]) + if ticket_col: + cur = conn.execute( + f"SELECT COUNT(*) FROM ticket_info WHERE {ticket_col} LIKE ?", + (f"{base_wxid}@%",), + ) + ticket_pending = (cur.fetchone()[0] or 0) > 0 + + # 通过 = contact 已建立且 ticket 已消失 + return contact_passed and not ticket_pending except sqlite3.Error: return False finally: conn.close() - def get_max_create_time_for_talker(self, talker: str) -> Optional[int]: - """获取指定会话的消息最大 create_time(游标初始化用)。 + def get_max_create_time_for_talker(self, talker: str) -> Optional[tuple[int, int]]: + """获取指定会话的消息最大 (create_time, local_id)(游标初始化用)。 直接查 Msg_ 单表,避免遍历所有分片。 返回 None 表示 DB 不可读(与 get_max_create_time 语义一致), - 返回 0 表示表为空或不存在。 + 返回 (0, 0) 表示表为空或不存在。 Args: talker: 会话 talker(如 "fmessage") Returns: - 最大 create_time,或 None / 0 + 最大 (create_time, local_id),或 None / (0, 0) """ try: db_path = self._ensure_decrypted("message/message_0.db") @@ -1433,12 +1600,17 @@ class DbReader: (table_name,), ) if cur.fetchone() is None: - return 0 - cur = conn.execute(f"SELECT MAX(create_time) FROM [{table_name}]") + return (0, 0) + cur = conn.execute( + f"SELECT create_time, local_id FROM [{table_name}] " + f"ORDER BY create_time DESC, local_id DESC LIMIT 1" + ) row = cur.fetchone() - return int(row[0]) if row and row[0] else 0 + if row and row[0] is not None: + return (int(row[0]), int(row[1]) if row[1] is not None else 0) + return (0, 0) except sqlite3.Error: - return 0 + return (0, 0) finally: conn.close() diff --git a/bridge/woc_bridge/messaging/__init__.py b/bridge/woc_bridge/messaging/__init__.py index d52e2ac..6b0e97b 100644 --- a/bridge/woc_bridge/messaging/__init__.py +++ b/bridge/woc_bridge/messaging/__init__.py @@ -1,6 +1,7 @@ -"""消息域:发送串行化队列 / SSE 实时消息推送。""" +"""消息域:发送串行化队列 / SSE 实时消息推送 / 登录状态守卫。""" +from woc_bridge.messaging.login_guard import LoginGuard from woc_bridge.messaging.send_queue import SendQueue from woc_bridge.messaging.streamer import MessageStreamer, StreamEvent, format_sse -__all__ = ["SendQueue", "MessageStreamer", "StreamEvent", "format_sse"] +__all__ = ["LoginGuard", "SendQueue", "MessageStreamer", "StreamEvent", "format_sse"] diff --git a/bridge/woc_bridge/messaging/friend_parser.py b/bridge/woc_bridge/messaging/friend_parser.py index 0193bf8..6ac26d2 100644 --- a/bridge/woc_bridge/messaging/friend_parser.py +++ b/bridge/woc_bridge/messaging/friend_parser.py @@ -18,20 +18,28 @@ from __future__ import annotations import logging import xml.etree.ElementTree as ET -from typing import TYPE_CHECKING, Optional +from typing import Optional -if TYPE_CHECKING: - # 仅类型注解用,避免与 friend_watcher.py 形成运行时循环 import - from woc_bridge.messaging.friend_watcher import FriendRequestInfo +from woc_bridge.models.contact import FriendRequestInfo logger = logging.getLogger("woc-bridge") +# 可能的好友申请 XML 类型标记(大小写不敏感) +_VERIFYUSER_TYPES = {"verifyuser", "verify_user", "verification"} + + def parse_friend_request( content: str, create_time: int, msg_local_id: int ) -> Optional[FriendRequestInfo]: """解析 fmessage 系统消息 XML,提取好友申请信息。 + 兼容多种实际格式: + - ...... + - ...... + - ... 根节点 + - 标签大小写/命名空间不一致 + Args: content: message_content 原始文本(XML) create_time: 消息时间戳 @@ -49,23 +57,31 @@ def parse_friend_request( # 部分消息可能不是合法 XML(如纯文本通知),跳过 return None - # 检查是否为 verifyUser 类型 sysmsg - msg_type = root.get("type", "") - if msg_type != "verifyUser": + # 1. 判断是否为好友申请类型 + if not _is_verify_user_root(root): return None - # 提取 Link 节点中的申请人信息 + # 2. 提取字段:优先 Link 子树,其次 verifyuser/verification 子树 link = root.find(".//Link") - if link is None: - return None - - stranger_wxid = _text(link, "UserName") - nickname = _text(link, "NickName") - verify_message = _text(link, "Content") - scene = _text(link, "Scene") + if link is not None: + stranger_wxid = _text(link, "UserName") + nickname = _text(link, "NickName") + verify_message = _text(link, "Content") + scene = _text(link, "Scene") + else: + # 无 Link 节点时从任意子节点按候选标签名搜索 + stranger_wxid = _find_text(root, ["UserName", "username", "v1", "ticket"]) + nickname = _find_text(root, ["NickName", "nickname", "nick"]) + verify_message = _find_text(root, ["Content", "content", "msg", "verifyContent"]) + scene = _find_text(root, ["Scene", "scene", "sceneid", "scene_id"]) + # 3. 从 ticket/encrypt username 中反推带 @stranger 的 wxid if not stranger_wxid: return None + if "@" not in stranger_wxid: + # 微信 ticket 常见为 wxid_xxx@stranger 或加密串,统一补后缀 + # 若已含 @ 则保留原样(如 @stranger / @openim) + stranger_wxid = f"{stranger_wxid}@stranger" return FriendRequestInfo( stranger_wxid=stranger_wxid, @@ -78,7 +94,42 @@ def parse_friend_request( ) +def _is_verify_user_root(root: ET.Element) -> bool: + """判断 XML 根节点是否表示好友申请。""" + tag = root.tag.split("}")[-1] if root.tag else "" + if tag.lower() in _VERIFYUSER_TYPES: + return True + if tag.lower() == "sysmsg": + msg_type = (root.get("type") or "").lower() + if msg_type in _VERIFYUSER_TYPES: + return True + return False + + def _text(parent: ET.Element, tag: str) -> str: - """安全提取子元素文本(None 安全)。""" - el = parent.find(tag) - return el.text.strip() if el is not None and el.text else "" + """安全提取直接子元素文本(None 安全,去掉命名空间)。""" + tag_lower = tag.lower() + for child in parent: + child_tag = child.tag.split("}")[-1] if child.tag else "" + if child_tag.lower() == tag_lower: + return child.text.strip() if child.text else "" + return "" + + +def _find_child_recursive(parent: ET.Element, tag: str) -> Optional[ET.Element]: + """按本地标签名递归查找后代元素(忽略命名空间)。""" + tag_lower = tag.lower() + for elem in parent.iter(): + elem_tag = elem.tag.split("}")[-1] if elem.tag else "" + if elem_tag.lower() == tag_lower: + return elem + return None + + +def _find_text(parent: ET.Element, tags: list[str]) -> str: + """按候选标签名列表顺序递归查找,返回首个非空文本。""" + for tag in tags: + el = _find_child_recursive(parent, tag) + if el is not None and el.text: + return el.text.strip() + return "" diff --git a/bridge/woc_bridge/messaging/friend_watcher.py b/bridge/woc_bridge/messaging/friend_watcher.py index 818b8ac..0ab221a 100644 --- a/bridge/woc_bridge/messaging/friend_watcher.py +++ b/bridge/woc_bridge/messaging/friend_watcher.py @@ -11,27 +11,17 @@ from __future__ import annotations import asyncio import logging +import time from dataclasses import dataclass from typing import Optional from woc_bridge.models import AcceptDecision, BridgeError +from woc_bridge.models.contact import FriendRequestInfo from woc_bridge.messaging.friend_parser import parse_friend_request logger = logging.getLogger("woc-bridge") -@dataclass -class FriendRequestInfo: - """好友申请信息(从 fmessage sysmsg XML 解析)。""" - stranger_wxid: str # 申请人 wxid(含 @stranger 后缀) - nickname: str # 申请人昵称 - verify_message: str # 验证消息内容 - scene: str # 来源场景(如群聊/搜索/二维码) - raw_xml: str # 原始 XML(调试用) - create_time: int # 消息时间戳 - msg_local_id: int # 消息 local_id(游标用) - - class FriendRequestWatcher: """好友申请监听器:轮询 fmessage 系统消息,解析后触发自动通过。""" @@ -46,6 +36,7 @@ class FriendRequestWatcher: poll_interval: float = 3.0, cursor_create_time: int = 0, cursor_local_id: int = 0, + login_guard=None, # LoginGuard(登录守卫,None 时跳过登录检查) ) -> None: self._db_reader = db_reader self._rule_engine = rule_engine @@ -54,23 +45,36 @@ class FriendRequestWatcher: self._xdotool = xdotool_driver self._breaker = breaker self._poll_interval = poll_interval + self._login_guard = login_guard # 复合游标 (create_time, local_id),与 get_friend_requests_since 返回值对齐 self._cursor_create_time: int = cursor_create_time self._cursor_local_id: int = cursor_local_id self._last_db_mtime: float = 0.0 self._last_wal_mtime: float = 0.0 + # contact.db 独立 mtime:好友申请可能只写入 contact.db 的 ticket_info, + # 不触发 message_0.db 变化,必须单独感知 + self._last_contact_db_mtime: float = 0.0 + self._last_contact_wal_mtime: float = 0.0 + # ticket_info 增量游标:避免每次全量读取历史已通过/残留 ticket + self._ticket_cursor_local_id: int = 0 + self._ticket_cursor_inited: bool = False self._watcher: Optional[asyncio.Task] = None self._enabled_event: asyncio.Event = asyncio.Event() # auto_accept 开关事件 self._cursor_inited: bool = False # 上轮满载标志:为 True 时跳过 mtime 一级过滤,避免批量积压(>50 条)时 # 剩余批次因 DB mtime 未变被卡住 self._has_more_pending: bool = False + self._has_more_ticket_pending: bool = False # 状态统计(供 /api/friends/auto_accept/status 查询) self._processed_count: int = 0 self._accepted_count: int = 0 self._rejected_count: int = 0 self._last_processed_time: Optional[int] = None + # 短期去重:同一个 wxid 在 N 秒内不重复入队执行 UI 通过, + # 避免已通过但 verify 校验超时/失败时被反复处理导致乱点 + self._recent_wxids: dict[str, float] = {} + self._recent_wxid_ttl: float = 60.0 @property def is_running(self) -> bool: @@ -96,6 +100,14 @@ class FriendRequestWatcher: def last_processed_time(self) -> Optional[int]: return self._last_processed_time + @property + def cursor_create_time(self) -> int: + return self._cursor_create_time + + @property + def cursor_local_id(self) -> int: + return self._cursor_local_id + async def start(self) -> None: if self._watcher is None or self._watcher.done(): self._watcher = asyncio.create_task(self._watch_loop()) @@ -114,56 +126,124 @@ class FriendRequestWatcher: if enabled: # 开启时重置游标,避免批量处理积压申请 self._cursor_inited = False + self._ticket_cursor_inited = False self._has_more_pending = False + self._has_more_ticket_pending = False self._enabled_event.set() else: self._enabled_event.clear() logger.info("FriendRequestWatcher enabled=%s", enabled) async def _init_cursor(self) -> bool: - """对齐游标到当前 fmessage 表最大 create_time。 + """对齐游标到当前 fmessage 表最大 (create_time, local_id)。 Returns: True 表示已对齐(或 DB 不可读但标记为已初始化,避免无限重试) False 表示异常,下一轮重试 """ try: - max_ct = await asyncio.to_thread( + max_cursor = await asyncio.to_thread( self._db_reader.get_max_create_time_for_talker, "fmessage" ) - if max_ct is None: + if max_cursor is None: # DB 不可读(_ensure_decrypted 抛 BridgeError 已被吞掉返回 None) # 不标记 _cursor_inited,下一轮重试 logger.warning("FriendRequestWatcher 游标初始化: DB 不可读,将在下轮重试") return False - if max_ct and max_ct > 0: - self._cursor_create_time = max_ct - self._cursor_local_id = 0 + if isinstance(max_cursor, tuple) and max_cursor[0] > 0: + self._cursor_create_time = max_cursor[0] + self._cursor_local_id = max_cursor[1] logger.info( - "FriendRequestWatcher 游标对齐到 create_time=%d(跳过历史申请)", - self._cursor_create_time, + "FriendRequestWatcher 游标对齐到 (create_time=%d, local_id=%d)(跳过历史申请)", + self._cursor_create_time, self._cursor_local_id, ) - # 无论 max_ct 是否为 0(空表),都标记为已初始化,避免空表时无限重试 + # 无论 max_cursor 是否为 (0, 0)(空表),都标记为已初始化,避免空表时无限重试 self._cursor_inited = True return True except Exception as e: logger.warning("FriendRequestWatcher 游标初始化失败: %s", e) return False + async def _init_ticket_cursor(self) -> bool: + """初始化 ticket_info 增量游标到当前最大 id,跳过历史数据。 + + Returns: + True 表示已初始化;None 时按 0 处理并标记成功。 + """ + try: + max_id = await asyncio.to_thread( + self._db_reader.get_max_ticket_info_id + ) + if max_id is None: + logger.warning( + "FriendRequestWatcher ticket 游标初始化: DB 不可读,将在下轮重试" + ) + return False + self._ticket_cursor_local_id = max_id + self._ticket_cursor_inited = True + logger.info( + "FriendRequestWatcher ticket 游标对齐到 max_id=%d(跳过历史 ticket)", + self._ticket_cursor_local_id, + ) + return True + except Exception as e: + logger.warning("FriendRequestWatcher ticket 游标初始化失败: %s", e) + return False + async def _poll_once(self) -> None: # 1. mtime 感知(一级过滤,避免空 SQL) - # 上轮满载时跳过过滤(仍有未读批次),否则会因 DB mtime 未变卡住剩余申请 + # 必须同时感知 message_0.db 与 contact.db:微信 4.x Linux 好友申请可能 + # 只写入 contact.db 的 ticket_info,不触发 message_0.db 变化。 + # 上轮满载时跳过过滤(仍有未读批次),否则会因 DB mtime 未变卡住剩余申请。 + msg_changed = False + contact_changed = False + mtimes = await asyncio.to_thread( self._db_reader.get_db_mtime, "message/message_0.db" ) if mtimes is None: + logger.debug("FriendRequestWatcher _poll_once: message_0.db mtime 不可读") + else: + db_mtime, wal_mtime = mtimes + if ( + self._has_more_pending + or db_mtime != self._last_db_mtime + or wal_mtime != self._last_wal_mtime + ): + msg_changed = True + self._last_db_mtime = db_mtime + self._last_wal_mtime = wal_mtime + + mtimes_contact = await asyncio.to_thread( + self._db_reader.get_db_mtime, "contact/contact.db" + ) + if mtimes_contact is None: + logger.debug("FriendRequestWatcher _poll_once: contact.db mtime 不可读") + else: + cdb_mtime, cwal_mtime = mtimes_contact + if ( + self._has_more_ticket_pending + or cdb_mtime != self._last_contact_db_mtime + or cwal_mtime != self._last_contact_wal_mtime + ): + contact_changed = True + self._last_contact_db_mtime = cdb_mtime + self._last_contact_wal_mtime = cwal_mtime + + logger.debug( + "FriendRequestWatcher _poll_once: 开始轮询 " + "cursor=(%d,%d) msg_changed=%s contact_changed=%s " + "last_msg_mtime=(%.3f,%.3f) last_contact_mtime=(%.3f,%.3f)", + self._cursor_create_time, self._cursor_local_id, + msg_changed, contact_changed, + self._last_db_mtime, self._last_wal_mtime, + self._last_contact_db_mtime, self._last_contact_wal_mtime, + ) + if not msg_changed and not contact_changed: + logger.debug( + "FriendRequestWatcher _poll_once: message_0.db/contact.db mtime 均未变化,跳过本次轮询" + ) return - db_mtime, wal_mtime = mtimes - if not self._has_more_pending: - if db_mtime == self._last_db_mtime and wal_mtime == self._last_wal_mtime: - return - self._last_db_mtime = db_mtime - self._last_wal_mtime = wal_mtime # 2. 查询 fmessage 分片表增量消息(复合游标) result = await asyncio.to_thread( @@ -174,26 +254,71 @@ class FriendRequestWatcher: ) if result is None: # DB 不可读(_ensure_decrypted 抛 BridgeError) + logger.warning( + "FriendRequestWatcher _poll_once: get_friend_requests_since 返回 None(DB 不可读)" + ) return - raw_items: list[dict] = result["requests"] + raw_items: list[dict] = list(result["requests"]) + + # 2.1 fallback:微信 4.x Linux 好友申请可能仅存于 contact.db 的 ticket_info, + # fmessage 表可能为空。额外读取 ticket_info,通过 since_id 游标跳过历史数据。 + ticket_items = await asyncio.to_thread( + self._db_reader.get_pending_requests_from_ticket_info, + 50, + self._ticket_cursor_local_id, + ) + if ticket_items: + logger.info( + "FriendRequestWatcher _poll_once: 从 ticket_info 读到 %d 条新申请 " + "(since_id=%d)", + len(ticket_items), self._ticket_cursor_local_id, + ) + raw_items.extend(ticket_items) + # 推进 ticket_info 游标到本批最大 id + max_ticket_id = max(item["local_id"] for item in ticket_items) + if max_ticket_id > self._ticket_cursor_local_id: + self._ticket_cursor_local_id = max_ticket_id + self._has_more_ticket_pending = len(ticket_items) >= 50 + + logger.info( + "FriendRequestWatcher _poll_once: 本轮检测到 %d 条原始申请 " + "(cursor=%d,%d → next=%d,%d)", + len(raw_items), + self._cursor_create_time, self._cursor_local_id, + result["next_create_time"], result["next_local_id"], + ) # 3. 解析 XML + 逐条处理 + parsed_count = 0 for item in raw_items: info = parse_friend_request( item["content"], item["create_time"], item["local_id"] ) if info is None: - # 非 verifyUser 类型或解析失败,跳过 + # 非 verifyUser 类型或解析失败,记录日志便于排查 + logger.debug( + "FriendRequestWatcher: 解析失败或非 verifyUser,content=%s", + repr(item["content"][:200]) if item.get("content") else "", + ) continue + parsed_count += 1 + logger.info( + "FriendRequestWatcher: 解析到申请 wxid=%s nickname=%s scene=%s", + info.stranger_wxid, info.nickname, info.scene, + ) await self._handle_request(info) - # 4. 推进复合游标(与 get_friend_requests_since 返回字段对齐) + # 4. 推进复合游标(仅由 fmessage 结果决定;ticket_info 无时间戳,不参与游标) self._cursor_create_time = result["next_create_time"] self._cursor_local_id = result["next_local_id"] - # 5. 满载标志:本批达 limit 则置 True,下轮跳过 mtime 过滤继续读剩余批次 - self._has_more_pending = len(raw_items) >= 50 + # 5. 满载标志:仅由 fmessage 本批是否达 limit 决定 + self._has_more_pending = len(result["requests"]) >= 50 + logger.debug( + "FriendRequestWatcher _poll_once: 解析成功 %d/%d 条,满载=%s", + parsed_count, len(raw_items), self._has_more_pending, + ) async def _watch_loop(self) -> None: while True: @@ -201,6 +326,20 @@ class FriendRequestWatcher: # auto_accept 关闭时挂起,避免空轮询 await self._enabled_event.wait() + # 登录守卫:微信未登录时跳过轮询,不推进游标 + # 登录后自然从上次游标位置重新读取,DB 中积累的申请不丢失 + if self._login_guard is not None and not self._login_guard.is_logged_in: + logger.info( + "FriendRequestWatcher: 微信未登录 (state=%s),暂停轮询等待登录恢复", + self._login_guard.current_state, + ) + # 等待登录恢复,带超时避免紧密循环 + # 超时后 continue 回到 while 顶部重新检查状态 + await self._login_guard.wait_for_login( + timeout=self._poll_interval + ) + continue + # 游标未初始化时先对齐(DB 恢复可读后自动补齐) # DB_ENCRYPTED 时 _init_cursor 返回 False,下一轮仍会重试 if not self._cursor_inited: @@ -210,6 +349,13 @@ class FriendRequestWatcher: await asyncio.sleep(self._poll_interval) continue + # fmessage 游标初始化成功后,再初始化 ticket_info 游标 + if not self._ticket_cursor_inited: + await self._init_ticket_cursor() + if not self._ticket_cursor_inited: + await asyncio.sleep(self._poll_interval) + continue + await asyncio.sleep(self._poll_interval) await self._poll_once() except asyncio.CancelledError: @@ -218,47 +364,144 @@ class FriendRequestWatcher: logger.exception("FriendRequestWatcher 异常: %s", e) await asyncio.sleep(5.0) + async def _verify_with_retry(self, stranger_wxid: str) -> bool: + """UI 操作完成后轮询 DB 校验好友是否已通过。 + + 先等待 1.5s 让微信刷盘,之后每隔 1s 查询一次,最多 10s。 + 期间任一次校验成功即返回 True;DB 异常视为未通过并继续重试。 + """ + await asyncio.sleep(1.5) + deadline = time.monotonic() + 10.0 + attempt = 0 + while time.monotonic() < deadline: + attempt += 1 + try: + verified = await asyncio.to_thread( + self._db_reader.verify_friend_accepted, stranger_wxid + ) + logger.info( + "FriendRequestWatcher: verify 尝试 #%d (wxid=%s) → verified=%s", + attempt, stranger_wxid, verified, + ) + if verified: + return True + except Exception as exc: + logger.warning( + "FriendRequestWatcher: verify_friend_accepted 异常 #%d (wxid=%s): %s", + attempt, stranger_wxid, exc, + ) + await asyncio.sleep(1.0) + logger.warning( + "FriendRequestWatcher: verify 超时 (wxid=%s, 尝试 %d 次)", + stranger_wxid, attempt, + ) + return False + async def _handle_request(self, req: FriendRequestInfo) -> None: self._processed_count += 1 self._last_processed_time = req.create_time - # 1. 规则匹配 + # 步骤 1:收到申请,打印原始信息 + logger.info( + "[自动通过][步骤1/8] 收到好友申请 wxid=%s nickname=%s scene=%s verify=%s", + req.stranger_wxid, + req.nickname, + req.scene, + repr(req.verify_message[:80]) if req.verify_message else "", + ) + + # 步骤 2:规则引擎决策 + logger.info("[自动通过][步骤2/8] 进入规则引擎评估 wxid=%s", req.stranger_wxid) decision = await self._rule_engine.evaluate(req) if decision == AcceptDecision.REJECT: self._rejected_count += 1 logger.info( - "friend_request: wxid=%s nickname=%s → decision=%s(拒绝)", - req.stranger_wxid, req.nickname, decision.value, + "[自动通过][步骤2/8] 决策结果=REJECT(拒绝),处理结束 wxid=%s", + req.stranger_wxid, ) return if decision != AcceptDecision.ACCEPT: logger.info( - "friend_request: wxid=%s nickname=%s → decision=%s(跳过)", - req.stranger_wxid, req.nickname, decision.value, + "[自动通过][步骤2/8] 决策结果=SKIP(跳过),处理结束 wxid=%s," + "原因:未命中任何通过规则(accept_all=false、无白名单/关键词匹配)", + req.stranger_wxid, ) return + logger.info( + "[自动通过][步骤2/8] 决策结果=ACCEPT(允许通过) wxid=%s", + req.stranger_wxid, + ) - # 2. 幂等去重(TTL=300s,5 分钟内不重复处理同一申请人) - # content 参数传空串(去重 key 是 stranger_wxid,无内容维度) + # 步骤 2.5:前置 DB 检查,避免已经通过的好友被重复执行 UI + logger.info("[自动通过][步骤2.5/8] 前置校验是否已是好友 wxid=%s", req.stranger_wxid) + try: + already_accepted = await asyncio.to_thread( + self._db_reader.verify_friend_accepted, req.stranger_wxid + ) + except Exception as exc: + logger.warning( + "[自动通过][步骤2.5/8] verify_friend_accepted 异常,按未通过继续 wxid=%s: %s", + req.stranger_wxid, exc, + ) + already_accepted = False + if already_accepted: + self._idem_cache.set( + "friend_accept", req.stranger_wxid, "", + {"success": True, "verified": True, "pre_check": True}, "", + ) + self._accepted_count += 1 + logger.info( + "[自动通过][步骤2.5/8] 已是好友,跳过 UI 操作并写入幂等缓存 wxid=%s", + req.stranger_wxid, + ) + return + logger.info("[自动通过][步骤2.5/8] 尚未通过,继续执行 UI wxid=%s", req.stranger_wxid) + + # 步骤 3:幂等去重(TTL=300s,5 分钟内不重复处理同一申请人) + logger.info("[自动通过][步骤3/8] 检查幂等缓存 wxid=%s", req.stranger_wxid) cached = self._idem_cache.get( "friend_accept", req.stranger_wxid, "", "" ) if cached is not None: logger.info( - "friend_request: wxid=%s → 幂等命中,跳过", - req.stranger_wxid, + "[自动通过][步骤3/8] 幂等命中,跳过 wxid=%s (cached=%s)", + req.stranger_wxid, cached, ) return - # 3. 熔断检查 - if not self._breaker.allow(): + # 步骤 3.5:短期去重,避免 verify 超时/失败后在短时间内重复入队乱点 + now = time.monotonic() + last_at = self._recent_wxids.get(req.stranger_wxid) + if last_at is not None and now - last_at < self._recent_wxid_ttl: + logger.info( + "[自动通过][步骤3.5/8] %.0fs 内已处理过,跳过 wxid=%s", + self._recent_wxid_ttl, req.stranger_wxid, + ) + return + logger.info("[自动通过][步骤3/8] 幂等未命中,继续处理 wxid=%s", req.stranger_wxid) + + # 步骤 4:熔断检查 + logger.info("[自动通过][步骤4/8] 检查熔断器 wxid=%s", req.stranger_wxid) + breaker_allowed = self._breaker.allow() + breaker_state = self._breaker.state.value + logger.info( + "[自动通过][步骤4/8] 熔断器状态 allow=%s state=%s wxid=%s", + breaker_allowed, breaker_state, req.stranger_wxid, + ) + if not breaker_allowed: logger.warning( - "friend_request: wxid=%s → 熔断器 OPEN,跳过", + "[自动通过][步骤4/8] 熔断器 OPEN,跳过 wxid=%s", req.stranger_wxid, ) return - # 4. 入队执行(lambda 用默认参数捕获 req,避免延迟执行时变量被覆盖) + # 步骤 5/6:入队执行 UI 通过 + logger.info( + "[自动通过][步骤5/8] 准备入队执行 UI 通过 wxid=%s", + req.stranger_wxid, + ) + # 记录本次处理时间,用于短期去重(即使后续 verify 失败也不立即重试) + self._recent_wxids[req.stranger_wxid] = time.monotonic() try: result = await self._send_queue.enqueue( lambda req=req: self._xdotool.accept_friend_request( @@ -268,14 +511,18 @@ class FriendRequestWatcher: delay_ms=None, # 使用 send_queue 默认间隔 ) - # 5. 等待微信 DB WAL 刷盘后校验(微信 DB 写入有 1-3s 延迟) - await asyncio.sleep(2.0) - verified = await asyncio.to_thread( - self._db_reader.verify_friend_accepted, req.stranger_wxid + logger.info( + "[自动通过][步骤6/8] UI 操作完成 result=%s,开始 DB 校验 wxid=%s", + result, req.stranger_wxid, ) + # 步骤 7:等待微信 DB WAL 刷盘后校验 + verified = await self._verify_with_retry(req.stranger_wxid) + + # 步骤 8:结果处理 if verified: - # set 签名: (flow_name, to_wxid, content, value, client_request_id) + # 只有真正通过并验证成功才写幂等缓存,避免坐标不准等临时失败 + # 导致该申请人 5 分钟内无法再次处理 self._idem_cache.set( "friend_accept", req.stranger_wxid, "", {"success": True, "verified": True}, "", @@ -283,30 +530,28 @@ class FriendRequestWatcher: self._breaker.record_success() self._accepted_count += 1 logger.info( - "friend_request: wxid=%s nickname=%s → 通过并验证成功", + "[自动通过][步骤8/8] 通过并验证成功 wxid=%s nickname=%s", req.stranger_wxid, req.nickname, ) else: - # UI 操作完成但 DB 校验未通过(可能有延迟) - self._idem_cache.set( - "friend_accept", req.stranger_wxid, "", - {"success": True, "verified": False}, "", - ) + # UI 操作完成但 DB 校验未通过(可能有延迟),不写入幂等缓存, + # 下轮仍可尝试;仅记录熔断器失败。 self._breaker.record_failure() logger.warning( - "friend_request: wxid=%s → UI 操作完成但 DB 校验未通过", + "[自动通过][步骤8/8] UI 操作完成但 DB 校验未通过," + "不写入幂等缓存,允许下轮重试 wxid=%s", req.stranger_wxid, ) except BridgeError as e: # 透传 BridgeError 错误码(RATE_LIMITED / SEND_FAILED / WINDOW_NOT_FOUND 等) self._breaker.record_failure() logger.error( - "friend_request: wxid=%s → BridgeError code=%s: %s", - req.stranger_wxid, getattr(e, "code", "UNKNOWN"), e, + "[自动通过][步骤6/8] BridgeError code=%s: %s wxid=%s", + getattr(e, "code", "UNKNOWN"), e, req.stranger_wxid, ) except Exception as e: self._breaker.record_failure() logger.error( - "friend_request: wxid=%s → 失败: %s", - req.stranger_wxid, e, + "[自动通过][步骤6/8] 异常: %s wxid=%s", + e, req.stranger_wxid, ) diff --git a/bridge/woc_bridge/messaging/login_guard.py b/bridge/woc_bridge/messaging/login_guard.py new file mode 100644 index 0000000..ffc590d --- /dev/null +++ b/bridge/woc_bridge/messaging/login_guard.py @@ -0,0 +1,127 @@ +"""微信登录状态守卫:后台检测登录状态,提供同步属性与事件通知。 + +独立后台协程定期调用 detect_login_state(),维护 asyncio.Event 标记当前 +登录状态。状态变化时记录日志(避免每轮都输出噪音)。 + +设计目标: +- 后台任务(如 FriendRequestWatcher)在执行 UI 操作前检查 is_logged_in, + 未登录时跳过操作,等待登录恢复后自然从上次位置继续处理。 +- 游标 / 状态不推进,登录后 DB 中积累的数据自然被重新读取,不丢数据。 +- 不替代路由层的登录守卫(routes/send.py 等已有 detect_login_state 前置检查), + 仅用于后台长轮询任务的登录前提保护。 +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Optional + +logger = logging.getLogger("woc-bridge") + + +class LoginGuard: + """微信登录状态守卫。 + + Attributes: + _xdotool: XdotoolDriver 实例(调用 detect_login_state) + _check_interval: 检测间隔秒数(默认 5.0s) + _logged_in_event: asyncio.Event,set 表示当前已登录 + _watcher: 后台检测协程 + _current_state: 最近一次检测到的登录状态字符串 + _last_logged_in: 上次是否登录(用于检测状态变化) + """ + + def __init__( + self, + xdotool_driver, + check_interval: float = 5.0, + ) -> None: + self._xdotool = xdotool_driver + self._check_interval = check_interval + self._logged_in_event: asyncio.Event = asyncio.Event() + self._watcher: Optional[asyncio.Task] = None + self._current_state: str = "unknown" + self._last_logged_in: bool = False + + @property + def is_logged_in(self) -> bool: + """当前是否已登录(同步属性,取最近一次检测结果)。""" + return self._logged_in_event.is_set() + + @property + def current_state(self) -> str: + """最近一次检测到的登录状态字符串。""" + return self._current_state + + async def wait_for_login(self, timeout: Optional[float] = None) -> bool: + """等待登录状态变为已登录。 + + Args: + timeout: 超时秒数,None 表示无限等待 + + Returns: + True 表示已登录;False 表示超时仍未登录 + """ + if self._logged_in_event.is_set(): + return True + try: + await asyncio.wait_for( + self._logged_in_event.wait(), timeout=timeout + ) + return True + except asyncio.TimeoutError: + return False + + async def start(self) -> None: + """启动后台检测协程。""" + if self._watcher is None or self._watcher.done(): + self._watcher = asyncio.create_task(self._check_loop()) + logger.info("LoginGuard 已启动 (check_interval=%.1fs)", self._check_interval) + + async def stop(self) -> None: + """停止后台检测协程。""" + if self._watcher is not None and not self._watcher.done(): + self._watcher.cancel() + try: + await self._watcher + except asyncio.CancelledError: + pass + self._watcher = None + logger.info("LoginGuard 已停止") + + async def _check_loop(self) -> None: + """后台检测循环:定期调用 detect_login_state 并维护事件状态。""" + while True: + try: + state = await self._xdotool.detect_login_state() + logged_in = (state == "logged_in") + + # 仅在状态变化时记录日志,避免每轮噪音 + if logged_in != self._last_logged_in: + if logged_in: + logger.info( + "[LoginGuard] 微信已登录 (state=%s),恢复后台任务", + state, + ) + else: + logger.warning( + "[LoginGuard] 微信未登录 (state=%s),暂停需要登录的后台任务", + state, + ) + self._last_logged_in = logged_in + + if logged_in: + self._logged_in_event.set() + else: + self._logged_in_event.clear() + + self._current_state = state + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning("[LoginGuard] 登录状态检测异常: %s", e) + self._logged_in_event.clear() + self._current_state = "error" + + await asyncio.sleep(self._check_interval) diff --git a/bridge/woc_bridge/messaging/send_queue.py b/bridge/woc_bridge/messaging/send_queue.py index 1ef0d61..5a6b133 100644 --- a/bridge/woc_bridge/messaging/send_queue.py +++ b/bridge/woc_bridge/messaging/send_queue.py @@ -62,18 +62,25 @@ class SendQueue: pass self._worker = None - async def enqueue(self, coro_factory: CoroFactory, delay_ms: Optional[int] = None) -> Any: + async def enqueue( + self, + coro_factory: CoroFactory, + delay_ms: Optional[int] = None, + wait_timeout_ms: Optional[int] = None, + ) -> Any: """将一个返回 coroutine 的工厂入队,等待执行结果。 Args: coro_factory: 调用后返回 coroutine 的工厂函数 delay_ms: 自定义延时毫秒,None 用默认 send_delay_ms + wait_timeout_ms: 队列等待超时毫秒,None 表示无限等待; + 超时抛出 BridgeError(TIMEOUT) Returns: coroutine 的执行结果 Raises: - BridgeError: 限流命中或队列满时立即抛 RATE_LIMITED; + BridgeError: 限流命中、队列满或等待超时时抛错; 任务执行抛出的异常会透传给调用方 """ loop = asyncio.get_running_loop() @@ -90,7 +97,34 @@ class SendQueue: details={"retry_after": 3}, ) await self._queue.put((coro_factory, future, delay_ms)) - logger.info("send_queue: 入队 (pending=%d)", self._queue.qsize()) + pending = self._queue.qsize() + logger.info("send_queue: 入队 (pending=%d)", pending) + if wait_timeout_ms is not None and wait_timeout_ms > 0: + try: + return await asyncio.wait_for( + future, timeout=wait_timeout_ms / 1000.0 + ) + except asyncio.TimeoutError: + # 竞争窗口:worker 可能刚好在此时完成并 set_result。 + # 若 future 已完成且未被取消,直接取结果;否则取消 future 并抛 TIMEOUT。 + if future.done() and not future.cancelled(): + logger.info( + "send_queue: 等待超时但任务刚好完成,取结果 (pending=%d)", + pending, + ) + return future.result() + # 取消 future,worker 出队时发现 cancelled 会跳过执行 + future.cancel() + wait_sec = wait_timeout_ms / 1000.0 + logger.warning( + "send_queue: 等待超时 (pending=%d, wait_timeout=%.1fs)", + pending, wait_sec, + ) + raise BridgeError( + code="TIMEOUT", + message=f"发送队列等待超时({pending} 个待处理,已等 {wait_sec:.1f}s)", + details={"retry_after": max(1, int(self.send_delay_ms / 1000))}, + ) return await future def pending_count(self) -> int: @@ -120,6 +154,10 @@ class SendQueue: message=f"发送限流:每秒最多 {self.max_calls_per_sec} 次", details={"retry_after": retry_after}, ) + logger.debug( + "send_queue: 限流通过 (recent=%d/%d)", + len(self._recent_call_times), self.max_calls_per_sec, + ) async def _run(self) -> None: """worker 主循环。 @@ -130,6 +168,15 @@ class SendQueue: """ while True: coro_factory, future, custom_delay_ms = await self._queue.get() + # 调用方已超时取消:跳过执行与延时 + if future.cancelled(): + self._queue.task_done() + logger.info( + "send_queue: 出队任务已取消,跳过 (pending=%d)", + self._queue.qsize(), + ) + continue + logger.info("send_queue: 出队,开始处理 (pending=%d)", self._queue.qsize()) # 标记任务是否真正开始执行(用于决定 finally 是否延时) executed = False @@ -163,7 +210,7 @@ class SendQueue: future.set_exception(e) finally: self._queue.task_done() - # 仅在任务真正执行过时延时,限流失败的任务不延时 + # 仅在任务真正执行过时延时,限流失败/已取消的任务不延时 if executed: delay = custom_delay_ms if custom_delay_ms is not None else self.send_delay_ms logger.info( diff --git a/bridge/woc_bridge/messaging/streamer.py b/bridge/woc_bridge/messaging/streamer.py index 77e4dd4..191b5da 100644 --- a/bridge/woc_bridge/messaging/streamer.py +++ b/bridge/woc_bridge/messaging/streamer.py @@ -114,6 +114,8 @@ class MessageStreamer: # value 为订阅时间戳(monotonic),用于在超限时剔除最早订阅者 self._subscribers: dict[asyncio.Queue[StreamEvent], float] = {} self._global_cursor: int = 0 + # 复合游标 tie-breaker:与 create_time 共同定位分页边界,避免同秒消息重复广播 + self._global_cursor_local_id: int = 0 self._watcher: asyncio.Task | None = None # DB mtime 缓存:变化时才查 SQL self._last_db_mtime: float = 0.0 @@ -174,11 +176,17 @@ class MessageStreamer: try: q.put_nowait(StreamEvent( event="sync", - data={"cursor": self._global_cursor}, + data={ + "cursor": self._global_cursor, + "cursor_local_id": self._global_cursor_local_id, + }, )) except asyncio.QueueFull: pass - logger.info("SSE 订阅,当前订阅者 %d,cursor=%d", self.subscriber_count(), self._global_cursor) + logger.info( + "SSE 订阅,当前订阅者 %d,cursor=%d cursor_local_id=%d", + self.subscriber_count(), self._global_cursor, self._global_cursor_local_id, + ) return q def unsubscribe(self, q: asyncio.Queue[StreamEvent]) -> None: @@ -267,20 +275,25 @@ class MessageStreamer: logger.info("SSE 广播 %s: %s 订阅者=%d", event.event, event.data, self.subscriber_count()) async def _init_cursor(self) -> bool: - """把全局游标对齐到当前 DB 最大 create_time。 + """把全局游标对齐到当前 DB 最大 (create_time, local_id)。 - 避免新连接收到全量历史消息。DB 不可读时保持 0,等可读后再对齐。 + 避免新连接收到全量历史消息,也避免启动时同秒消息被重复推送。 + DB 不可读时保持 0,等可读后再对齐。 Returns: True 表示成功对齐游标(或游标已对齐);False 表示 DB 不可读, 需要在后续轮询中重试 """ try: - max_ct = await asyncio.to_thread(self._db_reader.get_max_create_time) - if max_ct and max_ct > 0: - self._global_cursor = max_ct + max_cursor = await asyncio.to_thread(self._db_reader.get_max_create_time) + if max_cursor and isinstance(max_cursor, tuple) and max_cursor[0] > 0: + self._global_cursor = max_cursor[0] + self._global_cursor_local_id = max_cursor[1] self._cursor_inited = True - logger.info("MessageStreamer 初始游标对齐到 %d", self._global_cursor) + logger.info( + "MessageStreamer 初始游标对齐到 (%d, %d)", + self._global_cursor, self._global_cursor_local_id, + ) return True # DB 可读但 message 表为空或返回 None:视为已对齐(cursor=0 合理) self._cursor_inited = True @@ -368,7 +381,10 @@ class MessageStreamer: while True: try: result = await asyncio.to_thread( - self._db_reader.get_messages_since, self._global_cursor, BATCH_LIMIT + self._db_reader.get_messages_since, + self._global_cursor, + BATCH_LIMIT, + cursor_local_id=self._global_cursor_local_id, ) except Exception as e: # DB_ENCRYPTED 时尝试重新提取 key 并重试一次 @@ -382,7 +398,10 @@ class MessageStreamer: return try: result = await asyncio.to_thread( - self._db_reader.get_messages_since, self._global_cursor, BATCH_LIMIT + self._db_reader.get_messages_since, + self._global_cursor, + BATCH_LIMIT, + cursor_local_id=self._global_cursor_local_id, ) except Exception as e2: logger.warning("MessageStreamer: 重试后仍失败: %s", e2) @@ -396,41 +415,22 @@ class MessageStreamer: return next_cursor = result.get("next_cursor", self._global_cursor) + next_cursor_local_id = result.get("next_cursor_local_id", self._global_cursor_local_id) has_more = result.get("has_more", False) self._global_cursor = next_cursor + self._global_cursor_local_id = next_cursor_local_id - # 6. 广播 + # 6. 广播(内部已打印摘要 + 逐条消息日志,此处不再重复) self._broadcast(StreamEvent( event="messages", - id=str(next_cursor), + id=f"{next_cursor}:{next_cursor_local_id}", data={ "messages": messages, "next_cursor": next_cursor, + "next_cursor_local_id": next_cursor_local_id, "has_more": has_more, }, )) - # 逐条打印通过 SSE 推送出去的消息详情 - for msg in messages: - content_preview = (msg.get("content") or "").replace("\n", "\\n") - if len(content_preview) > 100: - content_preview = content_preview[:100] + "..." - logger.info( - "SSE 推送消息详情: msg_id=%s talker=%s sender=%s is_sender=%s " - "type=%s render=%s session=%s create_time=%s content=%s", - msg.get("msg_id"), - msg.get("talker"), - msg.get("sender") or "-", - msg.get("is_sender"), - msg.get("type"), - msg.get("render_type"), - msg.get("session_type"), - msg.get("create_time"), - content_preview, - ) - logger.info( - "MessageStreamer 推送 %d 条消息, cursor=%d, 订阅者=%d", - len(messages), next_cursor, self.subscriber_count(), - ) if not has_more: return diff --git a/bridge/woc_bridge/models/contact.py b/bridge/woc_bridge/models/contact.py index 83e8f85..c2b2e34 100644 --- a/bridge/woc_bridge/models/contact.py +++ b/bridge/woc_bridge/models/contact.py @@ -4,12 +4,13 @@ from __future__ import annotations import asyncio import enum -from typing import TYPE_CHECKING, Optional +import logging +from typing import Optional from pydantic import BaseModel, Field -if TYPE_CHECKING: - from woc_bridge.messaging.friend_watcher import FriendRequestInfo + +logger = logging.getLogger("woc-bridge") # --------------------------------------------------------------------------- @@ -135,6 +136,36 @@ class FriendRequestItem(BaseModel): create_time: int +# 运行时数据结构:避免 friend_parser.py 与 friend_watcher.py 循环导入 +class FriendRequestInfo: + """好友申请信息(从 fmessage sysmsg XML 解析)。""" + + def __init__( + self, + stranger_wxid: str, + nickname: str, + verify_message: str, + scene: str, + raw_xml: str, + create_time: int, + msg_local_id: int, + ) -> None: + self.stranger_wxid = stranger_wxid + self.nickname = nickname + self.verify_message = verify_message + self.scene = scene + self.raw_xml = raw_xml + self.create_time = create_time + self.msg_local_id = msg_local_id + + def __repr__(self) -> str: + return ( + f"FriendRequestInfo(stranger_wxid={self.stranger_wxid!r}, " + f"nickname={self.nickname!r}, scene={self.scene!r}, " + f"create_time={self.create_time}, msg_local_id={self.msg_local_id})" + ) + + class FriendRequestsResponse(BaseModel): """好友申请列表响应。""" requests: list[FriendRequestItem] = Field(default_factory=list) @@ -166,6 +197,18 @@ class AutoAcceptStatus(BaseModel): accepted_count: int = 0 rejected_count: int = 0 last_processed_time: Optional[int] = None + cursor_create_time: Optional[int] = Field( + default=None, description="当前轮询 create_time 游标(调试用)" + ) + cursor_local_id: Optional[int] = Field( + default=None, description="当前轮询 local_id 游标(调试用)" + ) + db_readable: Optional[bool] = Field( + default=None, description="DB 是否可读(调试用)" + ) + breaker_state: Optional[str] = Field( + default=None, description="熔断器当前状态(调试用)" + ) class AcceptRuleConfig(BaseModel): @@ -207,34 +250,69 @@ class AcceptRuleEngine: async with self._lock: cfg = self._config + logger.info( + "[规则引擎] 开始评估 wxid=%s nickname=%s scene=%s verify=%s", + req.stranger_wxid, req.nickname, req.scene, + repr(req.verify_message[:80]) if req.verify_message else "", + ) + logger.info( + "[规则引擎] 当前配置 enabled=%s accept_all=%s allow_scenes=%s keywords=%s " + "whitelist_wxids=%s whitelist_nicknames=%s blacklist_wxids=%s blacklist_nicknames=%s", + cfg.enabled, cfg.accept_all, cfg.allow_scenes, cfg.keywords, + cfg.whitelist_wxids, cfg.whitelist_nicknames, + cfg.blacklist_wxids, cfg.blacklist_nicknames, + ) + if not cfg.enabled: + logger.info("[规则引擎] 全局开关 enabled=false → SKIP") return AcceptDecision.SKIP if cfg.accept_all: + logger.info("[规则引擎] accept_all=true → ACCEPT") return AcceptDecision.ACCEPT # 黑名单优先 if req.stranger_wxid in cfg.blacklist_wxids: + logger.info("[规则引擎] wxid 命中黑名单 → REJECT") return AcceptDecision.REJECT - if req.nickname in cfg.blacklist_nicknames: + if req.nickname and req.nickname in cfg.blacklist_nicknames: + logger.info("[规则引擎] nickname 命中黑名单 → REJECT") return AcceptDecision.REJECT + logger.info("[规则引擎] 黑名单未命中") # 场景过滤 if cfg.allow_scenes and req.scene not in cfg.allow_scenes: + logger.info( + "[规则引擎] scene=%s 不在允许列表 %s → SKIP", + req.scene, cfg.allow_scenes, + ) return AcceptDecision.SKIP + logger.info("[规则引擎] 场景检查通过 allow_scenes=%s", cfg.allow_scenes) # 白名单 if req.stranger_wxid in cfg.whitelist_wxids: + logger.info("[规则引擎] wxid 命中白名单 → ACCEPT") return AcceptDecision.ACCEPT - if req.nickname in cfg.whitelist_nicknames: + if req.nickname and req.nickname in cfg.whitelist_nicknames: + logger.info("[规则引擎] nickname 命中白名单 → ACCEPT") return AcceptDecision.ACCEPT + logger.info("[规则引擎] 白名单未命中") # 关键词 if cfg.keywords: for kw in cfg.keywords: if kw in req.verify_message: + logger.info("[规则引擎] 验证消息命中关键词 '%s' → ACCEPT", kw) return AcceptDecision.ACCEPT + logger.info( + "[规则引擎] 关键词未命中 keywords=%s verify=%s", + cfg.keywords, + repr(req.verify_message[:80]) if req.verify_message else "", + ) + else: + logger.info("[规则引擎] 未配置关键词") + logger.info("[规则引擎] 无匹配规则 → SKIP") return AcceptDecision.SKIP async def update_config(self, config: AcceptRuleConfig) -> None: diff --git a/bridge/woc_bridge/models/message.py b/bridge/woc_bridge/models/message.py index 5cae231..3aa1179 100644 --- a/bridge/woc_bridge/models/message.py +++ b/bridge/woc_bridge/models/message.py @@ -27,6 +27,9 @@ class MessagesResponse(BaseModel): messages: list[Message] = Field(default_factory=list) next_cursor: int = Field(description="下次拉取应使用的 cursor") + next_cursor_local_id: int = Field( + default=0, description="下次拉取应使用的 local_id 游标(同秒消息 tie-breaker)" + ) has_more: bool = Field(default=False, description="是否可能还有更多消息") @@ -38,6 +41,9 @@ class MessagesBySessionResponse(BaseModel): messages: list[Message] = Field(default_factory=list) next_cursor: int = Field(description="下次拉取应使用的 cursor") + next_cursor_local_id: int = Field( + default=0, description="下次拉取应使用的 local_id 游标(同秒消息 tie-breaker)" + ) has_more: bool = Field(default=False, description="是否可能还有更多消息") talker: str = Field(description="本次查询的会话对方 wxid") diff --git a/bridge/woc_bridge/routes/batch.py b/bridge/woc_bridge/routes/batch.py index 9a9e184..7e6a995 100644 --- a/bridge/woc_bridge/routes/batch.py +++ b/bridge/woc_bridge/routes/batch.py @@ -10,6 +10,7 @@ from __future__ import annotations import logging +import time from fastapi import APIRouter, Request @@ -34,8 +35,24 @@ async def submit_batch(req: BatchSendRequest, request: Request): - 429 RATE_LIMITED:日频次超限 / 批间隔不足 """ worker = _require_batch_worker() - batch_id = await worker.submit_batch(req) + t_total = time.perf_counter() + logger.info( + "send/batch: 收到请求 targets=%d content_len=%d client_request_id=%s", + len(req.targets), len(req.content), req.client_request_id or "(none)", + ) + try: + batch_id = await worker.submit_batch(req) + except BridgeError as e: + logger.warning( + "send/batch: 提交失败 %s: %s", + e.code, e.message, + ) + raise status = await worker.get_status(batch_id) + logger.info( + "send/batch: ✓ batch_id=%s targets=%d (总耗时 %.0fms)", + batch_id, len(req.targets), (time.perf_counter() - t_total) * 1000, + ) return { "success": True, "batch_id": batch_id, @@ -54,6 +71,7 @@ async def get_batch_status(batch_id: str): 异常: - 404 NOT_FOUND:batch_id 不存在 """ + logger.debug("send/batch/status: 查询 batch_id=%s", batch_id) worker = _require_batch_worker() status = await worker.get_status(batch_id) if status is None: @@ -74,14 +92,27 @@ async def cancel_batch(batch_id: str): 异常: - 404 NOT_FOUND:batch_id 不存在或已完成 """ + logger.info("send/batch/cancel: 收到请求 batch_id=%s", batch_id) worker = _require_batch_worker() - cancelled = await worker.cancel_batch(batch_id) + try: + cancelled = await worker.cancel_batch(batch_id) + except BridgeError as e: + logger.warning( + "send/batch/cancel: 失败 %s: %s (batch_id=%s)", + e.code, e.message, batch_id, + ) + raise if not cancelled: + logger.warning( + "send/batch/cancel: 失败 NOT_FOUND: batch_id 不存在或已完成 (batch_id=%s)", + batch_id, + ) raise BridgeError( code="NOT_FOUND", message=f"batch_id={batch_id} 不存在或已完成", http_status=404, ) + logger.info("send/batch/cancel: ✓ batch_id=%s", batch_id) return {"success": True, "batch_id": batch_id, "status": "cancelled"} @@ -95,6 +126,7 @@ async def list_batches(limit: int = 20): 异常: - 400 INVALID_PARAMS:limit 越界 """ + logger.debug("send/batches: 列表查询 limit=%d", limit) worker = _require_batch_worker() if limit < 1 or limit > 100: raise BridgeError( diff --git a/bridge/woc_bridge/routes/contacts.py b/bridge/woc_bridge/routes/contacts.py index c71831b..e66098e 100644 --- a/bridge/woc_bridge/routes/contacts.py +++ b/bridge/woc_bridge/routes/contacts.py @@ -2,10 +2,12 @@ from __future__ import annotations import asyncio import logging +import time from fastapi import APIRouter from woc_bridge.config import ( + _state, _require_db_reader, _require_xdotool, _require_send_queue, _require_rule_engine, _require_friend_watcher, ) @@ -415,20 +417,36 @@ async def accept_friend(req: AcceptFriendRequest) -> AcceptFriendResponse: BridgeError(WECHAT_NOT_LOGGED_IN): 未登录(HTTP 401) BridgeError(WINDOW_NOT_FOUND / SEND_FAILED / RATE_LIMITED) """ + logger.info( + "friends/accept: 收到手动通过好友申请 wxid=%s nickname=%s", + req.stranger_wxid, req.nickname, + ) if not req.stranger_wxid: + logger.warning("friends/accept: 参数校验失败 — stranger_wxid 为空") raise BridgeError(code="INVALID_PARAMS", message="stranger_wxid 不能为空") xdotool = _require_xdotool() send_queue = _require_send_queue() db_reader = _require_db_reader() + t0 = time.perf_counter() login_state = await xdotool.detect_login_state() + logger.info( + "friends/accept: 登录态检测 → %s (%.0fms)", + login_state, (time.perf_counter() - t0) * 1000, + ) if login_state != "logged_in": + logger.warning("friends/accept: 拒绝通过,登录态=%s", login_state) raise BridgeError( code="WECHAT_NOT_LOGGED_IN", message=f"当前登录态为 {login_state},无法通过好友申请", ) + logger.info( + "friends/accept: 入队 send_queue (pending=%d)", + send_queue.pending_count(), + ) + t0 = time.perf_counter() try: # lambda 用默认参数捕获 req(SendQueue.enqueue 延迟执行,避免变量被覆盖) await send_queue.enqueue( @@ -439,16 +457,26 @@ async def accept_friend(req: AcceptFriendRequest) -> AcceptFriendResponse: ) except BridgeError: # 透传 BridgeError(RATE_LIMITED / SEND_FAILED / WINDOW_NOT_FOUND 等) + logger.warning( + "friends/accept: 入队/执行失败 BridgeError (%.0fms)", + (time.perf_counter() - t0) * 1000, + ) raise except Exception as e: + logger.warning( + "friends/accept: 入队/执行失败 %s: %s (%.0fms)", + type(e).__name__, e, (time.perf_counter() - t0) * 1000, + ) raise BridgeError(code="SEND_FAILED", message=f"通过好友申请失败: {e}") logger.info( - "friends/accept: wxid=%s nickname=%s → UI 操作完成", + "friends/accept: wxid=%s nickname=%s → UI 操作完成 (%.0fms)", req.stranger_wxid, req.nickname, + (time.perf_counter() - t0) * 1000, ) # Task 10: 复用 _verify_accept_with_retry 校验好友是否真正通过 + t0 = time.perf_counter() verified: bool | None = None try: verified = await _verify_accept_with_retry( @@ -456,10 +484,14 @@ async def accept_friend(req: AcceptFriendRequest) -> AcceptFriendResponse: ) except Exception as exc: logger.warning( - "friends/accept: post_verify 异常 (wxid=%s): %s", - req.stranger_wxid, exc, + "friends/accept: post_verify 异常 (wxid=%s): %s (%.0fms)", + req.stranger_wxid, exc, (time.perf_counter() - t0) * 1000, ) verified = False + logger.info( + "friends/accept: ✓ wxid=%s verified=%s post_verify=%.0fms", + req.stranger_wxid, verified, (time.perf_counter() - t0) * 1000, + ) return AcceptFriendResponse(success=True, error=None, verified=verified) @@ -473,6 +505,10 @@ async def get_auto_accept_config() -> AcceptRuleConfig: @router.put("/api/friends/auto_accept/config", response_model=AcceptRuleConfig) async def update_auto_accept_config(config: AcceptRuleConfig) -> AcceptRuleConfig: """更新自动通过规则配置(热生效)。""" + logger.info( + "friends/auto_accept/config: 更新配置 enabled=%s accept_all=%s", + config.enabled, config.accept_all, + ) rule_engine = _require_rule_engine() await rule_engine.update_config(config) # 同步更新 watcher 的 enabled 状态 @@ -483,6 +519,9 @@ async def update_auto_accept_config(config: AcceptRuleConfig) -> AcceptRuleConfi # 用户通过 API 从 False 切到 True 时需补启动;start() 幂等,已运行则跳过) if config.enabled and not watcher.is_running: await watcher.start() + # 关闭 auto_accept 时停止 watcher 协程,避免继续轮询 + if not config.enabled and watcher.is_running: + await watcher.stop() return config @@ -490,6 +529,21 @@ async def update_auto_accept_config(config: AcceptRuleConfig) -> AcceptRuleConfi async def get_auto_accept_status() -> AutoAcceptStatus: """查询自动通过运行状态。""" watcher = _require_friend_watcher() + db_reader = _require_db_reader() + breaker = _state.accept_breaker + db_readable = await asyncio.to_thread(db_reader.is_db_accessible) + breaker_state = None + if breaker is not None: + try: + breaker_state = breaker.state.value + except Exception: + pass + logger.info( + "friends/auto_accept/status: running=%s enabled=%s db_readable=%s breaker_state=%s " + "processed=%d accepted=%d rejected=%d", + watcher.is_running, watcher.is_enabled, db_readable, breaker_state, + watcher.processed_count, watcher.accepted_count, watcher.rejected_count, + ) return AutoAcceptStatus( running=watcher.is_running, enabled=watcher.is_enabled, @@ -497,4 +551,8 @@ async def get_auto_accept_status() -> AutoAcceptStatus: accepted_count=watcher.accepted_count, rejected_count=watcher.rejected_count, last_processed_time=watcher.last_processed_time, + cursor_create_time=watcher.cursor_create_time, + cursor_local_id=watcher.cursor_local_id, + db_readable=db_readable, + breaker_state=breaker_state, ) diff --git a/bridge/woc_bridge/routes/login.py b/bridge/woc_bridge/routes/login.py index 264be83..530e569 100644 --- a/bridge/woc_bridge/routes/login.py +++ b/bridge/woc_bridge/routes/login.py @@ -47,12 +47,26 @@ async def login_qr_start() -> QrLoginStartResult: - 若微信已登录,本接口仍会返回二维码截图(调用方应先调 /api/status 判断 login_state,避免重复登录) """ + logger.info("login/qr/start: 收到请求") xdotool = _require_xdotool() qr_capture = _require_qr_capture() # 激活微信窗口(非阻塞,避免 VNC 无人操作时 --sync 死等) - await xdotool._activate_window_fast() - qr_data_url = await qr_capture.capture_qr_code() + t0 = time.perf_counter() + logger.info("login/qr/start: 激活窗口 + 截图开始") + try: + await xdotool._activate_window_fast() + qr_data_url = await qr_capture.capture_qr_code() + except BridgeError as e: + logger.warning( + "login/qr/start: 失败 BridgeError code=%s msg=%s (%.0fms)", + e.code, e.message, (time.perf_counter() - t0) * 1000, + ) + raise + logger.info( + "login/qr/start: 激活窗口 + 截图完成 (%.0fms)", + (time.perf_counter() - t0) * 1000, + ) return QrLoginStartResult( qr_data_url=qr_data_url, @@ -167,25 +181,51 @@ async def login_logout() -> LogoutResponse: 不会拉起新进程;如需重新登录走 /api/login/qr/start - 不删除任何文件:登录态由微信自身管理,bridge 不破坏数据 """ + logger.info("login/logout: 收到请求") + t_total = time.perf_counter() xdotool = _require_xdotool() # 记录调用前的登录态(用于返回 message) + t0 = time.perf_counter() state_before = await xdotool.detect_login_state() + logger.info( + "login/logout: 登录态检测 → %s (%.0fms)", + state_before, (time.perf_counter() - t0) * 1000, + ) # 若已登录,执行 UI 退出操作 if state_before == LoginState.LOGGED_IN.value: + t1 = time.perf_counter() + logger.info("login/logout: logout 调用开始") try: await xdotool.logout() - except BridgeError: + except BridgeError as e: + logger.warning( + "login/logout: 失败 BridgeError code=%s msg=%s (%.0fms)", + e.code, e.message, (time.perf_counter() - t1) * 1000, + ) raise except Exception as e: + logger.warning( + "login/logout: 失败 %s: %s (%.0fms)", + type(e).__name__, e, (time.perf_counter() - t1) * 1000, + ) raise BridgeError( code="LOGOUT_FAILED", message=f"退出登录失败: {e}", ) + logger.info( + "login/logout: logout 调用完成 (%.0fms)", + (time.perf_counter() - t1) * 1000, + ) + logger.info( + "login/logout: ✓ (总耗时 %.0fms)", + (time.perf_counter() - t_total) * 1000, + ) return LogoutResponse(success=True, message="已退出登录") # 未登录,幂等返回 + logger.info("login/logout: 当前未登录,无需登出") return LogoutResponse(success=True, message="当前未登录,无需退出") @@ -222,22 +262,48 @@ async def wechat_restart() -> RestartResponse: - 调用方应在收到 success=true 后调 /api/status 确认 login_state 恢复到 logged_in(autostart 拉起后微信会自动尝试恢复登录) """ + timeout_sec = 30 + logger.info("wechat/restart: 收到请求 timeout=%ds", timeout_sec) + t_total = time.perf_counter() xdotool = _require_xdotool() # 记录调用前是否在运行 + t0 = time.perf_counter() was_running = await xdotool.is_wechat_running() + logger.info( + "wechat/restart: 进程检测 → running=%s (%.0fms)", + was_running, (time.perf_counter() - t0) * 1000, + ) + t1 = time.perf_counter() + logger.info("wechat/restart: restart 调用开始") try: - new_pid = await xdotool.restart_wechat(timeout_sec=30) - except BridgeError: + new_pid = await xdotool.restart_wechat(timeout_sec=timeout_sec) + except BridgeError as e: + logger.warning( + "wechat/restart: 失败 BridgeError code=%s msg=%s (%.0fms)", + e.code, e.message, (time.perf_counter() - t1) * 1000, + ) raise except Exception as e: # 非 timeout 的意外错误(如 pgrep 不可用)归为内部错误, # 不误报为 RESTART_TIMEOUT(调用方会按 timeout 语义重试,无意义) + logger.warning( + "wechat/restart: 失败 %s: %s (%.0fms)", + type(e).__name__, e, (time.perf_counter() - t1) * 1000, + ) raise BridgeError( code="BRIDGE_INTERNAL_ERROR", message=f"重启微信失败: {e}", ) + logger.info( + "wechat/restart: restart 调用完成 (%.0fms)", + (time.perf_counter() - t1) * 1000, + ) message = "微信已重启" if was_running else "微信已启动" + logger.info( + "wechat/restart: ✓ (总耗时 %.0fms)", + (time.perf_counter() - t_total) * 1000, + ) return RestartResponse(success=True, message=message, pid=new_pid) diff --git a/bridge/woc_bridge/routes/messages.py b/bridge/woc_bridge/routes/messages.py index 60ade31..c827c96 100644 --- a/bridge/woc_bridge/routes/messages.py +++ b/bridge/woc_bridge/routes/messages.py @@ -25,13 +25,17 @@ router = APIRouter() @with_db_retry async def get_messages_since( cursor: int = 0, + cursor_local_id: int = Query( + 0, + description="上次拉取的最大 local_id(同 create_time 时的 tie-breaker,默认 0)", + ), limit: int = 50, is_sender: Optional[bool] = Query( None, description="按发送方向过滤:True=仅本人发送 / False=仅对方发送 / None=不过滤(默认)", ), ) -> MessagesResponse: - """增量消息拉取(按 create_time 游标)。 + """增量消息拉取(按 create_time + local_id 复合游标)。 channels PullerAdapter 的核心接口:调用方维护本地 cursor(首次为 0), 每次拉取 create_time > cursor 的消息,把响应中 next_cursor 存下来作为 @@ -39,13 +43,14 @@ async def get_messages_since( Args: cursor: 上次拉取的最大 create_time,首次传 0(拉全量) + cursor_local_id: 上次拉取的最大 local_id,用于同秒消息去重(默认 0) limit: 最多返回条数,1~200,默认 50。建议用 /api/status 返回的 max_batch_size 作为上限参考 is_sender: 按发送方向过滤,True=仅本人发送 / False=仅对方发送 / None=不过滤(默认)。SQL 层过滤,无法定位本人时返回空 Returns: - MessagesResponse:含 messages 列表 / next_cursor / has_more + MessagesResponse:含 messages 列表 / next_cursor / next_cursor_local_id / has_more Raises: BridgeError(INVALID_PARAMS): cursor<0 或 limit 越界(HTTP 400) @@ -64,6 +69,11 @@ async def get_messages_since( code="INVALID_PARAMS", message=f"cursor 必须 >= 0,收到 {cursor}", ) + if cursor_local_id < 0: + raise BridgeError( + code="INVALID_PARAMS", + message=f"cursor_local_id 必须 >= 0,收到 {cursor_local_id}", + ) if limit < 1 or limit > 200: raise BridgeError( code="INVALID_PARAMS", @@ -77,12 +87,15 @@ async def get_messages_since( # DB 读取是同步阻塞操作,用 to_thread 包装 result = await asyncio.to_thread( - db_reader.get_messages_since, cursor, limit, is_sender + db_reader.get_messages_since, cursor, limit, is_sender, + cursor_local_id=cursor_local_id, ) msg_count = len(result.get("messages", [])) logger.info( - "messages/since: cursor=%d limit=%d is_sender=%s → 返回 %d 条, next_cursor=%s has_more=%s", - cursor, limit, is_sender, msg_count, result.get("next_cursor"), result.get("has_more"), + "messages/since: cursor=%d cursor_local_id=%d limit=%d is_sender=%s → " + "返回 %d 条, next_cursor=%s next_cursor_local_id=%s has_more=%s", + cursor, cursor_local_id, limit, is_sender, msg_count, + result.get("next_cursor"), result.get("next_cursor_local_id"), result.get("has_more"), ) return MessagesResponse(**result) @@ -171,6 +184,10 @@ async def stream_messages(request: Request) -> StreamingResponse: async def get_messages_by_session( talker: str, cursor: int = 0, + cursor_local_id: int = Query( + 0, + description="上次拉取的最大 local_id(同 create_time 时的 tie-breaker,默认 0)", + ), limit: int = 50, direction: str = "before", is_sender: Optional[bool] = Query( @@ -187,13 +204,14 @@ async def get_messages_by_session( talker: 会话对方 wxid(群消息为 chatroom id) cursor: 游标,首次传 0;before 模式取该时间之前的旧消息, after 模式取该时间之后的新消息 + cursor_local_id: 上次拉取的最大 local_id,用于同秒消息去重(默认 0) limit: 最多返回条数,1~200,默认 50 direction: before(默认,往前翻历史)/ after(往后拉新消息) is_sender: 按发送方向过滤,True=仅本人发送 / False=仅对方发送 / None=不过滤(默认)。SQL 层过滤,无法定位本人时返回空 Returns: - MessagesBySessionResponse:含 messages / next_cursor / has_more / talker + MessagesBySessionResponse:含 messages / next_cursor / next_cursor_local_id / has_more / talker Raises: BridgeError(INVALID_PARAMS): talker 为空、limit 越界、direction 非法(HTTP 400) @@ -203,7 +221,7 @@ async def get_messages_by_session( Notes: - before 模式返回升序消息(旧在前、新在后),便于客户端追加到列表头部 - cursor=0 + before 取最新 limit 条;cursor=某消息时间 + before 取更旧的消息 - - has_more=true 时用 next_cursor 继续翻页 + - has_more=true 时用 next_cursor + next_cursor_local_id 继续翻页 - 表不存在时返回空列表而非报错(会话可能从未有过消息) """ if not talker: @@ -223,18 +241,25 @@ async def get_messages_by_session( code="INVALID_PARAMS", message=f"cursor 必须 >= 0,收到 {cursor}", ) + if cursor_local_id < 0: + raise BridgeError( + code="INVALID_PARAMS", + message=f"cursor_local_id 必须 >= 0,收到 {cursor_local_id}", + ) db_reader = _require_db_reader() await _check_db_readable() result = await asyncio.to_thread( - db_reader.get_messages_by_session, talker, cursor, limit, direction, is_sender + db_reader.get_messages_by_session, + talker, cursor, limit, direction, cursor_local_id, is_sender, ) msg_count = len(result.get("messages", [])) logger.info( - "messages/by_session: talker=%s cursor=%d direction=%s limit=%d is_sender=%s → 返回 %d 条, next_cursor=%s has_more=%s", - talker, cursor, direction, limit, is_sender, msg_count, - result.get("next_cursor"), result.get("has_more"), + "messages/by_session: talker=%s cursor=%d cursor_local_id=%d direction=%s " + "limit=%d is_sender=%s → 返回 %d 条, next_cursor=%s next_cursor_local_id=%s has_more=%s", + talker, cursor, cursor_local_id, direction, limit, is_sender, msg_count, + result.get("next_cursor"), result.get("next_cursor_local_id"), result.get("has_more"), ) return MessagesBySessionResponse(**result) diff --git a/bridge/woc_bridge/routes/moments.py b/bridge/woc_bridge/routes/moments.py index e65aabe..d44a54c 100644 --- a/bridge/woc_bridge/routes/moments.py +++ b/bridge/woc_bridge/routes/moments.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import logging import os +import time from typing import Optional from fastapi import APIRouter @@ -136,24 +137,40 @@ async def publish_moment(req: MomentPublishRequest) -> MomentPublishResponse: """ xdotool = _require_xdotool() send_queue = _require_send_queue() + t_total = time.perf_counter() # 校验请求体 if not req.content: + logger.warning("moments/publish: 参数校验失败 — content 为空") raise BridgeError(code="INVALID_PARAMS", message="content 不能为空") + logger.info("moments/publish: 收到请求 content_len=%d", len(req.content)) + # 检查登录态 + t0 = time.perf_counter() login_state = await xdotool.detect_login_state() + logger.info( + "moments/publish: 登录态检测 → %s (%.0fms)", + login_state, (time.perf_counter() - t0) * 1000, + ) if login_state != "logged_in": + logger.warning("moments/publish: 拒绝,登录态=%s", login_state) raise BridgeError( code="WECHAT_NOT_LOGGED_IN", message=f"当前登录态为 {login_state},无法发表朋友圈", ) # 经发送队列串行执行(避免与发消息 UI 竞态) + logger.info("moments/publish: 入队 send_queue (pending=%d)", send_queue.pending_count()) try: + t0 = time.perf_counter() local_moment_id = await send_queue.enqueue( lambda: xdotool.publish_moment(req.content) ) + logger.info( + "moments/publish: 队列执行完成 → local_moment_id=%s (%.0fms)", + local_moment_id, (time.perf_counter() - t0) * 1000, + ) except BridgeError: raise except Exception as e: @@ -163,8 +180,9 @@ async def publish_moment(req: MomentPublishRequest) -> MomentPublishResponse: ) logger.info( - "moments/publish: content_len=%d → 成功 local_moment_id=%s", + "moments/publish: content_len=%d → 成功 local_moment_id=%s (总耗时 %.0fms)", len(req.content), local_moment_id, + (time.perf_counter() - t_total) * 1000, ) return MomentPublishResponse( success=True, @@ -210,26 +228,46 @@ async def share_article_moment(req: MomentShareArticleRequest) -> MomentShareArt """ xdotool = _require_xdotool() send_queue = _require_send_queue() + t_total = time.perf_counter() # 校验请求体 if not req.public_account: + logger.warning("moments/share_article: 参数校验失败 — public_account 为空") raise BridgeError(code="INVALID_PARAMS", message="public_account 不能为空") if req.article_index < 1: + logger.warning( + "moments/share_article: 参数校验失败 — article_index=%d (public_account=%s)", + req.article_index, req.public_account, + ) raise BridgeError( code="INVALID_PARAMS", message=f"article_index 必须 >= 1,收到 {req.article_index}", ) + logger.info( + "moments/share_article: 收到请求 public_account=%s index=%d comment_len=%d", + req.public_account, req.article_index, + len(req.comment) if req.comment else 0, + ) + # 检查登录态 + t0 = time.perf_counter() login_state = await xdotool.detect_login_state() + logger.info( + "moments/share_article: 登录态检测 → %s (%.0fms)", + login_state, (time.perf_counter() - t0) * 1000, + ) if login_state != "logged_in": + logger.warning("moments/share_article: 拒绝,登录态=%s", login_state) raise BridgeError( code="WECHAT_NOT_LOGGED_IN", message=f"当前登录态为 {login_state},无法分享文章", ) # 经发送队列串行执行(避免与发消息 UI 竞态) + logger.info("moments/share_article: 入队 send_queue (pending=%d)", send_queue.pending_count()) try: + t0 = time.perf_counter() local_moment_id = await send_queue.enqueue( lambda: xdotool.share_article_moment( req.public_account, @@ -237,6 +275,10 @@ async def share_article_moment(req: MomentShareArticleRequest) -> MomentShareArt comment=req.comment, ) ) + logger.info( + "moments/share_article: 队列执行完成 → local_moment_id=%s (%.0fms)", + local_moment_id, (time.perf_counter() - t0) * 1000, + ) except BridgeError: raise except Exception as e: @@ -246,10 +288,11 @@ async def share_article_moment(req: MomentShareArticleRequest) -> MomentShareArt ) logger.info( - "moments/share_article: public_account=%s index=%d comment_len=%d → 成功 local_moment_id=%s", + "moments/share_article: public_account=%s index=%d comment_len=%d → 成功 local_moment_id=%s (总耗时 %.0fms)", req.public_account, req.article_index, len(req.comment) if req.comment else 0, local_moment_id, + (time.perf_counter() - t_total) * 1000, ) return MomentShareArticleResponse( success=True, @@ -283,7 +326,12 @@ async def moment_like(req: MomentLikeRequest) -> MomentLikeResponse: - experimental:朋友圈"赞/评论"按钮坐标为估算值 - 自动进入朋友圈页面,无需调用方预先进入 """ + t_total = time.perf_counter() if req.moment_index < 1: + logger.warning( + "moments/like: 参数校验失败 — moment_index=%d", + req.moment_index, + ) raise BridgeError( code="INVALID_PARAMS", message=f"moment_index 必须 >= 1,收到 {req.moment_index}", @@ -292,8 +340,16 @@ async def moment_like(req: MomentLikeRequest) -> MomentLikeResponse: xdotool = _require_xdotool() send_queue = _require_send_queue() + logger.info("moments/like: 收到请求 index=%d", req.moment_index) + + t0 = time.perf_counter() login_state = await xdotool.detect_login_state() + logger.info( + "moments/like: 登录态检测 → %s (%.0fms)", + login_state, (time.perf_counter() - t0) * 1000, + ) if login_state != "logged_in": + logger.warning("moments/like: 拒绝,登录态=%s", login_state) raise BridgeError( code="WECHAT_NOT_LOGGED_IN", message=f"当前登录态为 {login_state},无法点赞", @@ -317,8 +373,14 @@ async def moment_like(req: MomentLikeRequest) -> MomentLikeResponse: return False verified: bool | None = None + logger.info("moments/like: 入队 send_queue (pending=%d)", send_queue.pending_count()) try: + t0 = time.perf_counter() verified = await send_queue.enqueue(_like_and_verify) + logger.info( + "moments/like: 队列执行完成 → verified=%s (%.0fms)", + verified, (time.perf_counter() - t0) * 1000, + ) except BridgeError: raise except Exception as e: @@ -327,7 +389,10 @@ async def moment_like(req: MomentLikeRequest) -> MomentLikeResponse: message=f"点赞失败: {e}", ) - logger.info("moments/like: index=%d → UI 操作完成", req.moment_index) + logger.info( + "moments/like: index=%d → UI 操作完成 (总耗时 %.0fms)", + req.moment_index, (time.perf_counter() - t_total) * 1000, + ) return MomentLikeResponse(success=True, error=None, verified=verified) @@ -355,9 +420,15 @@ async def moment_comment(req: MomentCommentRequest) -> MomentCommentResponse: - experimental:坐标为估算值 - 自动进入朋友圈页面,无需调用方预先进入 """ + t_total = time.perf_counter() if not req.comment: + logger.warning("moments/comment: 参数校验失败 — comment 为空") raise BridgeError(code="INVALID_PARAMS", message="comment 不能为空") if req.moment_index < 1: + logger.warning( + "moments/comment: 参数校验失败 — moment_index=%d (comment_len=%d)", + req.moment_index, len(req.comment), + ) raise BridgeError( code="INVALID_PARAMS", message=f"moment_index 必须 >= 1,收到 {req.moment_index}", @@ -367,8 +438,19 @@ async def moment_comment(req: MomentCommentRequest) -> MomentCommentResponse: send_queue = _require_send_queue() db_reader = _require_db_reader() + logger.info( + "moments/comment: 收到请求 index=%d comment_len=%d", + req.moment_index, len(req.comment), + ) + + t0 = time.perf_counter() login_state = await xdotool.detect_login_state() + logger.info( + "moments/comment: 登录态检测 → %s (%.0fms)", + login_state, (time.perf_counter() - t0) * 1000, + ) if login_state != "logged_in": + logger.warning("moments/comment: 拒绝,登录态=%s", login_state) raise BridgeError( code="WECHAT_NOT_LOGGED_IN", message=f"当前登录态为 {login_state},无法评论", @@ -392,8 +474,14 @@ async def moment_comment(req: MomentCommentRequest) -> MomentCommentResponse: return False verified: bool | None = None + logger.info("moments/comment: 入队 send_queue (pending=%d)", send_queue.pending_count()) try: + t0 = time.perf_counter() verified = await send_queue.enqueue(_comment_and_verify) + logger.info( + "moments/comment: 队列执行完成 → verified=%s (%.0fms)", + verified, (time.perf_counter() - t0) * 1000, + ) except BridgeError: raise except Exception as e: @@ -403,8 +491,9 @@ async def moment_comment(req: MomentCommentRequest) -> MomentCommentResponse: ) logger.info( - "moments/comment: index=%d comment_len=%d → UI 操作完成", + "moments/comment: index=%d comment_len=%d → UI 操作完成 (总耗时 %.0fms)", req.moment_index, len(req.comment), + (time.perf_counter() - t_total) * 1000, ) return MomentCommentResponse(success=True, error=None, verified=verified) @@ -435,7 +524,12 @@ async def moment_delete(req: MomentDeleteRequest) -> MomentDeleteResponse: - 自动进入朋友圈页面,避免在聊天界面误删消息 - 只能删除自己发的朋友圈;长按别人的朋友圈不会出现"删除"项 """ + t_total = time.perf_counter() if req.moment_index < 1: + logger.warning( + "moments/delete: 参数校验失败 — moment_index=%d", + req.moment_index, + ) raise BridgeError( code="INVALID_PARAMS", message=f"moment_index 必须 >= 1,收到 {req.moment_index}", @@ -445,8 +539,16 @@ async def moment_delete(req: MomentDeleteRequest) -> MomentDeleteResponse: send_queue = _require_send_queue() db_reader = _require_db_reader() + logger.info("moments/delete: 收到请求 index=%d", req.moment_index) + + t0 = time.perf_counter() login_state = await xdotool.detect_login_state() + logger.info( + "moments/delete: 登录态检测 → %s (%.0fms)", + login_state, (time.perf_counter() - t0) * 1000, + ) if login_state != "logged_in": + logger.warning("moments/delete: 拒绝,登录态=%s", login_state) raise BridgeError( code="WECHAT_NOT_LOGGED_IN", message=f"当前登录态为 {login_state},无法删除朋友圈", @@ -455,15 +557,34 @@ async def moment_delete(req: MomentDeleteRequest) -> MomentDeleteResponse: # P0 修复:删除前捕获朋友圈数量基线,供 post_verify 比对。 # 原实现 post_verify 只查 status=ok 就返回 True,完全未校验删除生效。 baseline_count: Optional[int] = None + t0 = time.perf_counter() try: result = await asyncio.to_thread(db_reader.get_moments_timeline, 0, 50) if result.get("status") == "ok": baseline_count = len(result.get("moments", [])) - except Exception: - pass + logger.info( + "moments/delete: baseline_count 捕获成功 → %d (%.0fms)", + baseline_count, (time.perf_counter() - t0) * 1000, + ) + else: + logger.warning( + "moments/delete: baseline_count 捕获失败,status=%s (%.0fms)", + result.get("status"), (time.perf_counter() - t0) * 1000, + ) + except Exception as exc: + logger.warning( + "moments/delete: baseline_count 捕获异常: %s (%.0fms)", + exc, (time.perf_counter() - t0) * 1000, + ) + logger.info("moments/delete: 入队 send_queue (pending=%d)", send_queue.pending_count()) try: + t0 = time.perf_counter() await send_queue.enqueue(lambda: xdotool.moment_delete(req.moment_index)) + logger.info( + "moments/delete: 队列执行完成 (%.0fms)", + (time.perf_counter() - t0) * 1000, + ) except BridgeError: raise except Exception as e: @@ -472,7 +593,10 @@ async def moment_delete(req: MomentDeleteRequest) -> MomentDeleteResponse: message=f"删除朋友圈失败: {e}", ) - logger.info("moments/delete: index=%d → UI 操作完成", req.moment_index) + logger.info( + "moments/delete: index=%d → UI 操作完成 (总耗时 %.0fms)", + req.moment_index, (time.perf_counter() - t_total) * 1000, + ) # Task 11: post_verify 校验 MomentsPost 记录是否已删除 verified: bool | None = None @@ -516,16 +640,26 @@ async def publish_moment_with_image( - experimental:相机按钮、文件选择器、发表按钮坐标均为估算值 - image_path 必须是容器内可访问的绝对路径 """ + t_total = time.perf_counter() if not req.image_path: + logger.warning("moments/publish_image: 参数校验失败 — image_path 为空") raise BridgeError(code="INVALID_PARAMS", message="image_path 不能为空") # 路径白名单:仅允许容器内安全目录的图片(防止 /etc/shadow 等敏感文件被读取) _real = os.path.realpath(req.image_path) if not _real.startswith(_SAFE_IMAGE_DIRS): + logger.warning( + "moments/publish_image: 路径校验失败 — image_path=%s 不在安全目录 %s", + _real, _SAFE_IMAGE_DIRS, + ) raise BridgeError( code="INVALID_PARAMS", message=f"image_path 必须位于安全目录 {_SAFE_IMAGE_DIRS} 内,收到 {_real}", ) if not os.path.isfile(req.image_path) or not os.access(req.image_path, os.R_OK): + logger.warning( + "moments/publish_image: 路径校验失败 — 图片不存在或不可读 image_path=%s", + req.image_path, + ) raise BridgeError( code="INVALID_PARAMS", message=f"图片不存在或不可读: {req.image_path}", @@ -534,17 +668,34 @@ async def publish_moment_with_image( xdotool = _require_xdotool() send_queue = _require_send_queue() + logger.info( + "moments/publish_image: 收到请求 image=%s content_len=%d", + req.image_path, len(req.content) if req.content else 0, + ) + + t0 = time.perf_counter() login_state = await xdotool.detect_login_state() + logger.info( + "moments/publish_image: 登录态检测 → %s (%.0fms)", + login_state, (time.perf_counter() - t0) * 1000, + ) if login_state != "logged_in": + logger.warning("moments/publish_image: 拒绝,登录态=%s", login_state) raise BridgeError( code="WECHAT_NOT_LOGGED_IN", message=f"当前登录态为 {login_state},无法发表朋友圈", ) + logger.info("moments/publish_image: 入队 send_queue (pending=%d)", send_queue.pending_count()) try: + t0 = time.perf_counter() local_moment_id = await send_queue.enqueue( lambda: xdotool.publish_moment_with_image(req.image_path, req.content) ) + logger.info( + "moments/publish_image: 队列执行完成 → local_moment_id=%s (%.0fms)", + local_moment_id, (time.perf_counter() - t0) * 1000, + ) except BridgeError: raise except Exception as e: @@ -554,8 +705,9 @@ async def publish_moment_with_image( ) logger.info( - "moments/publish_image: image=%s content_len=%d → 成功 local_moment_id=%s", + "moments/publish_image: image=%s content_len=%d → 成功 local_moment_id=%s (总耗时 %.0fms)", req.image_path, len(req.content) if req.content else 0, local_moment_id, + (time.perf_counter() - t_total) * 1000, ) return MomentPublishImageResponse( success=True, diff --git a/bridge/woc_bridge/routes/screenshot.py b/bridge/woc_bridge/routes/screenshot.py index 6336f2e..ad4bacc 100644 --- a/bridge/woc_bridge/routes/screenshot.py +++ b/bridge/woc_bridge/routes/screenshot.py @@ -6,6 +6,7 @@ from fastapi import APIRouter from fastapi.responses import Response from woc_bridge.config import _require_qr_capture +from woc_bridge.models import BridgeError logger = logging.getLogger("woc-bridge") router = APIRouter() @@ -34,8 +35,16 @@ async def screenshot() -> Response: - 与 /api/login/qr/start 区别:本接口截全屏,qr/start 截二维码区域 - 大屏幕截图可能 > 1MB,调用方注意带宽 """ + logger.info("screenshot: 收到请求") qr_capture = _require_qr_capture() - png_bytes = await qr_capture.capture_full_screenshot() - logger.info("screenshot: → 截图成功 %d bytes", len(png_bytes)) + try: + png_bytes = await qr_capture.capture_full_screenshot() + except BridgeError as e: + logger.warning("screenshot: 失败 %s: %s", e.code, e.message) + raise + except Exception as e: + logger.warning("screenshot: 失败 %s: %s", type(e).__name__, e) + raise + logger.info("screenshot: ✓ 截图成功 %d bytes", len(png_bytes)) return Response(content=png_bytes, media_type="image/png") diff --git a/bridge/woc_bridge/routes/send.py b/bridge/woc_bridge/routes/send.py index 930ff6b..a763f6e 100644 --- a/bridge/woc_bridge/routes/send.py +++ b/bridge/woc_bridge/routes/send.py @@ -32,10 +32,11 @@ router = APIRouter() # 路由:POST /api/send/text # --------------------------------------------------------------------------- # display_name 解析缓存:wxid -> (display_name, timestamp) -# 联系人备注/昵称不常变,5 分钟 TTL 足够;最大 256 条防内存膨胀 +# 联系人备注/昵称不常变,5 分钟 TTL 足够;最大 1024 条防内存膨胀 _display_name_cache: dict[str, tuple[str, float]] = {} _DISPLAY_NAME_CACHE_TTL = 300.0 # 5 分钟 -_DISPLAY_NAME_CACHE_MAX = 256 +_DISPLAY_NAME_CACHE_MAX = 1024 +_DISPLAY_NAME_WARMUP_LIMIT = 1000 # 启动时预热最近联系人数量 async def _resolve_display_name( @@ -82,6 +83,48 @@ async def _resolve_display_name( return resolved +async def warm_display_name_cache(db_reader: Optional[DbReader]) -> int: + """启动时异步预热 display_name 缓存。 + + 从高并发场景看,首批 30-100 个请求可能同时到达,若缓存为空会并发查询 + contact.db。预热最近 1000 个联系人可显著降低启动初期的 DB 压力。 + + Args: + db_reader: DbReader 实例 + + Returns: + 预热写入的条目数 + """ + if db_reader is None: + return 0 + t0 = time.perf_counter() + try: + result = await asyncio.to_thread( + db_reader.get_contacts, "", _DISPLAY_NAME_WARMUP_LIMIT + ) + except Exception as exc: + logger.warning("warm_display_name_cache: 预热失败 %s", exc) + return 0 + + contacts = result.get("contacts", []) + warmed = 0 + now = time.monotonic() + for contact in contacts: + wxid = contact.get("username") or contact.get("wxid") + if not wxid: + continue + resolved = contact.get("remark") or contact.get("nickname") or wxid + # 启动时批量写入,暂不触发 LRU 淘汰 + _display_name_cache[wxid] = (resolved, now) + warmed += 1 + + logger.info( + "warm_display_name_cache: 预热 %d 条联系人 (%.0fms)", + warmed, (time.perf_counter() - t0) * 1000, + ) + return warmed + + async def _get_latest_create_time_for_talker( db_reader: Optional[DbReader], to_wxid: str, @@ -348,15 +391,30 @@ async def _send_text_via_flow(req: SendTextRequest) -> SendResponse: code="BRIDGE_INTERNAL_ERROR", message="orchestrator not initialized" ) + t_total = time.perf_counter() + # 校验参数 if not req.to_wxid: + logger.warning("send/text: 参数校验失败 — to_wxid 为空 (flow 路径)") raise BridgeError(code="INVALID_PARAMS", message="to_wxid 不能为空") if not req.content: + logger.warning("send/text: 参数校验失败 — content 为空 (to_wxid=%s, flow 路径)", req.to_wxid) raise BridgeError(code="INVALID_PARAMS", message="content 不能为空") + logger.info( + "send/text: 收到请求 (flow) to_wxid=%s content_len=%d display_name=%s client_request_id=%s", + req.to_wxid, len(req.content), req.display_name or "(未提供)", + req.client_request_id or "(none)", + ) + # 检查登录态(与 legacy 路径一致) + t0 = time.perf_counter() xdotool = _require_xdotool() login_state = await xdotool.detect_login_state() + logger.info( + "send/text: 登录态检测 (flow) → %s (%.0fms)", + login_state, (time.perf_counter() - t0) * 1000, + ) if login_state != "logged_in": logger.warning("send/text: 拒绝发送(flow 路径),登录态=%s", login_state) raise BridgeError( @@ -365,9 +423,14 @@ async def _send_text_via_flow(req: SendTextRequest) -> SendResponse: ) # 解析 display_name(复用现有 _resolve_display_name) + t0 = time.perf_counter() display_name = await _resolve_display_name( _state.db_reader, req.to_wxid, req.display_name ) + logger.info( + "send/text: display_name 解析 (flow) → %s (%.0fms)", + display_name, (time.perf_counter() - t0) * 1000, + ) # 调用 FlowOrchestrator result = await orchestrator.send_text( @@ -379,6 +442,11 @@ async def _send_text_via_flow(req: SendTextRequest) -> SendResponse: # FlowResult 转 SendResponse if result.success: + logger.info( + "send/text: ✓ (flow) to=%s display_name=%s local_send_id=%s verified=%s skipped=%s (总耗时 %.0fms)", + req.to_wxid, display_name, result.local_id, result.verified, result.skipped, + (time.perf_counter() - t_total) * 1000, + ) return SendResponse( success=True, local_send_id=result.local_id, @@ -389,6 +457,11 @@ async def _send_text_via_flow(req: SendTextRequest) -> SendResponse: # 失败:抛 BridgeError(保持与 legacy 一致的错误语义) error_code = result.error_code or "SEND_FAILED" + logger.warning( + "send/text: ✗ (flow) to=%s failed code=%s error=%s (总耗时 %.0fms)", + req.to_wxid, error_code, result.error, + (time.perf_counter() - t_total) * 1000, + ) # 已知错误码直接透传(含可重试错误码 RATE_LIMITED/SEND_TIMEOUT 等), # ERROR_CODES 表已补全所有 Flow 错误码的 HTTP 映射 if error_code in ERROR_CODES: diff --git a/bridge/woc_bridge/ui/actions.py b/bridge/woc_bridge/ui/actions.py index 20a3e72..33d8e44 100644 --- a/bridge/woc_bridge/ui/actions.py +++ b/bridge/woc_bridge/ui/actions.py @@ -51,6 +51,23 @@ class Actions: """ self.backend = backend self.locator = locator + # 截图缓存:同一次 flow 内连续查找可避免重复截图(CPU/IO 优化) + self._shot_cache: Optional[tuple[float, bytes]] = None + self._shot_cache_ttl_ms = 150 + + def _get_cached_screenshot(self) -> Optional[bytes]: + """返回未过期的缓存截图,若无则 None。""" + if self._shot_cache is None: + return None + ts, shot = self._shot_cache + if (time.monotonic() - ts) * 1000 > self._shot_cache_ttl_ms: + self._shot_cache = None + return None + return shot + + def _set_cached_screenshot(self, shot: bytes) -> None: + """写入截图缓存。""" + self._shot_cache = (time.monotonic(), shot) # ------------------------------------------------------------------ # 元素查找 @@ -79,7 +96,10 @@ class Actions: and not self.locator.is_circuited(kind) and self.locator.opencv is not None ): - shot = await self.backend.screenshot() + shot = self._get_cached_screenshot() + 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) try: result = await self.locator.opencv.find_template( @@ -100,6 +120,10 @@ class Actions: # result = (x, y, w, h, confidence)(绝对坐标,含 ROI 偏移) x, y, w, h, conf = result self.locator.record_image_success(kind) + logger.info( + "[action] image match success kind=%s at (%d,%d) conf=%.3f", + kind, x + w // 2, y + h // 2, conf, + ) return ElementHandle( selector=sel, x=x + w // 2, @@ -120,6 +144,7 @@ class Actions: f"image_match_failed kind={kind}" ) + logger.debug("[action] geom fallback kind=%s", kind) return self._geom_find(sel, win_geom) async def click_element( @@ -161,10 +186,19 @@ class Actions: win_geom: 当前窗口几何 clear_first: 是否先 BackSpace×30 清空旧内容 """ + t0 = time.perf_counter() + logger.info( + "[action] type_into start kind=%s text_len=%d clear_first=%s", + kind, len(text), clear_first, + ) await self.click_element(kind, win_geom) if clear_first: await self.backend.key_press("BackSpace", repeat=30) await self.backend.type_text(text) + logger.info( + "[action] type_into done kind=%s text_len=%d (%.0fms)", + kind, len(text), (time.perf_counter() - t0) * 1000, + ) async def wait_for( self, @@ -184,20 +218,41 @@ class Actions: Returns: ElementHandle 或 None(超时) """ + t0 = time.perf_counter() deadline = time.monotonic() + timeout + attempts = 0 + last_err: Optional[Exception] = None while time.monotonic() < deadline: + attempts += 1 try: elem = await self.find_element(kind, win_geom) if elem: + logger.info( + "[action] wait_for OK kind=%s strategy=%s attempts=%d (%.0fms)", + kind, elem.strategy, attempts, + (time.perf_counter() - t0) * 1000, + ) return elem - except ElementNotFoundError: - pass + except ElementNotFoundError as e: + last_err = e await asyncio.sleep(interval) + logger.warning( + "[action] wait_for TIMEOUT kind=%s attempts=%d timeout=%.1fs last_err=%s", + kind, attempts, timeout, last_err, + ) return None async def screenshot(self) -> bytes: - """截图代理方法。""" - return await self.backend.screenshot() + """截图代理方法,带 150ms 缓存。""" + logger.debug("[action] screenshot start") + data = self._get_cached_screenshot() + if data is None: + data = await self.backend.screenshot() + self._set_cached_screenshot(data) + logger.debug( + "[action] screenshot done bytes=%d", len(data) if data else 0 + ) + return data # ------------------------------------------------------------------ # 几何兜底 @@ -207,11 +262,13 @@ class Actions: ) -> ElementHandle: """按 GeomSpec 计算绝对坐标,返回 ElementHandle。 - 简化策略:所有 relative_to 模式统一用 + 支持两种 relative_to 语义: + - window: 以窗口左上角为原点 base_x = win_geom.x + win_geom.width * g.x_ratio + g.x_offset base_y = win_geom.y + win_geom.height * g.y_ratio + g.y_offset - relative_to 仅作语义占位,offset 已含正负号区分方向 - (如 send_button 用 x_offset=-60 表示距右边缘 60px)。 + - window_bottom_right: 以窗口右下角为原点(offset 含正负号) + base_x = win_geom.x + win_geom.width + g.x_offset + base_y = win_geom.y + win_geom.height + g.y_offset Args: selector: 元素选择器(必须含 by_geom) @@ -228,8 +285,20 @@ class Actions: f"no geom spec kind={selector.kind}" ) g = selector.by_geom - base_x = win_geom.x + win_geom.width * g.x_ratio + g.x_offset - base_y = win_geom.y + win_geom.height * g.y_ratio + g.y_offset + if g.relative_to == "window_bottom_right": + base_x = win_geom.x + win_geom.width + g.x_offset + base_y = win_geom.y + win_geom.height + g.y_offset + else: + base_x = win_geom.x + win_geom.width * g.x_ratio + g.x_offset + base_y = win_geom.y + win_geom.height * g.y_ratio + g.y_offset + logger.debug( + "[action] geom_find kind=%s relative_to=%s win=(%d,%d,%dx%d) " + "ratio=(%.3f,%.3f) offset=(%d,%d) → abs=(%d,%d)", + selector.kind, g.relative_to, + win_geom.x, win_geom.y, win_geom.width, win_geom.height, + g.x_ratio, g.y_ratio, g.x_offset, g.y_offset, + int(base_x), int(base_y), + ) return ElementHandle( selector=selector, x=int(base_x), diff --git a/bridge/woc_bridge/ui/backends/opencv.py b/bridge/woc_bridge/ui/backends/opencv.py index 69aded3..ff80c1e 100644 --- a/bridge/woc_bridge/ui/backends/opencv.py +++ b/bridge/woc_bridge/ui/backends/opencv.py @@ -130,6 +130,10 @@ class OpenCVBackend: 被 find_template 用 asyncio.to_thread 调用,避免阻塞事件循环。 """ # 1. 解码截图 + logger.info("[opencv] decode screenshot: size=%d", len(screenshot_png)) + if not screenshot_png: + logger.warning("[opencv] screenshot_png is empty") + return None arr = np.frombuffer(screenshot_png, dtype=np.uint8) img = cv2.imdecode(arr, cv2.IMREAD_GRAYSCALE) if img is None: @@ -211,8 +215,13 @@ class OpenCVBackend: ) if best is not None: - logger.debug( + logger.warning( "[opencv] no match for %s (best conf=%.3f < threshold=%.2f)", template_name, best[4], threshold, ) + else: + logger.warning( + "[opencv] no match for %s (no valid scale returned result)", + template_name, + ) return None diff --git a/bridge/woc_bridge/ui/backends/xdotool.py b/bridge/woc_bridge/ui/backends/xdotool.py index 9cbb28a..734f7b3 100644 --- a/bridge/woc_bridge/ui/backends/xdotool.py +++ b/bridge/woc_bridge/ui/backends/xdotool.py @@ -111,7 +111,23 @@ class XdotoolBackend(BackendProtocol): async def _run(self, args: list[str]) -> tuple[int, bytes, bytes]: """执行一条命令并返回 (returncode, stdout, stderr)。""" + t0 = time.perf_counter() async with self._run_managed(args) as result: + rc, stdout, stderr = result + # 隐藏 stdout/stderr 内容避免污染日志,仅记录长度和 rc + logger.debug( + "[ui-backend] cmd rc=%d %s (%.0fms) out=%dB err=%dB", + rc, " ".join(args[:6]), + (time.perf_counter() - t0) * 1000, + len(stdout) if stdout else 0, + len(stderr) if stderr else 0, + ) + if rc != 0: + err_str = stderr.decode(errors="ignore").strip()[:200] if stderr else "" + logger.warning( + "[ui-backend] cmd FAILED rc=%d cmd=%s err=%s", + rc, " ".join(args), err_str, + ) return result # ------------------------------------------------------------------ @@ -122,6 +138,8 @@ class XdotoolBackend(BackendProtocol): 关键:单条命令,避免分两次 fork 之间窗口焦点变化导致点击错位。 """ + logger.info("[ui-backend] click start (%d,%d)", x, y) + t0 = time.perf_counter() async with self._ui_lock: await self._run( [ @@ -134,95 +152,197 @@ class XdotoolBackend(BackendProtocol): "1", ] ) + logger.info( + "[ui-backend] click done (%d,%d) (%.0fms)", + x, y, (time.perf_counter() - t0) * 1000, + ) async def type_text(self, text: str) -> None: """xdotool type -- :原样输入文本,不解析修饰键组合。""" + text_len = len(text) + logger.info( + "[ui-backend] type_text start len=%d preview=%r", + text_len, text[:30], + ) + t0 = time.perf_counter() async with self._ui_lock: await self._run(["xdotool", "type", "--", text]) + logger.info( + "[ui-backend] type_text done len=%d (%.0fms)", + text_len, (time.perf_counter() - t0) * 1000, + ) async def key_press(self, key: str, repeat: int = 1) -> None: """xdotool key [--repeat N] 。""" + logger.info("[ui-backend] key_press start key=%s repeat=%d", key, repeat) + t0 = time.perf_counter() async with self._ui_lock: args = ["xdotool", "key"] if repeat > 1: args.extend(["--repeat", str(repeat)]) args.append(key) await self._run(args) + logger.info( + "[ui-backend] key_press done key=%s repeat=%d (%.0fms)", + key, repeat, (time.perf_counter() - t0) * 1000, + ) async def screenshot(self) -> bytes: - """scrot -o - 输出 PNG 到 stdout,不写文件。 + """scrot 截图输出到临时文件,再读取为 bytes。 - 超时 5s,TimeoutError/CancelledError 时 kill+wait。 + 注意:s6 服务内 /dev/stdout 不是有效设备,scrot -o - 会失败, + 故改用临时文件路径。超时 5s,TimeoutError/CancelledError 时 kill+wait。 """ + import tempfile + async with self._ui_lock: - # scrot -o - 输出到 stdout - proc = await asyncio.create_subprocess_exec( - "scrot", - "-o", - "-", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=self._env(), - ) + fd, path = tempfile.mkstemp(suffix=".png", prefix="woc_screenshot_") + os.close(fd) try: - stdout, _ = await asyncio.wait_for( - proc.communicate(), timeout=_CMD_TIMEOUT_SEC + proc = await asyncio.create_subprocess_exec( + "scrot", + "-o", + path, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=self._env(), ) - except (asyncio.TimeoutError, asyncio.CancelledError): - proc.kill() try: - await asyncio.wait_for(proc.wait(), timeout=5.0) - except asyncio.TimeoutError: - logger.error("[ui-backend] scrot proc.wait() timeout after kill") - raise - except Exception: - if proc.returncode is None: + stdout, stderr = await asyncio.wait_for( + proc.communicate(), timeout=_CMD_TIMEOUT_SEC + ) + except (asyncio.TimeoutError, asyncio.CancelledError): proc.kill() try: await asyncio.wait_for(proc.wait(), timeout=5.0) except asyncio.TimeoutError: - logger.error( - "[ui-backend] scrot proc.wait() timeout after kill" - ) - raise - return stdout + logger.error("[ui-backend] scrot proc.wait() timeout after kill") + raise + except Exception: + if proc.returncode is None: + proc.kill() + try: + await asyncio.wait_for(proc.wait(), timeout=5.0) + except asyncio.TimeoutError: + logger.error( + "[ui-backend] scrot proc.wait() timeout after kill" + ) + raise - async def get_window_geometry(self, window_title: str) -> WindowGeometry: - """按标题查窗口并返回几何。容忍多个匹配窗口,取第一个。 + if proc.returncode != 0: + err = stderr.decode(errors="ignore").strip()[:200] + logger.error("[ui-backend] screenshot failed: rc=%s err=%s", proc.returncode, err) + return b"" + + with open(path, "rb") as f: + data = f.read() + logger.info( + "[ui-backend] screenshot: scrot rc=0 file=%s bytes=%d", + path, len(data), + ) + return data + finally: + try: + os.unlink(path) + except OSError: + pass + + async def _find_main_window_id( + self, window_title: str, min_area: int = 10000 + ) -> int: + """查找窗口标题匹配者中面积最大的主窗口,过滤隐藏/加载小窗。 + + 微信启动过程中会出现多个窗口(隐藏加载窗、托盘提示窗等), + 仅按标题搜索并取第一个常常会拿到 1×1 的无效窗口。本方法: + 1. 搜索所有标题匹配窗口; + 2. 逐个获取几何并计算面积; + 3. 过滤掉面积 < min_area 的候选; + 4. 返回面积最大者。 + + Args: + window_title: 窗口标题,如 "微信" + min_area: 最小有效窗口面积(默认 10000 像素) + + Returns: + 主窗口 ID Raises: - RuntimeError: 窗口未找到(TODO: P3 时换 FlowError(code=WINDOW_NOT_FOUND)) + RuntimeError: 未找到有效窗口 """ - # 1. 查窗口 id(容忍多匹配,取第一个) rc, stdout, _ = await self._run( ["xdotool", "search", "--name", window_title] ) if rc != 0: - # TODO: P3 时换 FlowError(code="WINDOW_NOT_FOUND", retryable=False) raise RuntimeError( f"WINDOW_NOT_FOUND: search failed for title={window_title!r}" ) text = stdout.decode(errors="ignore").strip() if not text: - # TODO: P3 时换 FlowError(code="WINDOW_NOT_FOUND", retryable=False) raise RuntimeError( f"WINDOW_NOT_FOUND: no window matches title={window_title!r}" ) - first_line = text.splitlines()[0].strip() - try: - window_id = int(first_line) - except ValueError: - # TODO: P3 时换 FlowError(code="WINDOW_NOT_FOUND", retryable=False) - raise RuntimeError( - f"WINDOW_NOT_FOUND: invalid window id={first_line!r} for title={window_title!r}" - ) - # 2. 获取几何 + candidates: list[tuple[int, int]] = [] + for line in text.splitlines(): + wid_str = line.strip() + if not wid_str: + continue + try: + wid = int(wid_str) + except ValueError: + continue + rc, gstdout, _ = await self._run( + ["xdotool", "getwindowgeometry", "--shell", str(wid)] + ) + if rc != 0: + continue + geom: dict[str, str] = {} + for gline in gstdout.decode(errors="ignore").splitlines(): + if "=" in gline: + k, v = gline.split("=", 1) + geom[k.strip()] = v.strip() + try: + w = int(geom.get("WIDTH", 0)) + h = int(geom.get("HEIGHT", 0)) + area = w * h + if area >= min_area and w >= 200 and h >= 200: + candidates.append((area, wid)) + except ValueError: + continue + + if not candidates: + raise RuntimeError( + f"WINDOW_NOT_FOUND: no valid window for title={window_title!r} " + f"(candidates filtered by min_area={min_area})" + ) + candidates.sort(key=lambda x: x[0], reverse=True) + return candidates[0][1] + + async def get_window_geometry(self, window_title: str) -> WindowGeometry: + """按标题查窗口并返回几何。容忍多个匹配窗口,取面积最大主窗口。 + + Raises: + RuntimeError: 窗口未找到(TODO: P3 时换 FlowError(code=WINDOW_NOT_FOUND)) + """ + t0 = time.perf_counter() + logger.info("[ui-backend] get_window_geometry start title=%r", window_title) + try: + window_id = await self._find_main_window_id(window_title) + except RuntimeError as exc: + logger.warning( + "[ui-backend] get_window_geometry: %s (%.0fms)", + exc, (time.perf_counter() - t0) * 1000, + ) + raise + rc, stdout, _ = await self._run( ["xdotool", "getwindowgeometry", "--shell", str(window_id)] ) if rc != 0: - # TODO: P3 时换 FlowError(code="WINDOW_NOT_FOUND", retryable=False) + logger.warning( + "[ui-backend] get_window_geometry: getwindowgeometry failed id=%d", + window_id, + ) raise RuntimeError( f"WINDOW_NOT_FOUND: getwindowgeometry failed for id={window_id}" ) @@ -232,14 +352,23 @@ class XdotoolBackend(BackendProtocol): k, v = line.split("=", 1) geom[k.strip()] = v.strip() try: - return WindowGeometry( + result = WindowGeometry( x=int(geom.get("X", 0)), y=int(geom.get("Y", 0)), width=int(geom.get("WIDTH", 0)), height=int(geom.get("HEIGHT", 0)), ) + logger.info( + "[ui-backend] get_window_geometry OK id=%d geom=%dx%d@(%d,%d) (%.0fms)", + window_id, result.width, result.height, result.x, result.y, + (time.perf_counter() - t0) * 1000, + ) + return result except ValueError: - # TODO: P3 时换 FlowError(code="WINDOW_NOT_FOUND", retryable=False) + logger.warning( + "[ui-backend] get_window_geometry: invalid geom dict=%s id=%d", + geom, window_id, + ) raise RuntimeError( f"WINDOW_NOT_FOUND: invalid geometry for id={window_id}" ) @@ -250,29 +379,55 @@ class XdotoolBackend(BackendProtocol): Raises: RuntimeError: 窗口未找到(TODO: P3 时换 FlowError(code=WINDOW_NOT_FOUND)) """ - rc, stdout, _ = await self._run( - ["xdotool", "search", "--name", window_title] + t0 = time.perf_counter() + logger.info("[ui-backend] activate_window start title=%r", window_title) + try: + window_id = await self._find_main_window_id(window_title) + except RuntimeError as exc: + logger.warning( + "[ui-backend] activate_window: %s (%.0fms)", + exc, (time.perf_counter() - t0) * 1000, + ) + raise + # 使用非阻塞 windowactivate,避免 --sync 在 VNC 无人操作时死等。 + # 之后轮询 getactivewindow 确认焦点是否真正落到目标窗口。 + rc, _, stderr = await self._run( + ["xdotool", "windowactivate", str(window_id)] ) if rc != 0: - # TODO: P3 时换 FlowError(code="WINDOW_NOT_FOUND", retryable=False) - raise RuntimeError( - f"WINDOW_NOT_FOUND: search failed for title={window_title!r}" + err_str = stderr.decode(errors="ignore").strip()[:200] if stderr else "" + logger.warning( + "[ui-backend] activate_window: windowactivate failed id=%d rc=%d err=%s", + window_id, rc, err_str, ) - text = stdout.decode(errors="ignore").strip() - if not text: - # TODO: P3 时换 FlowError(code="WINDOW_NOT_FOUND", retryable=False) raise RuntimeError( - f"WINDOW_NOT_FOUND: no window matches title={window_title!r}" + f"WINDOW_ACTIVATE_FAILED: windowactivate failed for id={window_id} rc={rc} err={err_str}" ) - first_line = text.splitlines()[0].strip() - try: - window_id = int(first_line) - except ValueError: - # TODO: P3 时换 FlowError(code="WINDOW_NOT_FOUND", retryable=False) - raise RuntimeError( - f"WINDOW_NOT_FOUND: invalid window id={first_line!r} for title={window_title!r}" + + deadline = time.monotonic() + 2.0 + activated = False + while time.monotonic() < deadline: + rc, stdout, _ = await self._run(["xdotool", "getactivewindow"]) + if rc == 0: + active_text = stdout.decode(errors="ignore").strip() + try: + if int(active_text) == window_id: + activated = True + break + except ValueError: + pass + await asyncio.sleep(0.1) + + if activated: + logger.info( + "[ui-backend] activate_window OK id=%d title=%r (%.0fms)", + window_id, window_title, (time.perf_counter() - t0) * 1000, + ) + else: + logger.warning( + "[ui-backend] activate_window: focus verify timeout id=%d title=%r (%.0fms), continue", + window_id, window_title, (time.perf_counter() - t0) * 1000, ) - await self._run(["xdotool", "windowactivate", "--sync", str(window_id)]) # ------------------------------------------------------------------ # 调试截图异步队列(生产环境关闭,调试模式后台写盘) diff --git a/bridge/woc_bridge/ui/drivers/contact.py b/bridge/woc_bridge/ui/drivers/contact.py index c3e684e..4e2f963 100644 --- a/bridge/woc_bridge/ui/drivers/contact.py +++ b/bridge/woc_bridge/ui/drivers/contact.py @@ -84,13 +84,18 @@ async def _verify_accept_with_retry( ) return False -# 好友申请通过 UI 几何常量(基于 1920x1080,坐标为估算值需实测校准) +# 好友申请通过 UI 几何常量(基于窗口比例,坐标为估算值需实测校准) +# 注:Task 6 优先使用 profile 中的几何兜底,以下常量仅在 profile 未注册时作为 fallback。 _CONTACT_ICON_X_RATIO = 0.04 # 通讯录图标 X 比例(左侧导航栏第 2 个图标) -_CONTACT_ICON_Y_RATIO = 0.15 # 通讯录图标 Y 比例 -_NEW_FRIEND_ENTRY_Y_RATIO = 0.22 # "新的朋友"入口 Y 比例(通讯录页顶部) -_VERIFY_BUTTON_X_OFFSET = -80 # "前往验证"按钮距右侧偏移(向左为负) -_VERIFY_BUTTON_Y_RATIO = 0.30 # 第一条申请项"前往验证"按钮 Y 比例 -_CONFIRM_BUTTON_Y_OFFSET = -60 # "确定"按钮距底部偏移(向上为负) +_CONTACT_ICON_Y_RATIO = 0.171 # 通讯录图标 Y 比例(1435x876 实测) +_NEW_FRIEND_ENTRY_X_RATIO = 0.125 # "新的朋友"入口 X 比例(1435x876 实测) +_NEW_FRIEND_ENTRY_Y_RATIO = 0.138 # "新的朋友"入口 Y 比例(1435x876 实测) +_FRIEND_REQUEST_ITEM_X_RATIO = 0.125 # 申请列表第一条 X 比例(1435x876 实测) +_FRIEND_REQUEST_ITEM_Y_RATIO = 0.178 # 申请列表第一条 Y 比例("新的朋友"展开后实测) +_VERIFY_BUTTON_X_RATIO = 0.593 # 详情页"前往验证"按钮 X 比例(OpenCV 实测 851/1435) +_VERIFY_BUTTON_Y_RATIO = 0.394 # 详情页"前往验证"按钮 Y 比例(OpenCV 实测 345/876) +_CONFIRM_BUTTON_X_RATIO = 0.464 # 弹窗"确定"按钮 X 比例(OpenCV 实测 666/1435) +_CONFIRM_BUTTON_Y_RATIO = 0.830 # 弹窗"确定"按钮 Y 比例(OpenCV 实测 727/876) # 微信 4.x 自绘 UI 布局常量(_open_session_by_name 复用) _LEFT_PANEL_WIDTH = 300 @@ -277,10 +282,14 @@ class ContactDriver(BaseDriver): from woc_bridge.ui.backends.base import WindowGeometry win_x, win_y, win_w, win_h = win_geom try: - await self._actions.click_element( + elem = await self._actions.click_element( kind, WindowGeometry(x=win_x, y=win_y, width=win_w, height=win_h), ) + logger.info( + "[ui] click_element(%s) 成功: (%d,%d) strategy=%s conf=%.3f", + kind, elem.x, elem.y, elem.strategy, elem.confidence, + ) except Exception as exc: # 元素未注册或 click_element 异常时回退硬编码坐标 logger.warning( @@ -589,6 +598,16 @@ class ContactDriver(BaseDriver): - 当前版本仅点击列表第一条申请项,昵称匹配待后续实现 - 所有坐标均为估算值,需在目标分辨率实测调优 """ + logger.info( + "accept_friend_request: start wxid=%s nickname=%s timeout=%.1fs", + stranger_wxid, nickname, timeout_sec, + ) + if not stranger_wxid and not nickname: + logger.warning("accept_friend_request: stranger_wxid 和 nickname 均为空,拒绝执行") + raise BridgeError( + code="INVALID_PARAMS", + message="stranger_wxid 和 nickname 不能同时为空", + ) deadline = time.monotonic() + timeout_sec # 1. 激活窗口 @@ -601,12 +620,40 @@ class ContactDriver(BaseDriver): await self._step(self._activate_window_fast(window_id), "激活窗口", deadline) await self._step(self._sleep(0.3), "等待窗口激活", deadline) - # 关闭可能存在的弹窗 - await self._step(self._key("Escape"), "关闭弹窗", deadline) + # 1.1 强制重置 UI 状态:多次 Esc 关闭弹窗,点击主视图回到主界面, + # 避免上次失败后停留在"朋友验证"详情页导致后续点击失效 + await self._step(self._key("Escape"), "关闭弹窗#1", deadline) + await self._step(self._sleep(0.3), "等待 Esc 生效", deadline) + await self._step(self._key("Escape"), "关闭弹窗#2", deadline) + await self._step(self._sleep(0.3), "等待 Esc 生效", deadline) + await self._step(self._key("Escape"), "关闭弹窗#3", deadline) + await self._step(self._sleep(0.3), "等待 Esc 生效", deadline) + + # 点击主视图区域,确保焦点在主聊天界面 + win_x, win_y, win_w, win_h = await self._get_window_geometry() + main_view_x = win_x + int(win_w * 0.65) + main_view_y = win_y + int(win_h * 0.5) + await self._step( + self._click_element_or_fallback( + "main_view", (win_x, win_y, win_w, win_h), main_view_x, main_view_y + ), + "点击主视图回到主界面", deadline, + ) + await self._step(self._sleep(0.3), "等待主界面稳定", deadline) + await self._step(self._key("Escape"), "退出可能存在的子页面", deadline) await self._step(self._sleep(0.2), "等待 Esc 生效", deadline) # 2. 获取窗口几何 win_x, win_y, win_w, win_h = await self._get_window_geometry() + if win_w < 200 or win_h < 200: + raise BridgeError( + code="WINDOW_NOT_FOUND", + message=f"微信窗口几何异常: {win_w}x{win_h},无法执行 UI 操作", + ) + logger.info( + "accept_friend_request: 窗口几何 win=(%d,%d %dx%d)", + win_x, win_y, win_w, win_h, + ) win_geom = (win_x, win_y, win_w, win_h) # 3. 点击通讯录图标(左侧导航栏第 2 个) @@ -617,29 +664,52 @@ class ContactDriver(BaseDriver): self._click_element_or_fallback("contact_icon", win_geom, contact_x, contact_y), "点击通讯录图标", deadline, ) - await self._step(self._sleep(0.8), "等待通讯录页面加载", deadline) + # 实测:通讯录页面加载后左侧列表需要约 2~3s 才能响应点击, + # 这里等待 2.5s 避免后续点击失效。 + await self._step(self._sleep(2.5), "等待通讯录页面加载", deadline) # 4. 点击"新的朋友"入口 + new_friend_x = win_x + int(win_w * _NEW_FRIEND_ENTRY_X_RATIO) new_friend_y = win_y + int(win_h * _NEW_FRIEND_ENTRY_Y_RATIO) await self._step( - self._click_element_or_fallback("new_friend_entry", win_geom, contact_x, new_friend_y), + self._click_element_or_fallback("new_friend_entry", win_geom, new_friend_x, new_friend_y), "点击新的朋友", deadline, ) - await self._step(self._sleep(0.8), "等待新的朋友列表加载", deadline) + await self._step(self._sleep(1.2), "等待新的朋友列表加载", deadline) - # 5. 定位并点击目标申请项的"前往验证"按钮 - # 当前版本取第一条申请项(昵称匹配待后续实现) - verify_x = win_x + win_w + _VERIFY_BUTTON_X_OFFSET # 负偏移 = 向左 + # 5. 点击第一条申请项,展开右侧详情页 + # 微信 4.x Linux 中点击"新的朋友"后只显示左侧列表, + # 右侧为空白,必须点击具体申请项才会出现"前往验证"按钮。 + # 当前版本取第一条(昵称匹配待后续实现)。 + item_x = win_x + int(win_w * _FRIEND_REQUEST_ITEM_X_RATIO) + item_y = win_y + int(win_h * _FRIEND_REQUEST_ITEM_Y_RATIO) + await self._step( + self._click_element_or_fallback("friend_request_item", win_geom, item_x, item_y), + "点击第一条好友申请项", deadline, + ) + await self._step(self._sleep(1.0), "等待详情页加载", deadline) + + # 6. 点击详情页"前往验证"按钮 + verify_x = win_x + int(win_w * _VERIFY_BUTTON_X_RATIO) verify_y = win_y + int(win_h * _VERIFY_BUTTON_Y_RATIO) + logger.info( + "accept_friend_request: 准备点击'前往验证' (win=%dx%d, x=%d, y=%d, actions=%s)", + win_w, win_h, verify_x, verify_y, + "yes" if self._actions is not None else "no", + ) await self._step( self._click_element_or_fallback("verify_button", win_geom, verify_x, verify_y), "点击前往验证", deadline, ) await self._step(self._sleep(1.0), "等待验证弹窗打开", deadline) - # 6. 点击弹窗"确定"按钮 - confirm_x = win_x + win_w // 2 - confirm_y = win_y + win_h + _CONFIRM_BUTTON_Y_OFFSET # 负偏移 = 向上 + # 7. 点击弹窗"确定"按钮 + confirm_x = win_x + int(win_w * _CONFIRM_BUTTON_X_RATIO) + confirm_y = win_y + int(win_h * _CONFIRM_BUTTON_Y_RATIO) + logger.info( + "accept_friend_request: 准备点击'确定' (x=%d, y=%d)", + confirm_x, confirm_y, + ) await self._step( self._click_element_or_fallback("confirm_button", win_geom, confirm_x, confirm_y), "点击确定", deadline, diff --git a/bridge/woc_bridge/ui/drivers/message.py b/bridge/woc_bridge/ui/drivers/message.py index 66da040..e3fdfa6 100644 --- a/bridge/woc_bridge/ui/drivers/message.py +++ b/bridge/woc_bridge/ui/drivers/message.py @@ -21,6 +21,7 @@ import time from typing import Any, Awaitable, Optional, TYPE_CHECKING from woc_bridge.models import BridgeError +from woc_bridge.ui.backends.base import WindowGeometry from woc_bridge.ui.drivers.base import BaseDriver if TYPE_CHECKING: @@ -28,6 +29,9 @@ if TYPE_CHECKING: logger = logging.getLogger("woc-bridge") +# 微信窗口标题常量(与 flow 路径保持一致) +_WECHAT_WINDOW_TITLE = "微信" + # --------------------------------------------------------------------------- # 分层超时与布局常量(与 xdotool_driver.py 保持一致) # --------------------------------------------------------------------------- @@ -51,11 +55,15 @@ class MessageDriver(BaseDriver): display: str, backend=None, window_driver: Optional["WindowDriver"] = None, + actions: Optional[Any] = None, ) -> None: super().__init__(display, backend) # 注入兄弟 driver:_open_session_by_name / revoke / forward 依赖 # WindowDriver.find_wechat_window self._window_driver = window_driver + # 注入 L3 Actions(可选):非 None 时 _open_session_by_name / 输入框 / + # 发送按钮走 LocatorRegistry + profile 比例坐标;None 时回退原硬编码坐标。 + self._actions = actions # 调试截图:环境变量控制开关(与原 XdotoolDriver.__init__ 一致) self._debug_shots_enabled = os.environ.get( "WOC_UI_DEBUG_SHOTS", "false" @@ -202,10 +210,28 @@ class MessageDriver(BaseDriver): self._debug_queue.task_done() # ------------------------------------------------------------------ - # 输入框 / 发送按钮定位 + # 输入框 / 发送按钮定位(优先走 Actions + profile 比例坐标) # ------------------------------------------------------------------ + async def _get_window_geometry_backend(self) -> WindowGeometry: + """获取窗口几何,返回 backend 统一的 WindowGeometry。 + + 优先委托 XdotoolBackend;无 backend 时自建 WindowGeometry。 + """ + if self._backend is not None and hasattr( + self._backend, "get_window_geometry" + ): + return await self._backend.get_window_geometry(_WECHAT_WINDOW_TITLE) + win_x, win_y, win_w, win_h = await self._get_window_geometry() + return WindowGeometry(x=win_x, y=win_y, width=win_w, height=win_h) + async def _click_input_box(self) -> None: """鼠标点击聊天输入框,强制焦点落到输入框。""" + if self._actions is not None: + geom = await self._get_window_geometry_backend() + await self._actions.click_element("input_box", geom) + await asyncio.sleep(0.2) + return + # legacy fallback:固定像素坐标 win_x, win_y, win_w, win_h = await self._get_window_geometry() input_x = win_x + _LEFT_PANEL_WIDTH + (win_w - _LEFT_PANEL_WIDTH) // 2 input_y = win_y + win_h - _INPUT_BOX_Y_OFFSET @@ -214,6 +240,11 @@ class MessageDriver(BaseDriver): async def _click_send_button(self) -> None: """鼠标点击右下角发送按钮,替代可能失效的 Return 键。""" + if self._actions is not None: + geom = await self._get_window_geometry_backend() + await self._actions.click_element("send_button", geom) + return + # legacy fallback:固定像素坐标 win_x, win_y, win_w, win_h = await self._get_window_geometry() send_x = win_x + win_w - _SEND_BUTTON_X_OFFSET send_y = win_y + win_h - _SEND_BUTTON_Y_OFFSET @@ -229,15 +260,18 @@ class MessageDriver(BaseDriver): ) -> None: """通过微信左侧搜索框定位并进入指定联系人的会话。 - WeChat 4.x 自绘 UI 对 Ctrl+F 等 X11 修饰键组合不响应,全部改用 - 鼠标点击: - 1. 激活窗口并校验焦点 - 2. 按 Esc 关闭可能存在的搜索框/弹窗 + 优先走 Actions + LocatorRegistry + YAML profile 比例坐标; + 未注入 Actions 时回退原硬编码坐标逻辑。 + + 新流程: + 1. 激活窗口并获取几何 + 2. 点击左侧"会话管理"图标重置状态(回到会话列表首页) 3. 点击左侧顶部搜索框 - 4. 输入 display_name - 5. 等待搜索结果渲染 - 6. 点击第一个搜索结果进入会话 - 7. 按 Esc 退出搜索覆盖层 + 4. Ctrl+A + BackSpace 清空旧内容 + 5. 输入 display_name + 6. 等待并点击第一个搜索结果 + 7. 验证 input_box 出现(确保真正进入会话) + 8. 按 Esc 退出搜索覆盖层 Args: name: 用于搜索的显示名(备注/昵称/微信号) @@ -253,6 +287,77 @@ class MessageDriver(BaseDriver): message="display_name 不能为空,无法定位会话", ) + if self._actions is None: + return await self._open_session_by_name_legacy(name, timeout_sec) + + deadline = time.monotonic() + timeout_sec + backend = self._actions.backend + + # 1. 激活窗口并获取几何 + geom = await self._get_window_geometry_backend() + await self._step(backend.activate_window(_WECHAT_WINDOW_TITLE), "激活窗口", deadline) + + # 2. 重置状态:点击左侧"会话管理"图标,强制回到会话列表首页 + await self._step( + self._actions.click_element("session_manager_icon", geom), + "点击会话管理图标重置状态", + deadline, + ) + await self._step(backend.key_press("Escape"), "关闭可能打开的菜单", deadline) + await self._step(asyncio.sleep(0.3), "等待重置生效", deadline) + + # 3. 点击搜索框 + await self._step(self._actions.click_element("search_box", geom), "点击搜索框", deadline) + await self._step(asyncio.sleep(0.2), "等待搜索框聚焦", deadline) + + # 4. 清空旧内容:Ctrl+A 全选 + BackSpace 删除 + await self._step(backend.key_press("ctrl+a"), "全选搜索框旧内容", deadline) + await self._step(backend.key_press("BackSpace"), "删除搜索框旧内容", deadline) + await self._step(asyncio.sleep(0.2), "等待清空", deadline) + + # 5. 输入搜索关键词 + await self._step(backend.type_text(name), "输入搜索关键词", deadline) + await self._step(asyncio.sleep(0.5), "等待搜索结果渲染", deadline) + + # 6. 点击第一个搜索结果进入会话 + await self._step( + self._actions.click_element("search_result_first", geom), + "点击第一个搜索结果", + deadline, + ) + await self._step(asyncio.sleep(0.5), "等待会话打开", deadline) + await self._debug_screenshot("after_open_session") + + # 7. 验证 input_box 出现(确保真正进入目标会话) + input_appeared = False + verify_deadline = time.monotonic() + 2.0 + while time.monotonic() < verify_deadline: + try: + elem = await self._actions.wait_for( + "input_box", geom, timeout=0.5, interval=0.1 + ) + if elem is not None: + input_appeared = True + break + except Exception: + pass + await asyncio.sleep(0.2) + if not input_appeared: + raise BridgeError( + code="SEND_FAILED", + message="点击搜索结果后未进入目标会话(未找到输入框)", + ) + + # 8. 退出搜索模式,确保焦点在会话区域 + await self._step(backend.key_press("Escape"), "退出搜索模式", deadline) + await self._step(asyncio.sleep(0.2), "等待焦点稳定", deadline) + + async def _open_session_by_name_legacy( + self, + name: str, + timeout_sec: float = 20.0, + ) -> None: + """原硬编码坐标实现(保留兼容)。""" deadline = time.monotonic() + timeout_sec # 1. 查找并激活窗口(委托 WindowDriver 查窗口) @@ -393,7 +498,7 @@ class MessageDriver(BaseDriver): await self._paste_via_xclip(content) logger.info("[send_text] 文本已输入") await self._debug_screenshot("after_type") - await self._sleep(1.0) + await self._sleep(0.3) # 优先用鼠标点击右下角发送按钮,规避 Return 键可能不触发发送的问题 await self._click_send_button() try: @@ -406,11 +511,11 @@ class MessageDriver(BaseDriver): code="SEND_FAILED", message="文本输入/发送步骤超时", ) from exc - await self._sleep(1.0) + await self._sleep(0.3) await self._debug_screenshot("after_send") # 3. 按 Esc 退出当前聊天界面,回到会话列表,避免影响下一次发送 await self._key("Escape") - await self._sleep(1.0) + await self._sleep(0.3) await self._debug_screenshot("after_escape") # 4. 生成 local_send_id(本地 ID,非微信原生 msg_id) local_send_id = f"local_{int(time.time())}_{random.randint(0, 0xFFFFFF):06x}" diff --git a/bridge/woc_bridge/ui/drivers/window.py b/bridge/woc_bridge/ui/drivers/window.py index 3295bff..d090e4c 100644 --- a/bridge/woc_bridge/ui/drivers/window.py +++ b/bridge/woc_bridge/ui/drivers/window.py @@ -15,11 +15,13 @@ from __future__ import annotations import asyncio import logging +import time from typing import Optional from woc_bridge.models import BridgeError from woc_bridge.ui.drivers.base import BaseDriver, WindowGeom + logger = logging.getLogger("woc-bridge") @@ -28,6 +30,8 @@ class WindowDriver(BaseDriver): def __init__(self, display: str, backend=None) -> None: super().__init__(display, backend) + # 缓存最近一次找到的主窗口信息,用于抑制连续重复的 info 日志 + self._last_window_key: Optional[tuple[int, int, int, int, int, int]] = None # ------------------------------------------------------------------ # 命令执行包装(BaseDriver._run_managed 返回 tuple 且抛 TimeoutError, @@ -67,30 +71,140 @@ class WindowDriver(BaseDriver): # ------------------------------------------------------------------ # 窗口与进程检测 # ------------------------------------------------------------------ + async def _find_wechat_pids(self) -> list[int]: + """查找微信主进程 PID 列表。 + + 微信可执行文件常见路径: + - /config/wechat/opt/wechat/wechat(安装后的实际路径) + - /opt/wechat/wechat + 通过 pgrep 按命令行匹配,避免依赖固定路径。 + + Returns: + PID 列表(可能为空) + """ + pids: list[int] = [] + for pattern in ["wechat", "/config/wechat/opt/wechat"]: + rc, stdout, _ = await self._run(["pgrep", "-f", pattern]) + if rc != 0: + continue + for line in stdout.decode(errors="ignore").splitlines(): + line = line.strip() + if not line: + continue + try: + pids.append(int(line)) + except ValueError: + continue + # 去重保持顺序 + seen = set() + unique: list[int] = [] + for pid in pids: + if pid not in seen: + seen.add(pid) + unique.append(pid) + return unique + async def find_wechat_window(self) -> Optional[int]: - """用 xdotool search --name "微信" 查找微信窗口 ID。 + """查找微信主窗口 ID。 + + 策略: + 1. 优先通过 wechat 进程 PID 搜索窗口(最可靠,微信主窗口 name/class 可能为空); + 2. 再用 name/class 搜索兜底; + 3. 过滤掉面积极小的隐藏/装饰/加载窗口; + 4. 多个候选时取面积最大者。 Returns: 窗口 ID(int),找不到返回 None """ - returncode, stdout, stderr = await self._run( - ["xdotool", "search", "--name", "微信"] - ) - if returncode != 0: - return None - text = stdout.decode(errors="ignore").strip() - if not text: - return None - # 取第一个匹配的窗口 ID - first_line = text.splitlines()[0].strip() - try: - return int(first_line) - except ValueError: + candidates: list[tuple[int, int, WindowGeom]] = [] + seen_wids: set[int] = set() + + async def add_candidate(wid: int) -> None: + """获取窗口几何并加入候选(去重)。""" + if wid in seen_wids: + return + seen_wids.add(wid) + rc, gstdout, _ = await self._run( + ["xdotool", "getwindowgeometry", "--shell", str(wid)] + ) + if rc != 0: + return + geom = self._parse_window_geometry(gstdout.decode(errors="ignore")) + if geom is None: + return + area = geom.width * geom.height + # 过滤极小/无效的窗口 + if area < 10000 or geom.width < 100 or geom.height < 100: + return + candidates.append((area, wid, geom)) + + # 1. 通过 wechat 进程 PID 搜索窗口(优先) + for pid in await self._find_wechat_pids(): + rc, stdout, _ = await self._run( + ["xdotool", "search", "--pid", str(pid)] + ) + if rc != 0: + continue + for line in stdout.decode(errors="ignore").splitlines(): + wid_str = line.strip() + if not wid_str: + continue + try: + await add_candidate(int(wid_str)) + except ValueError: + continue + + # 2. name/class 兜底 + searches = [ + ["xdotool", "search", "--onlyvisible", "--name", "微信"], + ["xdotool", "search", "--onlyvisible", "--class", "wechat"], + ["xdotool", "search", "--name", "微信"], + ["xdotool", "search", "--class", "wechat"], + ] + for args in searches: + returncode, stdout, _ = await self._run(args) + if returncode != 0: + continue + for line in stdout.decode(errors="ignore").splitlines(): + wid_str = line.strip() + if not wid_str: + continue + try: + await add_candidate(int(wid_str)) + except ValueError: + continue + + if not candidates: + if self._last_window_key is not None: + logger.warning("[window] 未找到有效微信主窗口") + self._last_window_key = None + else: + logger.debug("[window] 仍未找到有效微信主窗口") return None + # 取面积最大者(微信主窗口通常远大于通知/托盘窗口) + candidates.sort(key=lambda x: x[0], reverse=True) + area, wid, geom = candidates[0] + key = (wid, geom.x, geom.y, geom.width, geom.height, area) + if key == self._last_window_key: + logger.debug( + "[window] 主窗口未变化 id=%d area=%d geom=(%d,%d %dx%d)", + wid, area, geom.x, geom.y, geom.width, geom.height, + ) + else: + logger.info( + "[window] 选中微信主窗口 id=%d area=%d geom=(%d,%d %dx%d)", + wid, area, geom.x, geom.y, geom.width, geom.height, + ) + self._last_window_key = key + return wid + async def activate_window(self, window_id: Optional[int] = None) -> None: """激活微信窗口。 + 使用非阻塞 windowactivate 避免 --sync 在 VNC 无人操作时死等, + 之后轮询 getactivewindow 确认焦点是否真正落到目标窗口。 + Args: window_id: 指定窗口 ID;为 None 时自动查找 """ @@ -101,7 +215,23 @@ class WindowDriver(BaseDriver): code="WINDOW_NOT_FOUND", message="未找到微信窗口,无法激活", ) - await self._run(["xdotool", "windowactivate", "--sync", str(window_id)]) + await self._run(["xdotool", "windowactivate", str(window_id)]) + + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline: + rc, stdout, _ = await self._run(["xdotool", "getactivewindow"]) + if rc == 0: + active_text = stdout.decode(errors="ignore").strip() + try: + if int(active_text) == window_id: + return + except ValueError: + pass + await asyncio.sleep(0.1) + logger.warning( + "[window] activate_window: focus verify timeout id=%d, continue", + window_id, + ) async def get_window_geometry( self, window_id: Optional[int] = None diff --git a/bridge/woc_bridge/ui/flow.py b/bridge/woc_bridge/ui/flow.py index 43e5679..f517e9f 100644 --- a/bridge/woc_bridge/ui/flow.py +++ b/bridge/woc_bridge/ui/flow.py @@ -102,39 +102,61 @@ class Flow: # 状态恢复 # ------------------------------------------------------------------ async def _reset_to_idle(self, win_geom: Optional[WindowGeometry] = None) -> bool: - """多策略清场:Esc×3 + 点击空白 + BackSpace×30 + Esc×2 + 校验。 + """多策略清场:Esc×3 + 点击安全空白 + BackSpace×30 + Esc×2 + 校验。 Flow 失败或启动时调用,确保 UI 回到干净状态。 - 不依赖 win_geom(点击空白用固定坐标,避免 win_geom=None 时崩溃)。 + 点击位置采用比例坐标,固定落在右侧聊天区域,避免命中左侧联系人列表、 + 分割线或底部输入框导致的误操作。 Args: - win_geom: 窗口几何(可选,用于 post_verify) + win_geom: 窗口几何(可选,用于点击空白区域与 post_verify) Returns: - True 表示清场成功(post_verify 通过),False 表示可能仍残留脏状态 + True 表示清场成功(post_verify 通过或无需校验),False 表示可能仍残留脏状态 """ try: - # 1. Esc×3 关闭可能的弹窗/搜索框 + # 1. Esc×3 关闭可能的弹窗/搜索框/联系人详情 await self.actions.backend.key_press("Escape", repeat=3) - await asyncio.sleep(0.3) + await asyncio.sleep(0.2) - # 2. 点击主视图空白区域(避免焦点在搜索框/输入框) - if win_geom is not None: - # 点击主视图中心区域 - blank_x = win_geom.x + win_geom.width // 2 - blank_y = win_geom.y + win_geom.height // 2 + # 2. 点击右侧聊天区域的安全空白处(避开左侧联系人面板与底部输入区) + if win_geom is not None and win_geom.width >= 200 and win_geom.height >= 200: + # 左侧联系人面板一般占宽度 30% 以内,右侧安全区取 65%-85% + # 顶部工具栏约 8%,底部输入区约 15%-20%,安全 y 取 25%-45% + safe_x_ratio = 0.75 + safe_y_ratio = 0.40 + blank_x = win_geom.x + int(win_geom.width * safe_x_ratio) + blank_y = win_geom.y + int(win_geom.height * safe_y_ratio) + # 兜底保护:若计算点仍落在左侧 42% 区域内,则强制右移 + left_panel_max_x = win_geom.x + int(win_geom.width * 0.42) + if blank_x <= left_panel_max_x: + blank_x = win_geom.x + int(win_geom.width * 0.75) await self.actions.backend.click(blank_x, blank_y) - await asyncio.sleep(0.3) + logger.debug( + "[flow] _reset_to_idle click safe blank (%d,%d) ratio=(%.2f,%.2f)", + blank_x, blank_y, safe_x_ratio, safe_y_ratio, + ) + await asyncio.sleep(0.2) - # 3. BackSpace×30 清空可能残留的输入框内容 + # 3. BackSpace×30 清空可能残留的输入框/搜索框内容 await self.actions.backend.key_press("BackSpace", repeat=30) - await asyncio.sleep(0.2) + await asyncio.sleep(0.15) - # 4. Esc×2 再次关闭 + # 4. Esc×2 再次关闭可能因 BackSpace 触发的弹窗 await self.actions.backend.key_press("Escape", repeat=2) - await asyncio.sleep(0.2) + await asyncio.sleep(0.15) - # 5. post_verify:尝试用 main_view 模板校验 + # 5. 再次点击安全空白,确保焦点不在输入框 + if win_geom is not None and win_geom.width >= 200 and win_geom.height >= 200: + blank_x = win_geom.x + int(win_geom.width * 0.75) + blank_y = win_geom.y + int(win_geom.height * 0.40) + left_panel_max_x = win_geom.x + int(win_geom.width * 0.42) + if blank_x <= left_panel_max_x: + blank_x = win_geom.x + int(win_geom.width * 0.75) + await self.actions.backend.click(blank_x, blank_y) + await asyncio.sleep(0.15) + + # 6. post_verify:尝试用 main_view 模板校验 if win_geom is not None: try: elem = await self.actions.wait_for( diff --git a/bridge/woc_bridge/ui/flows/send_file.py b/bridge/woc_bridge/ui/flows/send_file.py index b05dfa0..915895d 100644 --- a/bridge/woc_bridge/ui/flows/send_file.py +++ b/bridge/woc_bridge/ui/flows/send_file.py @@ -149,36 +149,76 @@ class SendFileFlow: search_query = display_name.strip() if display_name and display_name.strip() else to_wxid logger.info( - "[flow:send_file] start to_wxid=%s file=%s size=%d type=%s timeout=%.1fs", + "[flow:send_file] START to_wxid=%s file=%s size=%d type=%s timeout=%.1fs display_name=%s", to_wxid, file_name, file_size, file_type, timeout_sec, + display_name or "(none)", ) # 3. 获取基线时间戳(post_verify 游标,仅查发送之后的新消息) # 复合游标 (create_time, local_id) 避免基线消息本身被重复匹配 baseline_time, baseline_local_id = await self._get_baseline_time(to_wxid) + logger.info( + "[flow:send_file] baseline ct=%d local_id=%d", + baseline_time, baseline_local_id, + ) try: async with asyncio.timeout(timeout_sec): # 4. 激活窗口 + 获取几何 + Esc 清场 + t0 = time.perf_counter() await self._actions.backend.activate_window(_WECHAT_WINDOW_TITLE) win_geom = await self._actions.backend.get_window_geometry( _WECHAT_WINDOW_TITLE ) await self._actions.backend.key_press("Escape") await asyncio.sleep(0.3) + logger.info( + "[flow:send_file] step=activate done geom=%dx%d@(%d,%d) (%.0fms)", + win_geom.width, win_geom.height, win_geom.x, win_geom.y, + (time.perf_counter() - t0) * 1000, + ) # 5. 定位会话(搜索框 → 输入 display_name → 点击搜索结果第一项) + t0 = time.perf_counter() + logger.info( + "[flow:send_file] step=open_session start query=%r", + search_query, + ) await self._open_session(search_query, win_geom) + logger.info( + "[flow:send_file] step=open_session done (%.0fms)", + (time.perf_counter() - t0) * 1000, + ) # 6. 点击 "+" → 选 "发送文件" 菜单项 + t0 = time.perf_counter() + logger.info("[flow:send_file] step=click_plus_and_send_file start") await self._click_plus_and_send_file(win_geom) + logger.info( + "[flow:send_file] step=click_plus_and_send_file done (%.0fms)", + (time.perf_counter() - t0) * 1000, + ) # 7. 系统文件选择器:xdotool type 路径 → Return 提交 + t0 = time.perf_counter() + logger.info( + "[flow:send_file] step=input_path_in_chooser start path=%s", + file_path, + ) await self._input_path_in_chooser(file_path) + logger.info( + "[flow:send_file] step=input_path_in_chooser done (%.0fms)", + (time.perf_counter() - t0) * 1000, + ) # 8. 等待文件加载到输入区 + logger.info("[flow:send_file] step=wait_file_load 1.0s") await asyncio.sleep(1.0) except asyncio.TimeoutError as exc: + logger.warning( + "[flow:send_file] TIMEOUT file=%s timeout=%.1fs", + file_name, timeout_sec, + ) # 兜底清场:Esc 关闭可能残留的弹窗/选择器 try: await self._actions.backend.key_press("Escape", repeat=2) @@ -191,12 +231,13 @@ class SendFileFlow: ) from exc # 9. post_verify:DB 校验文件消息是否落库 + t0 = time.perf_counter() verified = await self._post_verify( to_wxid, file_name, baseline_time, baseline_local_id ) logger.info( - "[flow:send_file] done file=%s verified=%s", - file_name, verified, + "[flow:send_file] DONE file=%s verified=%s (%.0fms)", + file_name, verified, (time.perf_counter() - t0) * 1000, ) return {"success": True, "verified": verified, "error": None} @@ -211,14 +252,17 @@ class SendFileFlow: search_query: 搜索查询词(display_name 优先,回退到 to_wxid) win_geom: 窗口几何信息 """ + logger.info("[flow:send_file] _open_session: click search_box") await self._actions.click_element("search_box", win_geom) await asyncio.sleep(0.3) # 清空可能残留的查询 + logger.info("[flow:send_file] _open_session: clear + type query") await self._actions.backend.key_press("BackSpace", repeat=30) await self._actions.backend.type_text(search_query) # 等待搜索结果出现 await asyncio.sleep(0.5) # 点击搜索结果第一项打开会话 + logger.info("[flow:send_file] _open_session: click search_result_first") await self._actions.click_element("search_result_first", win_geom) await asyncio.sleep(0.5) @@ -234,10 +278,18 @@ class SendFileFlow: # "+" 按钮:靠近窗口右下角(输入框右侧) plus_x = win_geom.x + win_geom.width - 30 plus_y = win_geom.y + win_geom.height - 30 + logger.info( + "[flow:send_file] _click_plus: click (+) at (%d,%d) geom=%dx%d@(%d,%d)", + plus_x, plus_y, win_geom.width, win_geom.height, win_geom.x, win_geom.y, + ) await self._actions.backend.click(plus_x, plus_y) await asyncio.sleep(0.6) # 等待菜单弹出 # 键盘导航选 "发送文件"(参考 publish_moment_with_image 的 Down + Return 范式) + logger.info( + "[flow:send_file] _click_plus: navigate menu Down×%d + Return", + _SEND_FILE_MENU_DOWN_COUNT, + ) for _ in range(_SEND_FILE_MENU_DOWN_COUNT): await self._actions.backend.key_press("Down") await asyncio.sleep(0.15) @@ -253,17 +305,26 @@ class SendFileFlow: 关键点:微信自绘 UI 不接受 Ctrl+V,但系统 GTK/Qt 文件选择器对话框 接受 xdotool type 逐字符输入。用 --delay 50 保证每字符被对话框处理。 """ + logger.info( + "[flow:send_file] _input_path: xdotool type path len=%d delay=50ms", + len(file_path), + ) rc, _, stderr = await self._xdotool._run( ["xdotool", "type", "--delay", "50", file_path] ) if rc != 0: err = stderr.decode(errors="ignore").strip() if stderr else "unknown" + logger.warning( + "[flow:send_file] _input_path: xdotool type FAILED rc=%d err=%s", + rc, err, + ) raise BridgeError( "SEND_FAILED", f"xdotool type 路径失败 (code={rc}): {err}", ) await asyncio.sleep(0.3) # 等待输入完成 # Return 提交选择 + logger.info("[flow:send_file] _input_path: Return to submit") await self._xdotool._key("Return") # ------------------------------------------------------------------ diff --git a/bridge/woc_bridge/ui/flows/send_text.py b/bridge/woc_bridge/ui/flows/send_text.py index 346a04a..29a8b04 100644 --- a/bridge/woc_bridge/ui/flows/send_text.py +++ b/bridge/woc_bridge/ui/flows/send_text.py @@ -15,6 +15,7 @@ import random import time from typing import Awaitable, Callable, Optional +from woc_bridge.ui.backends.base import WindowGeometry from woc_bridge.ui.errors import FlowError, PermanentError from woc_bridge.ui.flow import ( Flow, @@ -49,14 +50,17 @@ class SendTextFlow(Flow): # 后台 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", + ctx.to_wxid, ctx.display_name, len(ctx.content), ctx.client_request_id, + ) try: async with asyncio.timeout(_FLOW_TIMEOUT_SEC): # 检查会话缓存(用 to_wxid 做 key,display_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 + 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, @@ -65,16 +69,24 @@ class SendTextFlow(Flow): if cache_hit: # 跳过 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 = await self.actions.backend.get_window_geometry( - _WECHAT_WINDOW_TITLE + ctx.win_geom = self._ensure_valid_geometry( + await self.actions.backend.get_window_geometry( + _WECHAT_WINDOW_TITLE + ) ) await self._verify_session_still_open(ctx) ctx.current_state = FlowState.SESSION_OPENED + logger.info( + "[flow:send_text] cache_hit branch done geom=%dx%d (%.0fms)", + ctx.win_geom.width, ctx.win_geom.height, + (time.perf_counter() - t0) * 1000, + ) else: # 完整流程 await self._activate(ctx) @@ -93,6 +105,14 @@ class SendTextFlow(Flow): 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) @@ -100,6 +120,12 @@ class SendTextFlow(Flow): 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, @@ -111,8 +137,8 @@ class SendTextFlow(Flow): except (FlowError, asyncio.TimeoutError) as exc: logger.warning( - "[flow:send_text] failed at state=%s: %s", - ctx.current_state, exc, + "[flow:send_text] failed at state=%s: %s: %s", + ctx.current_state, type(exc).__name__, exc, ) # 尝试恢复 try: @@ -121,13 +147,13 @@ class SendTextFlow(Flow): logger.warning( "[flow:send_text] reset_to_idle failed: %s", reset_exc ) - # 失败时失效会话缓存 + # 失败时仅失效当前联系人会话缓存 if self.session_cache is not None: - self.session_cache.invalidate() + self.session_cache.invalidate(ctx.to_wxid) - error_code = "" + error_code = "SEND_FAILED" error_msg = str(exc) - if isinstance(exc, FlowError): + if isinstance(exc, FlowError) and exc.code: error_code = exc.code elif isinstance(exc, asyncio.TimeoutError): error_code = "SEND_TIMEOUT" @@ -148,7 +174,7 @@ class SendTextFlow(Flow): except Exception: pass if self.session_cache is not None: - self.session_cache.invalidate() + self.session_cache.invalidate(ctx.to_wxid) return FlowResult( success=False, error=str(exc), @@ -170,13 +196,34 @@ class SendTextFlow(Flow): # ------------------------------------------------------------------ # 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。""" + """激活窗口 + 获取几何 + 重置状态(回到会话列表首页)。""" await self.actions.backend.activate_window(_WECHAT_WINDOW_TITLE) - ctx.win_geom = await self.actions.backend.get_window_geometry( - _WECHAT_WINDOW_TITLE + ctx.win_geom = self._ensure_valid_geometry( + await self.actions.backend.get_window_geometry(_WECHAT_WINDOW_TITLE) ) + # 重置状态:点击左侧"会话管理"图标,强制回到会话列表首页, + # 清除可能存在的搜索覆盖层、弹窗或非会话页面。 + await self.actions.click_element("session_manager_icon", ctx.win_geom) 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", @@ -185,77 +232,149 @@ class SendTextFlow(Flow): 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 - # 简化:点击后立即可输入,等待 0.3s 让 UI 响应 - await asyncio.sleep(0.3) + # 点击后短暂等待 UI 响应,高并发场景下调短以提升吞吐 + await asyncio.sleep(0.2) + 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)。""" - # 清空可能的残留 - await self.actions.backend.key_press("BackSpace", repeat=30) + 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 - # 自适应等待搜索结果 + # 自适应等待搜索结果渲染;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=3.0, + timeout=1.5, interval=0.3, ) if not appeared: logger.warning( - "[flow:send_text] search result not appeared in 3s, continue" + "[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: """点击搜索结果第一项打开会话。""" + 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 出现 + # 自适应等待 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=3.0, + timeout=1.5, interval=0.3, ) if not appeared: logger.warning( - "[flow:send_text] input_box not appeared in 3s, continue" + "[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: """聚焦输入框。""" + t0 = time.perf_counter() + logger.info("[flow:send_text] step=focus_input click input_box start") await self.actions.click_element("input_box", ctx.win_geom) ctx.current_state = FlowState.INPUT_FOCUSED + await asyncio.sleep(0.1) + logger.info( + "[flow:send_text] step=focus_input done (%.0fms)", + (time.perf_counter() - t0) * 1000, + ) async def _type_content(self, ctx: FlowContext) -> None: """输入消息内容。""" + t0 = time.perf_counter() + # 清理开头/结尾的空白和换行,避免输入框视觉为空、发送按钮灰色 + cleaned_content = ctx.content.strip() + if cleaned_content != ctx.content: + logger.info( + "[flow:send_text] step=type_content stripped leading/trailing whitespace: " + "original_len=%d cleaned_len=%d", + len(ctx.content), len(cleaned_content), + ) + ctx.content = cleaned_content + logger.info( + "[flow:send_text] step=type_content start content_len=%d", + len(ctx.content), + ) await self.actions.backend.type_text(ctx.content) ctx.current_state = FlowState.TEXT_TYPED + logger.info( + "[flow:send_text] step=type_content done (%.0fms)", + (time.perf_counter() - t0) * 1000, + ) async def _send(self, ctx: FlowContext) -> None: """点击发送按钮 + 校验。""" + 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) 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", + "[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, + ) # ------------------------------------------------------------------ # 校验 # ------------------------------------------------------------------ async def _verify_sent(self, ctx: FlowContext) -> bool: """发送结果校验:DB 校验优先,失败降级截图校验。""" + t0 = time.perf_counter() 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) + 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: @@ -263,7 +382,12 @@ class SendTextFlow(Flow): "[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) + 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 deadline = time.monotonic() + _DB_VERIFY_TIMEOUT_SEC attempts = 0 @@ -289,14 +413,15 @@ class SendTextFlow(Flow): # 微信消息内容可能包含发送者前缀(群消息),用 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"), + "[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)", + "[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: @@ -307,46 +432,104 @@ class SendTextFlow(Flow): await asyncio.sleep(_DB_VERIFY_INTERVAL_SEC) logger.warning( - "[flow:send_text] DB verify timeout, fall back to screenshot" + "[flow:send_text] DB verify timeout after %ds/%d attempts, fall back to screenshot", + _DB_VERIFY_TIMEOUT_SEC, attempts, ) - return await self._verify_by_screenshot(ctx) + 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 模板出现。""" + """截图校验:轮询等待 input_box_empty 模板出现。 + + 策略说明: + - OpenCV image 命中 → 可靠校验通过,返回 True + - geom 兜底命中 → 不可靠,继续轮询等待 image 命中;超时后返回 False + - 超时/异常 → 返回 False + + geom 兜底永远返回坐标,无法证明输入框已清空。以往乐观返回 True 会掩盖 + 真实发送失败,现改为只有 image 命中才认为校验通过。 + """ if ctx.win_geom is None: + logger.warning("[flow:send_text] screenshot verify skipped: 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 + 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": + 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, ) - 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 + 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 存在)。""" - # 获取窗口几何(缓存命中时未走 _activate) - ctx.win_geom = await self.actions.backend.get_window_geometry( - _WECHAT_WINDOW_TITLE + t0 = time.perf_counter() + # 获取窗口几何(缓存命中时未走 _activate),并校验/窄化类型 + ctx.win_geom = self._ensure_valid_geometry( + await self.actions.backend.get_window_geometry(_WECHAT_WINDOW_TITLE) ) try: elem = await self.actions.wait_for( "input_box", ctx.win_geom, timeout=2.0, interval=0.3 ) if elem is None: + logger.warning( + "[flow:send_text] session_still_open FAIL: input_box not found (%.0fms)", + (time.perf_counter() - t0) * 1000, + ) raise PermanentError( "ELEMENT_NOT_FOUND", "session cache hit but 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, + ) 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}", @@ -373,20 +556,29 @@ class SendTextFlow(Flow): 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 + 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 + return elem is not None and elem.strategy == "image" except Exception: return False @@ -396,7 +588,9 @@ class SendTextFlow(Flow): 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, @@ -410,9 +604,18 @@ class SendTextFlow(Flow): 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.debug( - "[flow:send_text] get_latest_create_time exception: %s", exc + logger.warning( + "[flow:send_text] get_latest_create_time exception: %s (%.0fms)", + exc, (time.perf_counter() - t0) * 1000, ) return (0, 0) diff --git a/bridge/woc_bridge/ui/locators/registry.py b/bridge/woc_bridge/ui/locators/registry.py index 69c714c..25062aa 100644 --- a/bridge/woc_bridge/ui/locators/registry.py +++ b/bridge/woc_bridge/ui/locators/registry.py @@ -9,6 +9,7 @@ from __future__ import annotations import logging import os +import re import time from typing import Optional @@ -51,18 +52,45 @@ class LocatorRegistry: # Profile 加载 # ------------------------------------------------------------------ def load(self, app_version: str, resolution: tuple[int, int]) -> None: - """按精确 > 版本 > default 优先级加载 YAML profile。 + """按精确 > 最近分辨率 > 版本 > default 优先级加载 YAML profile。 Args: app_version: 微信版本号,如 "4.0" resolution: (width, height) 元组,如 (1920, 1080) """ - candidates = [ - f"wechat_{app_version}_{resolution[0]}x{resolution[1]}.yaml", - f"wechat_{app_version}.yaml", - "default.yaml", - ] - for name in candidates: + exact_name = f"wechat_{app_version}_{resolution[0]}x{resolution[1]}.yaml" + exact_path = os.path.join(self.profile_dir, exact_name) + if os.path.exists(exact_path): + logger.info( + "[locator] load exact profile: %s (app_version=%s resolution=%s)", + exact_name, + app_version, + resolution, + ) + self._load_yaml(exact_path) + return + + # 无精确匹配时,找同版本最近分辨率并按比例缩放绝对偏移 + nearest = self._find_nearest_profile(app_version, resolution) + if nearest: + name, profile_w, profile_h = nearest + logger.info( + "[locator] load nearest profile: %s for resolution=%s " + "(scale=%.3fx%.3f)", + name, + resolution, + resolution[0] / profile_w, + resolution[1] / profile_h, + ) + self._load_yaml( + os.path.join(self.profile_dir, name), + target_resolution=resolution, + profile_resolution=(profile_w, profile_h), + ) + return + + # 版本通用/默认兜底 + for name in [f"wechat_{app_version}.yaml", "default.yaml"]: path = os.path.join(self.profile_dir, name) if os.path.exists(path): logger.info( @@ -73,6 +101,7 @@ class LocatorRegistry: ) self._load_yaml(path) return + logger.warning( "[locator] no profile found in %s, fall back to built-in defaults " "(app_version=%s resolution=%s)", @@ -82,6 +111,30 @@ class LocatorRegistry: ) self._load_defaults() + def _find_nearest_profile( + self, app_version: str, resolution: tuple[int, int] + ) -> Optional[tuple[str, int, int]]: + """找与当前分辨率最接近的 wechat__x.yaml。""" + pattern = re.compile(rf"wechat_{re.escape(app_version)}_(\d+)x(\d+)\.yaml$") + target_w, target_h = resolution + target_area = target_w * target_h + best: Optional[tuple[str, int, int]] = None + best_score: Optional[float] = None + for name in os.listdir(self.profile_dir): + m = pattern.match(name) + if not m: + continue + pw, ph = int(m.group(1)), int(m.group(2)) + area = pw * ph + area_ratio = min(target_area, area) / max(target_area, area) + aspect_diff = abs(target_w / target_h - pw / ph) + # 面积越接近、宽高比越接近,分数越低 + score = (1 - area_ratio) + aspect_diff + if best_score is None or score < best_score: + best_score = score + best = (name, pw, ph) + return best + def set_theme(self, theme: str) -> None: """切换主题(light / dark)。""" if theme != self.theme: @@ -135,9 +188,17 @@ class LocatorRegistry: # ------------------------------------------------------------------ # YAML 解析与默认值 # ------------------------------------------------------------------ - def _load_yaml(self, path: str) -> None: + def _load_yaml( + self, + path: str, + target_resolution: Optional[tuple[int, int]] = None, + profile_resolution: Optional[tuple[int, int]] = None, + ) -> None: """用 yaml.safe_load 加载 YAML 并填充 self.selectors。 + 若提供 target_resolution 与 profile_resolution,则对 by_geom 中的 + 绝对偏移 x_offset / y_offset 按分辨率比例缩放(宽高比不变时坐标更接近目标)。 + YAML 结构(每个 key 对应一个 Selector): search_box: by_image: search_box.png # str,可省略 @@ -159,6 +220,11 @@ class LocatorRegistry: self._load_defaults() return + scale_x = scale_y = 1.0 + if target_resolution and profile_resolution: + scale_x = target_resolution[0] / profile_resolution[0] + scale_y = target_resolution[1] / profile_resolution[1] + self.selectors.clear() for kind, spec in data.items(): if not isinstance(spec, dict): @@ -171,8 +237,8 @@ class LocatorRegistry: relative_to=by_geom_raw.get("relative_to", "window"), x_ratio=float(by_geom_raw.get("x_ratio", 0.0)), y_ratio=float(by_geom_raw.get("y_ratio", 0.0)), - x_offset=int(by_geom_raw.get("x_offset", 0)), - y_offset=int(by_geom_raw.get("y_offset", 0)), + 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), ) self.selectors[kind] = Selector( kind=kind, @@ -183,7 +249,8 @@ class LocatorRegistry: description=str(spec.get("description", "")), ) logger.info( - "[locator] loaded %d selectors from %s", len(self.selectors), path + "[locator] loaded %d selectors from %s (scale=%.3fx%.3f)", + len(self.selectors), path, scale_x, scale_y, ) def _default_selector(self, kind: str) -> Selector: @@ -201,12 +268,23 @@ class LocatorRegistry: (offset 已含正负号区分方向,详见 Actions._geom_find)。 """ defaults = { + "session_manager_icon": Selector( + kind="session_manager_icon", + by_geom=GeomSpec( + relative_to="window", + x_ratio=0.018, + y_ratio=0.109, + x_offset=0, + y_offset=0, + ), + description="左侧导航栏顶部微信消息/会话管理图标", + ), "search_box": Selector( kind="search_box", by_geom=GeomSpec( relative_to="window", - x_ratio=0.08, - y_ratio=0.05, + x_ratio=0.092, + y_ratio=0.095, x_offset=0, y_offset=0, ), @@ -215,11 +293,11 @@ class LocatorRegistry: "send_button": Selector( kind="send_button", by_geom=GeomSpec( - relative_to="window_bottom_right", - x_ratio=1.0, - y_ratio=1.0, - x_offset=-60, - y_offset=-30, + relative_to="window", + x_ratio=0.972, + y_ratio=0.929, + x_offset=0, + y_offset=0, ), description="右下角发送按钮", ), @@ -227,8 +305,8 @@ class LocatorRegistry: kind="input_box", by_geom=GeomSpec( relative_to="window", - x_ratio=0.65, - y_ratio=0.93, + x_ratio=0.743, + y_ratio=0.914, x_offset=0, y_offset=0, ), @@ -238,8 +316,8 @@ class LocatorRegistry: kind="input_box_empty", by_geom=GeomSpec( relative_to="window", - x_ratio=0.65, - y_ratio=0.93, + x_ratio=0.743, + y_ratio=0.914, x_offset=0, y_offset=0, ), @@ -249,8 +327,8 @@ class LocatorRegistry: kind="search_result_first", by_geom=GeomSpec( relative_to="window", - x_ratio=0.08, - y_ratio=0.11, + x_ratio=0.078, + y_ratio=0.174, x_offset=0, y_offset=0, ), diff --git a/bridge/woc_bridge/ui/orchestrator.py b/bridge/woc_bridge/ui/orchestrator.py index 492ba8c..fe93053 100644 --- a/bridge/woc_bridge/ui/orchestrator.py +++ b/bridge/woc_bridge/ui/orchestrator.py @@ -6,7 +6,7 @@ send_text 流程: 3. 入队(自适应 delay_ms:同联系人 1000 / 不同 config.send_delay_ms) 4. 成功且 verified 才写幂等缓存 5. DB 校验失败计数 + 熔断 -6. 失败时 session_cache.invalidate() +6. 失败时 session_cache.invalidate(to_wxid)(仅失效当前联系人) """ from __future__ import annotations @@ -14,9 +14,14 @@ from __future__ import annotations import asyncio import logging import time -from typing import Optional +from collections import OrderedDict +from typing import Optional, TYPE_CHECKING 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.flow import FlowContext, FlowResult, FlowState @@ -29,33 +34,49 @@ _SAME_CONTACT_DELAY_MS = 1000 class SessionCache: - """会话缓存:记录最近打开的会话名,30s TTL。 + """会话缓存:LRU 多会话,30s TTL。 命中时 SendTextFlow 跳过 activate/click_search_box/type_query/open_session 四步, 直接校验会话仍打开后进入 focus_input。 + 多会话缓存提升 30-100 并发人群中重复联系人的命中率。 """ - def __init__(self, ttl: float = 30.0) -> None: + def __init__(self, ttl: float = 30.0, max_size: int = 16) -> None: self.ttl = ttl - self._current: Optional[tuple[str, float]] = None # (to_wxid, expire_at) + self.max_size = max_size + # wxid -> expire_at,按最近使用排序 + self._cache: OrderedDict[str, float] = OrderedDict() - def get(self) -> Optional[str]: - """返回当前缓存的 to_wxid,过期或无缓存返回 None。""" - if self._current is None: - return None - to_wxid, expire_at = self._current + def get(self, to_wxid: str) -> bool: + """查询指定 wxid 是否在缓存且未过期。命中时移到队尾(最近使用)。""" + expire_at = self._cache.get(to_wxid) + if expire_at is None: + return False if time.monotonic() >= expire_at: - self._current = None - return None - return to_wxid + self._cache.pop(to_wxid, None) + return False + self._cache.move_to_end(to_wxid) + return True def set(self, to_wxid: str) -> None: """更新缓存。""" - self._current = (to_wxid, time.monotonic() + self.ttl) + self._cache[to_wxid] = time.monotonic() + self.ttl + self._cache.move_to_end(to_wxid) + # 超限淘汰最久未使用 + while len(self._cache) > self.max_size: + self._cache.popitem(last=False) - def invalidate(self) -> None: - """失效缓存。""" - self._current = None + def invalidate(self, to_wxid: Optional[str] = None) -> None: + """失效缓存。 + + Args: + to_wxid: 为 None 时清除全部缓存;为具体 wxid 时仅清除该条目 + (传空字符串不会误清全部)。 + """ + if to_wxid is None: + self._cache.clear() + else: + self._cache.pop(to_wxid, None) class FlowOrchestrator: @@ -91,10 +112,11 @@ class FlowOrchestrator: self.actions = actions self.db_reader = db_reader self.config = config - self.session_cache = SessionCache(ttl=30.0) - # DB 校验熔断器:5 次失败 / 30s 恢复 + # 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=5, recovery_timeout=30.0 + "db_verify", failure_threshold=10, recovery_timeout=30.0 ) self._send_text_flow = send_text_flow self._send_file_flow = send_file_flow @@ -153,6 +175,11 @@ class FlowOrchestrator: """ started_at = time.monotonic() + logger.info( + "[orchestrator] send_text START to_wxid=%s content_len=%d display_name=%s client_request_id=%s", + to_wxid, len(content), display_name or "(empty)", client_request_id or "(none)", + ) + # 1. 幂等检查 cached = self.idem_cache.get( "send_text", to_wxid, content, client_request_id @@ -182,7 +209,8 @@ class FlowOrchestrator: # 2. DB 校验熔断检查 if not self.db_verify_breaker.allow(): logger.warning( - "[orchestrator] db_verify breaker OPEN, reject send" + "[orchestrator] db_verify breaker OPEN, reject send (to_wxid=%s)", + to_wxid, ) return FlowResult( success=False, @@ -193,12 +221,13 @@ class FlowOrchestrator: ) # 3. 自适应延时:同联系人用短延时 - cached_session = self.session_cache.get() - is_same_contact = ( - cached_session is not None and cached_session == to_wxid - ) + 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 + logger.info( + "[orchestrator] send_text enqueue: same_contact=%s delay_ms=%s to_wxid=%s", + is_same_contact, delay_ms, to_wxid, + ) # 4. 构造 FlowContext ctx = FlowContext( @@ -208,19 +237,21 @@ class FlowOrchestrator: client_request_id=client_request_id, ) - # 5. 入队执行 + # 5. 入队执行(带队列等待超时,避免无界排队) flow = self._get_send_text_flow() try: result: FlowResult = await self.send_queue.enqueue( lambda: flow.run(ctx), delay_ms=delay_ms, + wait_timeout_ms=self.config.send_queue_wait_timeout_ms, ) except Exception as exc: logger.error( - "[orchestrator] send_queue exception: %s", exc + "[orchestrator] send_queue exception: %s: %s (to_wxid=%s)", + type(exc).__name__, exc, to_wxid, ) - # 失败时失效会话缓存 - self.session_cache.invalidate() + # 失败时仅失效当前联系人会话缓存,避免误伤其它缓存命中 + self.session_cache.invalidate(to_wxid) # 保留 BridgeError 的原始错误码(如 RATE_LIMITED), # 避免限流/超时等可重试错误被吞成 SEND_FAILED error_code = "SEND_FAILED" @@ -246,18 +277,24 @@ class FlowOrchestrator: "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() logger.warning( - "[orchestrator] send success but verify failed, breaker fail_count+1" + "[orchestrator] send success but verify failed, breaker fail_count+1 (to_wxid=%s)", + to_wxid, ) else: - # 发送失败:失效会话缓存 - self.session_cache.invalidate() + # 发送失败:仅失效当前联系人会话缓存,避免误伤其它缓存命中 + self.session_cache.invalidate(to_wxid) logger.warning( - "[orchestrator] send failed: %s (code=%s)", - result.error, result.error_code, + "[orchestrator] send failed: %s (code=%s, to_wxid=%s)", + result.error, result.error_code, to_wxid, ) return result @@ -302,6 +339,11 @@ class FlowOrchestrator: # client_request_id 归一化:None -> "" idem_id = client_request_id or "" + logger.info( + "[orchestrator] send_file START to_wxid=%s file=%s type=%s display_name=%s client_request_id=%s", + to_wxid, file_path, file_type, display_name or "(none)", idem_id or "(none)", + ) + # 1. 幂等检查 cached = self.idem_cache.get("send_file", to_wxid, file_path, idem_id) if cached is not None: @@ -327,7 +369,8 @@ class FlowOrchestrator: # 2. DB 校验熔断检查 if not self.db_verify_breaker.allow(): logger.warning( - "[orchestrator] send_file: db_verify breaker OPEN, reject" + "[orchestrator] send_file: db_verify breaker OPEN, reject (to_wxid=%s)", + to_wxid, ) return { "success": False, "verified": False, "placeholder": False, @@ -340,19 +383,24 @@ class FlowOrchestrator: flow = self._get_send_file_flow() # 4. 入队执行(SendFileFlow.execute 内部完成路径校验 + 文件选择器输入 + post_verify) + logger.info( + "[orchestrator] send_file enqueue (pending=%d)", + self.send_queue.pending_count(), + ) try: result = await self.send_queue.enqueue( lambda: flow.execute( to_wxid, file_path, file_type, display_name=display_name ), + wait_timeout_ms=self.config.send_queue_wait_timeout_ms, ) except BridgeError as exc: logger.error( - "[orchestrator] send_file BridgeError code=%s msg=%s", - exc.code, exc.message, + "[orchestrator] send_file BridgeError code=%s msg=%s (to_wxid=%s)", + exc.code, exc.message, to_wxid, ) - # 失败时失效会话缓存 - self.session_cache.invalidate() + # 失败时失效当前联系人会话缓存 + self.session_cache.invalidate(to_wxid) return { "success": False, "verified": False, "placeholder": False, "skipped": False, @@ -361,10 +409,10 @@ class FlowOrchestrator: } except Exception as exc: logger.error( - "[orchestrator] send_file exception: %s: %s", - type(exc).__name__, exc, + "[orchestrator] send_file exception: %s: %s (to_wxid=%s)", + type(exc).__name__, exc, to_wxid, ) - self.session_cache.invalidate() + self.session_cache.invalidate(to_wxid) return { "success": False, "verified": False, "placeholder": False, "skipped": False, @@ -386,12 +434,18 @@ class FlowOrchestrator: self.db_verify_breaker.record_success() # 更新会话缓存(send_file 打开了 to_wxid 会话) self.session_cache.set(to_wxid) + logger.info( + "[orchestrator] send_file DONE ✓ to=%s file=%s verified=%s (%.0fms)", + to_wxid, file_path, verified, + (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" + "[orchestrator] send_file success but verify failed, breaker fail_count+1 (to_wxid=%s)", + to_wxid, ) elif success and verified is None: # P0 修复:发送成功但无法校验(DB 不可用或无基线): @@ -399,21 +453,17 @@ class FlowOrchestrator: # 也不写幂等缓存(未校验的结果不应被复用)。 self.session_cache.set(to_wxid) logger.info( - "[orchestrator] send_file success but verify skipped (DB unavailable), breaker untouched" + "[orchestrator] send_file success but verify skipped (DB unavailable), breaker untouched (to_wxid=%s)", + to_wxid, ) else: - # 发送失败:失效会话缓存 - self.session_cache.invalidate() + # 发送失败:仅失效当前联系人会话缓存 + self.session_cache.invalidate(to_wxid) logger.warning( - "[orchestrator] send_file failed: %s", - result.get("error"), + "[orchestrator] send_file failed: %s (to_wxid=%s)", + result.get("error"), to_wxid, ) - logger.info( - "[orchestrator] send_file done to=%s file=%s success=%s verified=%s (%.0fms)", - to_wxid, file_path, success, verified, - (time.monotonic() - started_at) * 1000, - ) return { "success": success, "verified": verified, diff --git a/bridge/woc_bridge/ui/profiles/default.yaml b/bridge/woc_bridge/ui/profiles/default.yaml index 945bb0c..9b45ce4 100644 --- a/bridge/woc_bridge/ui/profiles/default.yaml +++ b/bridge/woc_bridge/ui/profiles/default.yaml @@ -10,8 +10,8 @@ search_box: by_geom: relative_to: window - x_ratio: 0.08 - y_ratio: 0.05 + x_ratio: 0.092 + y_ratio: 0.095 x_offset: 0 y_offset: 0 threshold: 0.80 @@ -20,11 +20,11 @@ search_box: send_button: by_geom: - relative_to: window_bottom_right - x_ratio: 1.0 - y_ratio: 1.0 - x_offset: -60 - y_offset: -30 + relative_to: window + x_ratio: 0.972 + y_ratio: 0.929 + x_offset: 0 + y_offset: 0 threshold: 0.80 require_image: false description: 右下角发送按钮 @@ -32,8 +32,8 @@ send_button: input_box: by_geom: relative_to: window - x_ratio: 0.65 - y_ratio: 0.93 + x_ratio: 0.743 + y_ratio: 0.914 x_offset: 0 y_offset: 0 threshold: 0.80 @@ -43,8 +43,8 @@ input_box: input_box_empty: by_geom: relative_to: window - x_ratio: 0.65 - y_ratio: 0.93 + x_ratio: 0.743 + y_ratio: 0.914 x_offset: 0 y_offset: 0 threshold: 0.80 @@ -54,8 +54,8 @@ input_box_empty: search_result_first: by_geom: relative_to: window - x_ratio: 0.08 - y_ratio: 0.11 + x_ratio: 0.078 + y_ratio: 0.174 x_offset: 0 y_offset: 0 threshold: 0.80 @@ -78,7 +78,7 @@ contact_icon: by_geom: relative_to: window x_ratio: 0.04 - y_ratio: 0.15 + y_ratio: 0.171 x_offset: 0 y_offset: 0 threshold: 0.80 @@ -88,32 +88,43 @@ contact_icon: new_friend_entry: by_geom: relative_to: window - x_ratio: 0.04 - y_ratio: 0.22 + x_ratio: 0.125 + y_ratio: 0.138 x_offset: 0 y_offset: 0 threshold: 0.80 require_image: false description: 通讯录页"新的朋友"入口 -verify_button: +friend_request_item: by_geom: - relative_to: window_bottom_right - x_ratio: 1.0 - y_ratio: 0.30 - x_offset: -80 + relative_to: window + x_ratio: 0.125 + y_ratio: 0.178 + x_offset: 0 y_offset: 0 threshold: 0.75 require_image: false - description: 好友申请项"前往验证"按钮 + description: '"新的朋友"列表第一条申请项' + +verify_button: + by_geom: + relative_to: window + x_ratio: 0.593 + y_ratio: 0.394 + x_offset: 0 + y_offset: 0 + threshold: 0.75 + require_image: false + description: 好友详情页"前往验证"按钮(OpenCV 实测标定) confirm_button: by_geom: - relative_to: window_bottom_right - x_ratio: 0.5 - y_ratio: 1.0 + relative_to: window + x_ratio: 0.464 + y_ratio: 0.830 x_offset: 0 - y_offset: -60 + y_offset: 0 threshold: 0.75 require_image: false - description: "通过朋友验证"弹窗确定按钮 + description: '"通过朋友验证"弹窗绿色确定按钮(OpenCV 弹窗出现时实测 666/1435, 727/876)' diff --git a/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/confirm_button.png b/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/confirm_button.png index 35a340b..c99244b 100644 Binary files a/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/confirm_button.png and b/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/confirm_button.png differ diff --git a/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/contact_icon.png b/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/contact_icon.png index 35a340b..276fceb 100644 Binary files a/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/contact_icon.png and b/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/contact_icon.png differ diff --git a/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/friend_request_item.png b/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/friend_request_item.png new file mode 100644 index 0000000..7284690 Binary files /dev/null and b/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/friend_request_item.png differ diff --git a/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/input_box.png b/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/input_box.png index e9f1e91..fa20bc8 100644 Binary files a/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/input_box.png and b/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/input_box.png differ diff --git a/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/input_box_empty.png b/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/input_box_empty.png index 1809557..fa20bc8 100644 Binary files a/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/input_box_empty.png and b/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/input_box_empty.png differ diff --git a/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/main_view.png b/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/main_view.png index 242f5f4..0f28ce4 100644 Binary files a/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/main_view.png and b/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/main_view.png differ diff --git a/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/new_friend_entry.png b/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/new_friend_entry.png index 35a340b..be12e6b 100644 Binary files a/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/new_friend_entry.png and b/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/new_friend_entry.png differ diff --git a/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/search_box.png b/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/search_box.png index 7bc4132..94e594e 100644 Binary files a/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/search_box.png and b/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/search_box.png differ diff --git a/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/search_result_first.png b/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/search_result_first.png index 9255b31..59a1f39 100644 Binary files a/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/search_result_first.png and b/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/search_result_first.png differ diff --git a/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/send_button.png b/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/send_button.png index 77117e7..cf7d80d 100644 Binary files a/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/send_button.png and b/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/send_button.png differ diff --git a/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/verify_button.png b/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/verify_button.png index 35a340b..cba2f74 100644 Binary files a/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/verify_button.png and b/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/verify_button.png differ diff --git a/bridge/woc_bridge/ui/profiles/wechat_4.0_1280x720.yaml b/bridge/woc_bridge/ui/profiles/wechat_4.0_1280x720.yaml index 0febaba..08f0778 100644 --- a/bridge/woc_bridge/ui/profiles/wechat_4.0_1280x720.yaml +++ b/bridge/woc_bridge/ui/profiles/wechat_4.0_1280x720.yaml @@ -11,8 +11,8 @@ search_box: by_image: search_box.png by_geom: relative_to: window - x_ratio: 0.08 - y_ratio: 0.05 + x_ratio: 0.092 + y_ratio: 0.095 x_offset: 0 y_offset: 0 threshold: 0.80 @@ -22,12 +22,11 @@ search_box: send_button: by_image: send_button.png by_geom: - relative_to: window_bottom_right - x_ratio: 1.0 - y_ratio: 1.0 - # 1920x1080 的 -60/-30 按 2/3 缩放为 -40/-20 - x_offset: -40 - y_offset: -20 + relative_to: window + x_ratio: 0.972 + y_ratio: 0.929 + x_offset: 0 + y_offset: 0 threshold: 0.80 require_image: false description: 右下角发送按钮 @@ -36,8 +35,8 @@ input_box: by_image: input_box.png by_geom: relative_to: window - x_ratio: 0.65 - y_ratio: 0.93 + x_ratio: 0.743 + y_ratio: 0.914 x_offset: 0 y_offset: 0 threshold: 0.80 @@ -48,8 +47,8 @@ input_box_empty: by_image: input_box_empty.png by_geom: relative_to: window - x_ratio: 0.65 - y_ratio: 0.93 + x_ratio: 0.743 + y_ratio: 0.914 x_offset: 0 y_offset: 0 threshold: 0.80 @@ -60,8 +59,8 @@ search_result_first: by_image: search_result_first.png by_geom: relative_to: window - x_ratio: 0.08 - y_ratio: 0.11 + x_ratio: 0.078 + y_ratio: 0.174 x_offset: 0 y_offset: 0 threshold: 0.80 @@ -69,7 +68,6 @@ search_result_first: description: 搜索结果第一项 main_view: - by_image: main_view.png by_geom: relative_to: window x_ratio: 0.65 @@ -78,7 +76,7 @@ main_view: y_offset: 0 threshold: 0.75 require_image: false - description: 主聊天视图(post_verify 校验用,阈值略低因区域大) + description: 主聊天视图(post_verify 校验用,模板匹配不稳定,使用几何兜底) # 好友申请通过 UI 元素定位(experimental,坐标为估算值需实测校准) contact_icon: @@ -86,7 +84,7 @@ contact_icon: by_geom: relative_to: window x_ratio: 0.04 - y_ratio: 0.15 + y_ratio: 0.171 x_offset: 0 y_offset: 0 threshold: 0.80 @@ -97,34 +95,44 @@ new_friend_entry: by_image: new_friend_entry.png by_geom: relative_to: window - x_ratio: 0.04 - y_ratio: 0.22 + x_ratio: 0.125 + y_ratio: 0.138 x_offset: 0 y_offset: 0 threshold: 0.80 require_image: false description: 通讯录页"新的朋友"入口 -verify_button: - by_image: verify_button.png +friend_request_item: by_geom: - relative_to: window_bottom_right - x_ratio: 1.0 - y_ratio: 0.30 - x_offset: -53 + relative_to: window + x_ratio: 0.125 + y_ratio: 0.178 + x_offset: 0 y_offset: 0 threshold: 0.75 require_image: false - description: 好友申请项"前往验证"按钮 + description: '"新的朋友"列表第一条申请项(模板匹配不稳定,使用几何兜底)' -confirm_button: - by_image: confirm_button.png +verify_button: + by_image: verify_button.png by_geom: - relative_to: window_bottom_right - x_ratio: 0.5 - y_ratio: 1.0 + relative_to: window + x_ratio: 0.593 + y_ratio: 0.394 x_offset: 0 - y_offset: -40 + y_offset: 0 threshold: 0.75 require_image: false - description: '"通过朋友验证"弹窗确定按钮' + description: 好友详情页"前往验证"按钮(OpenCV 实测标定) + +confirm_button: + by_geom: + relative_to: window + x_ratio: 0.464 + y_ratio: 0.830 + x_offset: 0 + y_offset: 0 + threshold: 0.75 + require_image: false + description: '"通过朋友验证"弹窗绿色确定按钮(OpenCV 弹窗出现时实测 666/1435, 727/876)' diff --git a/bridge/woc_bridge/ui/profiles/wechat_4.0_1435x876.yaml b/bridge/woc_bridge/ui/profiles/wechat_4.0_1435x876.yaml new file mode 100644 index 0000000..be9039e --- /dev/null +++ b/bridge/woc_bridge/ui/profiles/wechat_4.0_1435x876.yaml @@ -0,0 +1,161 @@ +# 微信 4.0 UI 自动化 Profile - 1435x876 分辨率 +# +# 适用场景:VNC/实际桌面分辨率为 1435x876 时运行微信 4.x。 +# 该分辨率为 bridge 运行环境实测常见值(1920x1080 经标题栏/缩放后约为 1435x876)。 +# 坐标基于截图标定,relative_to 统一使用 window 语义(与 Actions._geom_find 实现一致)。 +# +# 字段说明: +# by_image: 模板文件名(相对 templates/wechat_4.0/light/) +# by_geom: 几何兜底规格(图像匹配失败或熔断时使用) +# relative_to: window / window_bottom_right(当前统一按 window 处理) +# x_ratio / y_ratio: 相对窗口宽高的比例位置 +# x_offset / y_offset: 绝对像素偏移(含正负号) +# threshold: 图像匹配置信度阈值 +# require_image: true 时图像失败拒绝执行(高风险元素) +# description: 元素描述 + +# 会话管理图标:左侧导航栏最上方(头像下方第一个)微信消息图标。 +# 每次发送前点击它,强制回到会话列表首页,清除搜索覆盖层/弹窗/非会话页面。 +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 + 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 + x_offset: 0 + y_offset: 0 + threshold: 0.80 + require_image: false + description: 左侧顶部搜索框 + +send_button: + by_image: send_button.png + by_geom: + relative_to: window + x_ratio: 0.972 + y_ratio: 0.929 + x_offset: 0 + y_offset: 0 + threshold: 0.80 + require_image: false + description: 右下角发送按钮 + +input_box: + by_image: input_box.png + by_geom: + relative_to: window + x_ratio: 0.743 + y_ratio: 0.914 + x_offset: 0 + y_offset: 0 + threshold: 0.80 + require_image: false + description: 聊天输入框 + +input_box_empty: + by_image: input_box_empty.png + by_geom: + relative_to: window + x_ratio: 0.743 + y_ratio: 0.914 + x_offset: 0 + y_offset: 0 + threshold: 0.80 + require_image: false + description: 空输入框灰色提示态(发送成功后校验用) + +search_result_first: + by_image: search_result_first.png + by_geom: + relative_to: window + x_ratio: 0.078 + y_ratio: 0.174 + x_offset: 0 + y_offset: 0 + threshold: 0.80 + require_image: false + description: 搜索结果第一项 + +main_view: + by_image: main_view.png + by_geom: + relative_to: window + x_ratio: 0.65 + y_ratio: 0.50 + x_offset: 0 + y_offset: 0 + threshold: 0.75 + require_image: false + description: 主聊天视图(post_verify 校验用,阈值略低因区域大) + +# 好友申请通过 UI 元素定位(基于 1435x876 实测标定) +contact_icon: + by_image: contact_icon.png + by_geom: + relative_to: window + x_ratio: 0.04 + y_ratio: 0.171 + x_offset: 0 + y_offset: 0 + threshold: 0.80 + require_image: false + description: 左侧导航栏通讯录图标 + +new_friend_entry: + by_image: new_friend_entry.png + by_geom: + relative_to: window + x_ratio: 0.125 + y_ratio: 0.138 + x_offset: 0 + y_offset: 0 + threshold: 0.80 + require_image: false + description: 通讯录页"新的朋友"入口 + +friend_request_item: + by_image: friend_request_item.png + by_geom: + relative_to: window + x_ratio: 0.125 + y_ratio: 0.178 + x_offset: 0 + y_offset: 0 + threshold: 0.75 + require_image: false + description: '"新的朋友"列表第一条申请项' + +verify_button: + by_image: verify_button.png + by_geom: + relative_to: window + x_ratio: 0.593 + y_ratio: 0.394 + x_offset: 0 + y_offset: 0 + threshold: 0.75 + require_image: false + description: 好友详情页"前往验证"按钮(OpenCV 实测标定) + +confirm_button: + by_geom: + relative_to: window + x_ratio: 0.464 + y_ratio: 0.830 + x_offset: 0 + y_offset: 0 + threshold: 0.75 + require_image: false + description: '"通过朋友验证"弹窗绿色确定按钮(OpenCV 弹窗出现时实测 666/1435, 727/876)' diff --git a/bridge/woc_bridge/ui/profiles/wechat_4.0_1920x1080.yaml b/bridge/woc_bridge/ui/profiles/wechat_4.0_1920x1080.yaml index 5112154..ea3c24a 100644 --- a/bridge/woc_bridge/ui/profiles/wechat_4.0_1920x1080.yaml +++ b/bridge/woc_bridge/ui/profiles/wechat_4.0_1920x1080.yaml @@ -17,8 +17,8 @@ search_box: by_image: search_box.png by_geom: relative_to: window - x_ratio: 0.08 - y_ratio: 0.05 + x_ratio: 0.092 + y_ratio: 0.095 x_offset: 0 y_offset: 0 threshold: 0.80 @@ -28,11 +28,11 @@ search_box: send_button: by_image: send_button.png by_geom: - relative_to: window_bottom_right - x_ratio: 1.0 - y_ratio: 1.0 - x_offset: -60 - y_offset: -30 + relative_to: window + x_ratio: 0.972 + y_ratio: 0.929 + x_offset: 0 + y_offset: 0 threshold: 0.80 require_image: false description: 右下角发送按钮 @@ -41,8 +41,8 @@ input_box: by_image: input_box.png by_geom: relative_to: window - x_ratio: 0.65 - y_ratio: 0.93 + x_ratio: 0.743 + y_ratio: 0.914 x_offset: 0 y_offset: 0 threshold: 0.80 @@ -53,8 +53,8 @@ input_box_empty: by_image: input_box_empty.png by_geom: relative_to: window - x_ratio: 0.65 - y_ratio: 0.93 + x_ratio: 0.743 + y_ratio: 0.914 x_offset: 0 y_offset: 0 threshold: 0.80 @@ -65,8 +65,8 @@ search_result_first: by_image: search_result_first.png by_geom: relative_to: window - x_ratio: 0.08 - y_ratio: 0.11 + x_ratio: 0.078 + y_ratio: 0.174 x_offset: 0 y_offset: 0 threshold: 0.80 @@ -74,7 +74,6 @@ search_result_first: description: 搜索结果第一项 main_view: - by_image: main_view.png by_geom: relative_to: window x_ratio: 0.65 @@ -83,7 +82,7 @@ main_view: y_offset: 0 threshold: 0.75 require_image: false - description: 主聊天视图(post_verify 校验用,阈值略低因区域大) + description: 主聊天视图(post_verify 校验用,模板匹配不稳定,使用几何兜底) # 好友申请通过 UI 元素定位(experimental,坐标为估算值需实测校准) contact_icon: @@ -91,7 +90,7 @@ contact_icon: by_geom: relative_to: window x_ratio: 0.04 - y_ratio: 0.15 + y_ratio: 0.171 x_offset: 0 y_offset: 0 threshold: 0.80 @@ -102,34 +101,44 @@ new_friend_entry: by_image: new_friend_entry.png by_geom: relative_to: window - x_ratio: 0.04 - y_ratio: 0.22 + x_ratio: 0.125 + y_ratio: 0.138 x_offset: 0 y_offset: 0 threshold: 0.80 require_image: false description: 通讯录页"新的朋友"入口 -verify_button: - by_image: verify_button.png +friend_request_item: by_geom: - relative_to: window_bottom_right - x_ratio: 1.0 - y_ratio: 0.30 - x_offset: -80 + relative_to: window + x_ratio: 0.125 + y_ratio: 0.178 + x_offset: 0 y_offset: 0 threshold: 0.75 require_image: false - description: 好友申请项"前往验证"按钮 + description: '"新的朋友"列表第一条申请项(模板匹配不稳定,使用几何兜底)' -confirm_button: - by_image: confirm_button.png +verify_button: + by_image: verify_button.png by_geom: - relative_to: window_bottom_right - x_ratio: 0.5 - y_ratio: 1.0 + relative_to: window + x_ratio: 0.593 + y_ratio: 0.394 x_offset: 0 - y_offset: -60 + y_offset: 0 threshold: 0.75 require_image: false - description: '"通过朋友验证"弹窗确定按钮' + description: 好友详情页"前往验证"按钮(OpenCV 实测标定) + +confirm_button: + by_geom: + relative_to: window + x_ratio: 0.464 + y_ratio: 0.830 + x_offset: 0 + y_offset: 0 + threshold: 0.75 + require_image: false + description: '"通过朋友验证"弹窗绿色确定按钮(OpenCV 弹窗出现时实测 666/1435, 727/876)' diff --git a/bridge/woc_bridge/ui/xdotool_driver.py b/bridge/woc_bridge/ui/xdotool_driver.py index e0fd364..e6d8b0d 100644 --- a/bridge/woc_bridge/ui/xdotool_driver.py +++ b/bridge/woc_bridge/ui/xdotool_driver.py @@ -79,6 +79,14 @@ class XdotoolDriver: self._moment._ui_lock = shared_ui_lock self._contact._ui_lock = shared_ui_lock + # Actions 由 app.py 在初始化阶段回填,供 MessageDriver 复用 profile 比例坐标。 + self._actions: Optional[Any] = None + + def set_actions(self, actions: Any) -> None: + """注入 L3 Actions,使 MessageDriver 可走 LocatorRegistry + profile。""" + self._actions = actions + self._message._actions = actions + # ================================================================== # 委托辅助方法(供内联的 5 个未拆分方法调用子 driver 底层能力) # ================================================================== diff --git a/docker-compose.yml b/docker-compose.yml index 746cc53..ea8ff63 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,8 @@ services: environment: - PORT=8080 # 新建微信实例时使用的镜像(多架构,amd64/arm64 自动匹配);前缀同样跟随 WOC_IMAGE_PREFIX。 - - WOC_WECHAT_IMAGE=${WOC_IMAGE_PREFIX:-docker.io/gloridust}/wechat-on-cloud:${WOC_VERSION:-latest} + # 可通过 .env 中的 WOC_WECHAT_IMAGE 完全覆盖(本地自构建时使用)。 + - WOC_WECHAT_IMAGE=${WOC_WECHAT_IMAGE:-${WOC_IMAGE_PREFIX:-docker.io/gloridust}/wechat-on-cloud:${WOC_VERSION:-latest}} # 透传给每个微信实例容器(KasmVNC 基础镜像用它们降权运行) - PUID=${WOC_PUID:-1000} - PGID=${WOC_PGID:-1000} @@ -45,6 +46,22 @@ services: # bridge 业务 API 的 M2M 鉴权 token(供外部系统如 ForcePilot 调用 /api/bridge/:id/*) # 留空=仅允许管理员会话鉴权(兼容旧部署);配置后支持 Authorization: Bearer 直连。 - WOC_BRIDGE_API_TOKEN=${WOC_BRIDGE_API_TOKEN:-} + # bridge 行为与好友自动通过相关配置:面板启动实例时透传给容器内的 bridge 进程。 + - WOC_BRIDGE_SEND_DELAY_MS=${WOC_BRIDGE_SEND_DELAY_MS:-800} + - WOC_BRIDGE_MAX_CALLS_PER_SEC=${WOC_BRIDGE_MAX_CALLS_PER_SEC:-10} + - WOC_BRIDGE_MAX_BATCH_SIZE=${WOC_BRIDGE_MAX_BATCH_SIZE:-50} + - WOC_BRIDGE_POLL_INTERVAL_MS=${WOC_BRIDGE_POLL_INTERVAL_MS:-2000} + - WOC_AUTO_ACCEPT_ENABLED=${WOC_AUTO_ACCEPT_ENABLED:-false} + - WOC_AUTO_ACCEPT_ALL=${WOC_AUTO_ACCEPT_ALL:-false} + - WOC_AUTO_ACCEPT_WHITELIST_WXIDS=${WOC_AUTO_ACCEPT_WHITELIST_WXIDS:-} + - WOC_AUTO_ACCEPT_WHITELIST_NICKNAMES=${WOC_AUTO_ACCEPT_WHITELIST_NICKNAMES:-} + - WOC_AUTO_ACCEPT_KEYWORDS=${WOC_AUTO_ACCEPT_KEYWORDS:-} + - WOC_AUTO_ACCEPT_BLACKLIST_WXIDS=${WOC_AUTO_ACCEPT_BLACKLIST_WXIDS:-} + - WOC_AUTO_ACCEPT_BLACKLIST_NICKNAMES=${WOC_AUTO_ACCEPT_BLACKLIST_NICKNAMES:-} + - WOC_AUTO_ACCEPT_ALLOW_SCENES=${WOC_AUTO_ACCEPT_ALLOW_SCENES:-} + - WOC_AUTO_ACCEPT_POLL_INTERVAL=${WOC_AUTO_ACCEPT_POLL_INTERVAL:-3} + - WOC_UI_BACKEND=${WOC_UI_BACKEND:-flow} + - WOC_UI_DEBUG_SHOTS=${WOC_UI_DEBUG_SHOTS:-false} volumes: # 面板账号数据(用户、实例元信息、密码哈希) diff --git a/docker/Dockerfile b/docker/Dockerfile index 4f882a5..9a548ef 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -10,6 +10,12 @@ ENV DEBIAN_FRONTEND=noninteractive RUN set -eux; \ # 中文字体(否则界面/消息显示方块)+ 语言环境 + 下载/解压工具 + # 构建环境可能无法直连 deb.debian.org,优先使用国内镜像源 + if [ -f /etc/apt/sources.list ]; then \ + cp /etc/apt/sources.list /etc/apt/sources.list.bak; \ + sed -i 's|http://deb.debian.org/debian|https://mirrors.tuna.tsinghua.edu.cn/debian|g' /etc/apt/sources.list; \ + sed -i 's|http://security.debian.org/debian-security|https://mirrors.tuna.tsinghua.edu.cn/debian-security|g' /etc/apt/sources.list; \ + fi; \ apt-get update; \ apt-get install -y --no-install-recommends \ curl ca-certificates locales dpkg \ @@ -27,6 +33,10 @@ RUN set -eux; \ # 微信运行时需要、但官方 deb 未声明的额外库(单独成层,避免动到上面缓存的安装层)。 # 微信原生版是 Qt 程序,依赖一组 xcb 平台库;libxcb-cursor0 由 Qt 动态 dlopen,ldd 查不到,需主动装。 RUN set -eux; \ + if [ -f /etc/apt/sources.list ]; then \ + sed -i 's|http://deb.debian.org/debian|https://mirrors.tuna.tsinghua.edu.cn/debian|g' /etc/apt/sources.list; \ + sed -i 's|http://security.debian.org/debian-security|https://mirrors.tuna.tsinghua.edu.cn/debian-security|g' /etc/apt/sources.list; \ + fi; \ apt-get update; \ apt-get install -y --no-install-recommends \ libatomic1 \ @@ -101,6 +111,10 @@ RUN chmod 755 /custom-cont-init.d/02-woc-app COPY docker/woc-ptrace-init.sh /custom-cont-init.d/03-woc-ptrace RUN chmod 755 /custom-cont-init.d/03-woc-ptrace +# 预创建 X11 Unix socket 目录并设置 sticky 全局可写权限, +# 避免 Xvnc 以非 root 用户启动时因 euid != 0 报 _XSERVTransmkdir ERROR。 +RUN mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix + # 3000 = HTTP web 客户端, 3001 = HTTPS # ===== 新增:woc-bridge 业务 API 服务 ===== # 安装 Python3 + 依赖 + sqlite3(DB 读取用)+ scrot(截图用,供 /api/screenshot 与 /api/login/qr/*) @@ -115,7 +129,9 @@ RUN set -eux; \ "uvicorn[standard]==0.30.0" \ python-multipart==0.0.9 \ pillow==10.4.0 \ - cryptography; \ + cryptography \ + opencv-python-headless \ + numpy; \ apt-get clean; \ rm -rf /var/lib/apt/lists/* diff --git a/docker/woc-ptrace-init.sh b/docker/woc-ptrace-init.sh index fe857be..00839f3 100644 --- a/docker/woc-ptrace-init.sh +++ b/docker/woc-ptrace-init.sh @@ -1,5 +1,35 @@ #!/usr/bin/with-contenv bash # 设置 ptrace_scope=0,允许 bridge 进程读取微信进程内存 # 用于 SQLCipher 密钥自动提取(key_extractor.py 读 /proc//mem) -echo 0 > /proc/sys/kernel/yama/ptrace_scope 2>/dev/null || \ - echo "woc-ptrace: 无法设置 ptrace_scope(可能需要 --privileged 或 SYS_PTRACE capability)" >&2 + +PTRACE_SCOPE="/proc/sys/kernel/yama/ptrace_scope" + +# 检查容器是否拥有 SYS_PTRACE capability +# 通过 /proc/self/status 中的 CapEff 判断 bit 12 (CAP_SYS_PTRACE) +has_sys_ptrace() { + local cap_eff + cap_eff=$(awk '/^CapEff:/{print $2}' /proc/self/status 2>/dev/null) + if [ -z "$cap_eff" ]; then + return 1 + fi + # CAP_SYS_PTRACE = 12,掩码 0x1000 + local mask + mask=$((0x${cap_eff} & 0x1000)) + [ "$mask" -ne 0 ] +} + +if [ -e "$PTRACE_SCOPE" ]; then + # 文件存在:尝试写入 0 + if echo 0 > "$PTRACE_SCOPE" 2>/dev/null; then + echo "woc-ptrace: ptrace_scope 已设置为 0" >&2 + else + echo "woc-ptrace: 无法写入 ptrace_scope(可能需要 --privileged 或 SYS_PTRACE capability)" >&2 + fi +else + # 文件不存在:YAMA 模块未加载(常见于 Docker Desktop WSL2 精简内核) + if has_sys_ptrace; then + echo "woc-ptrace: 宿主机未启用 YAMA($PTRACE_SCOPE 不存在),但容器已持有 SYS_PTRACE,key 提取仍可尝试" >&2 + else + echo "woc-ptrace: 宿主机未启用 YAMA($PTRACE_SCOPE 不存在),且容器未持有 SYS_PTRACE;自动 key 提取可能不可用" >&2 + fi +fi diff --git a/panel/server/src/docker.ts b/panel/server/src/docker.ts index c8c17a1..f43de2f 100644 --- a/panel/server/src/docker.ts +++ b/panel/server/src/docker.ts @@ -188,6 +188,23 @@ function envList(inst: Instance): string[] { const v = process.env[k]; if (v) env.push(`${k}=${v}`); } + // 好友自动通过与 UI 自动化相关配置透传:.env 中修改后必须重建面板/实例容器生效。 + for (const k of [ + 'WOC_AUTO_ACCEPT_ENABLED', + 'WOC_AUTO_ACCEPT_ALL', + 'WOC_AUTO_ACCEPT_WHITELIST_WXIDS', + 'WOC_AUTO_ACCEPT_WHITELIST_NICKNAMES', + 'WOC_AUTO_ACCEPT_KEYWORDS', + 'WOC_AUTO_ACCEPT_BLACKLIST_WXIDS', + 'WOC_AUTO_ACCEPT_BLACKLIST_NICKNAMES', + 'WOC_AUTO_ACCEPT_ALLOW_SCENES', + 'WOC_AUTO_ACCEPT_POLL_INTERVAL', + 'WOC_UI_BACKEND', + 'WOC_UI_DEBUG_SHOTS', + ]) { + const v = process.env[k]; + if (v) env.push(`${k}=${v}`); + } return env; }