From c45282f0944cc1dd77da95a3492216f3cacf0ef2 Mon Sep 17 00:00:00 2001 From: Kris <2893855659@qq.com> Date: Fri, 17 Jul 2026 18:10:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=A5=BD=E5=8F=8B?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=80=9A=E8=BF=87=E3=80=81UI=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=8C=96=E8=83=BD=E5=8A=9B=E4=B8=8E=E5=A4=9A=E5=88=86?= =?UTF-8?q?=E8=BE=A8=E7=8E=87=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增登录状态守卫后台任务 - 新增好友申请自动通过规则引擎 - 新增多分辨率UI配置与模板资源 - 新增消息拉取复合游标支持 - 优化发送队列与UI自动化逻辑 - 新增批量发送日志与错误处理 - 优化Docker镜像构建与ptrace初始化 - 新增联系人名称缓存预热 --- .gitignore | 4 + bridge/s6/woc-bridge/run | 61 ++- bridge/woc_bridge/app.py | 83 +++- bridge/woc_bridge/config.py | 6 + bridge/woc_bridge/db/reader.py | 236 +++++++++-- bridge/woc_bridge/messaging/__init__.py | 5 +- bridge/woc_bridge/messaging/friend_parser.py | 87 ++++- bridge/woc_bridge/messaging/friend_watcher.py | 369 +++++++++++++++--- bridge/woc_bridge/messaging/login_guard.py | 127 ++++++ bridge/woc_bridge/messaging/send_queue.py | 55 ++- bridge/woc_bridge/messaging/streamer.py | 68 ++-- bridge/woc_bridge/models/contact.py | 88 ++++- bridge/woc_bridge/models/message.py | 6 + bridge/woc_bridge/routes/batch.py | 36 +- bridge/woc_bridge/routes/contacts.py | 64 ++- bridge/woc_bridge/routes/login.py | 76 +++- bridge/woc_bridge/routes/messages.py | 47 ++- bridge/woc_bridge/routes/moments.py | 168 +++++++- bridge/woc_bridge/routes/screenshot.py | 13 +- bridge/woc_bridge/routes/send.py | 77 +++- bridge/woc_bridge/ui/actions.py | 89 ++++- bridge/woc_bridge/ui/backends/opencv.py | 11 +- bridge/woc_bridge/ui/backends/xdotool.py | 277 ++++++++++--- bridge/woc_bridge/ui/drivers/contact.py | 106 ++++- bridge/woc_bridge/ui/drivers/message.py | 129 +++++- bridge/woc_bridge/ui/drivers/window.py | 160 +++++++- bridge/woc_bridge/ui/flow.py | 56 ++- bridge/woc_bridge/ui/flows/send_file.py | 67 +++- bridge/woc_bridge/ui/flows/send_text.py | 313 ++++++++++++--- bridge/woc_bridge/ui/locators/registry.py | 126 ++++-- bridge/woc_bridge/ui/orchestrator.py | 156 +++++--- bridge/woc_bridge/ui/profiles/default.yaml | 65 +-- .../wechat_4.0/light/confirm_button.png | Bin 68 -> 2529 bytes .../wechat_4.0/light/contact_icon.png | Bin 68 -> 737 bytes .../wechat_4.0/light/friend_request_item.png | Bin 0 -> 9160 bytes .../templates/wechat_4.0/light/input_box.png | Bin 229 -> 3974 bytes .../wechat_4.0/light/input_box_empty.png | Bin 164 -> 3974 bytes .../templates/wechat_4.0/light/main_view.png | Bin 1750 -> 155819 bytes .../wechat_4.0/light/new_friend_entry.png | Bin 68 -> 2666 bytes .../templates/wechat_4.0/light/search_box.png | Bin 251 -> 1323 bytes .../wechat_4.0/light/search_result_first.png | Bin 4093 -> 13117 bytes .../wechat_4.0/light/send_button.png | Bin 139 -> 1466 bytes .../wechat_4.0/light/verify_button.png | Bin 68 -> 2470 bytes .../ui/profiles/wechat_4.0_1280x720.yaml | 74 ++-- .../ui/profiles/wechat_4.0_1435x876.yaml | 161 ++++++++ .../ui/profiles/wechat_4.0_1920x1080.yaml | 73 ++-- bridge/woc_bridge/ui/xdotool_driver.py | 8 + docker-compose.yml | 19 +- docker/Dockerfile | 18 +- docker/woc-ptrace-init.sh | 34 +- panel/server/src/docker.ts | 17 + 51 files changed, 3062 insertions(+), 573 deletions(-) create mode 100644 bridge/woc_bridge/messaging/login_guard.py create mode 100644 bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light/friend_request_item.png create mode 100644 bridge/woc_bridge/ui/profiles/wechat_4.0_1435x876.yaml 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 35a340b6565bfc92ef9d53ef01e30267f65d4ec2..c99244b8b64ab061dae9e8d855a93c72aa08627b 100644 GIT binary patch literal 2529 zcmV<72_E)|P)R{B+YWwV8{-%#HeeuWiWKuml{75@+DeVu(oi0vO4~#&;a4ls zs#O|LrLE|{-3j^6I}n*9)-#hFSO0A;{}RO$}e{xEXk=RK$Qx1Zd9>&<;_F~N!bZO0!7 z?7j8I-kWdiZ#(f)*W14yI@{#y^Fnws!6G-6GC~LdaT5PI$ESZA_@pyvaq@n*PMi>8 z0FPFBP%xpBJvwb%$^I$&B+DT_3cG1=cZc6*cyhKR%nLjX?ZSb!0D zAOM&-6ePkzRB}R~(b8dN?g%-P)hx;&2%=q@V6&SiwI8Y*E>lQcke5R|uJUTvRc>&R zQ?dX+W+sa`DI273V+J zQ!W_rGSP{gF3eFmx<^b?j;b+&ue2hU5au|U5=J6Gwdm`()+%MVQ4pMx1PD{`F5LND`iWNhX)8F6sT2)N{6$8g`6 zoEiby~)i8?x6dEH=29d1x5_E_SrM)z;9` z(}oYGL6dLL`FQg3(xhzEj6>v8F5;AB43u>;nmYhQAcB@lFa?~(gp{qtHIF9bRc06N zu6g>gB`b217C%|F@jJ^m=O-_gK(aFL<+0mm>@8<(SKqa_ykl>E*Vgi$4If+HH(j-Y zN8%*hw*I}p$r>I{OIy@B-uKtx#=neQX>eXYKhoOkwuT6cTQATN#VZcL{V(E$=%`SV zlv%Vedm`lS8|=%XNpe`?c)p^jw6=0}Mxt@UikekRD|wDXeUT#1BtJ7JKQnv4KicoI z1;ya#sB_pnVm6swE?0<%B9)O`RE`_sL>Vxgk;Wc>>n>j9p)F%z(3 zF^>Q(+5Ym#b?fgGSKmY?9}a`NmMl>4M-$V9XwJGt>hx5<@|9hTHAx_E{b; zSy^RVqL(=X5pY4&P+p+C7C0SnGx`8AF61tIbLvj@Hr+91v5$lY!1~Um`qNDxy0y}f z&ta0rgPe@ZIVN2K2+FB}k=5CyrCCc_9o?t8FHNw(BN@4AG^sXoSzXSm+65(R5)0NO z7gi+Y=-Kqszv^_3q}i0sNrr)vGZEHswy_XMHzo$o3}3mzhHi8AF5+k$Y4>rGBj`3w z*q7%NRu_~H_#MEY*qCGYI6oS1ulL;iQ+q>$>8jalUzxgONB;V~wz&shZ>xW; zxBgJ`$ydAn(dy|tZEAeI{rzTt?}Qv^@%I0|yZ&J7n?G#*$xSza9Bf{+SRUI4XV!c|@PgWFHIJLgN!8C{p5;!`858 zI6U4T9JMf)M+)?KExmrbmx%Zse1M8Jd162u?G4*|L$=Kz-MzPDTcwa|5b^V{N}8Hw(Kf<}My7@kO$~611Db~dL8Y7N0ZkN( zgPOFUW>HXM3}})AoFL&M5#?sGo=w?gvvR{%sQv#B%9Qy9FxgW~nkcY^MV48B$c;g? r{1*TK|NoCbr3?T700v1!K~w_(&in|tkxj{200000NkvXXu0mjfqxZ^! literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k8}blZci7-kcv6U2@FgO46F={RdK8B Pfx--)u6{1-oD!Ml$S`!*n}BTl*u(Q6(v5x^$olZ?^Iq&8JN&DFffsrDY{5q z$t8RM_kU8W+s^51%ev? zDwWE?8jT<>#bHR5!6pwvf+j$ zN~Kadoi-Q@Hk%CucwcI@+EglqIzA67B#2u`g(5kZd$Cv)3WaPodwqQ!i9}#%iRAT6 zEL|=aDp+PR8T6p@z(?G2Skld}_n_OkUa#}{d@h%}y}gY_qh7BU-VhvxiqGf6)tj3e zbfTZt>yw_lSS*4v9*?`-?x&|GGXVPoIuZw=LX&^-%lCn$1dFU{GjI!hr{1aAE8j_d}y3N&yCIhF*x_fiqG}tdW%tj z`$UXN?0UG~?6eef(!bVQ3BUo literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k8}blZci7-kcv6U2@FgO46F={RdK8B Pfx--)u6{1-oD!Mh`_9W&UzI49+`s=Uz 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 E0Ag@2;Q#;t 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 242f5f4fc2c59c677c58218c3b8edd6b6974db3b..0f28ce439bc1ea4ae78828e3bdfbc84e171ebaf2 100644 GIT binary patch literal 155819 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 oTOGz9x-2!9z~gYlC3!ONtXl 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ǩL(E!6VBmWp!}%CIVPomH^uWMahAND@ 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 Ee}w;Vz5oCK literal 1750 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKNIvh+uk)*3dQyCc8=6bp~hE&XXduwlYaIM7g zME}dT%w{aSuux;)mZ@Qmjz|7Gv4z0>>0%GWe`@#^koCD$$`J*yu^D?Bzx zEdKUmTJq)g;||l)J*-Tg{w|1hZqs>QAZ)kuv$bZ_+G~0ZEXvlk%nZ#!ItpzGjD{@S z9f}bSJO`PQIK%{;H#A5zu^tg1U+SWJXsB!8nzd_7cgG%1O6L@-4GA?hH4UAXy87xR zow8{O7v1%Ps=vL_JpJkO^YgQg@(Jm%6+JsM6A1F}@7sDi_iWN~4l(A+@9*y3+?@XX z?d|Q`wtahgTKsI1J5YG#s#idf=jZ3&-(O$9`|iJAUstCY)-)Y;u$t=^8oEAi@2-M} zhwj{o*?srhoe2+xb=WrT+ZXru>!+uumo8oU^XE_h`F2xNcW&I6*u>MDuyL#E>dP-} z>i_*YHC3CNn|t-@)w7NoaEdj*y1Ht6x-HO=udc3k?~{p)js5%Y?{3@fhXOj=7KE>k z+cwXxw(9Mztw)a@P20ROlBYdkBkSy~ulBB6vnFPL-QO1%7oSZ7dT#zVoo%;YY>RLF zKQV86bZF@I+k30OzkBoM&9mRf`{nCDKRbIP-ny#!&v{O<=zUH<;wmoHylym)cr#*KS>tCufd?mIa*bW7CQw|ki$p4wzn@W3HZq`0{F`}_O( zCQ`@yWLIC!@}FlD85d_)^P^z##UI~S=a-b!)YPOLQDnY-;&^wXj*gB^&5sWsA0J;I zzkgrJ%b>_<=K1$*N?u%;Z(slFYt`b5Gv>?*(cU&Y!EgdQznqS~{&&6&)22;RQ&Y>k zyUWzt`ZrK3FyN(?4l71{x&F2+JS?nhkwxXFCuh$1tiHOd;$zb4t5siKT>SOxSHHZy zjeLp)OSfIt)?3};`fBRx{&Or0feB&#_1$;hJv}}B{hgh~udb|2+Z-v#c6C=ONVLWr@oHMg?zW1D&xtKh2=FETNPrL&~vjb<&oJnTH zpLhp~atC6B73J>6zB4jngvBT*+8sz6!m_ioZ)By3L?Wo=)sG|*6Es5`grOyg82nW! zuRumKs~X0_^zJ~~5SBXD6b?M%Ej=> zTQ1>0tNYJd?Um>jV8w!jE?l^fUt3m)l{O+>6k<<^k4P7K5C%o^8)0(kbHWOs1A~w0 zFW7+sBFxG>b?n`{R~rTLx{;#^lqXJ{ICt(GWyB96KY#xG$&)89Uc9J_piTGKv126E zbpMm{!RS&z7=%eBu2Q?Xc%ugn95{RSEGE}b+%2LVC?LW_$P?YRZQItaT?;X~))!jL zHE8SBt&=BD)&^w&1=IEG*Uz3kd;0Y0M~)l`@=KR4O_?%f>eQ)bAvt~ewC>84D*-Wb zP7Z=|=FIs$=d)zV66U<+^nRZ?bLOmBv-a%S6Z9!VCuH~U-{0(@iNX*D1wL-mz5@kB zn2_9k_uY>@_Sn&*M>lTVDBoBap*EFLCD5>D&6+J+wk%q-XvT~g%a$$Mv17;l`SSq_ zdRDAhaocUT6)RROm_yj6O`D1rFRrKo2b|HAewHs^zIgFsen=n&)2vyu?%lh0?AWnW zr%rtG+`M^nk}h4k7=lp_c?bm1yLa!FM?+T|M!CYoR1`Z<5QK%m2!F|vC7*fb86exR zVS^%;H%>t_tQ(&C_3Jlk)TpeitffnrcJ11A!GZNfJee}`H%uJeAa{TylsY|g|BAJ^D z;zsYFxNQy*8aUhT9bv<^=YDYYoCs@}hIJ|jkqPzsg5 z5+zD}|NZyU5=edi`R5R$8p@Xd#1#Ssa12u%K71HrRNnpj_lK!>?b^lv+_`h5z##ed z+iyE`=%9>BmOuRPL#tM;09O+2+_|%2+P!=Cwr$%=fx%}m3C`oT@HUVH7e7A;!TuV4S|x8J60^n(vRXxg-C{ct zVmb%|7hs}k3gFO&iQt=pE~20fatvGEpjoDLEtMA?2mAEtLjuH+8qJu}xC4bnn25>> zDqv=2ra~r@T{TpKExIE|j�OE0r*XapA&+&;XPY40CA-5NQ(1g=#30_T;lCo_OM= zmtIm0IWeO=r;=j4Y;E7Zec-@>j#-pl5`oXDYw+N~PFIo>Z^(XGm32~h@x>S6wLyah zZkn}f)slVP6?`}7#8ebJP$-0nv4gWf!pG~czy9#U4~HcRXJDe1M2%ArLBy@lQORQj zTc|<-1eB>IvNK8)&ni>;UyT|yYS^&hC!c%*Omz5H{uEYu%e-Kx#RDZjYN!+iKwObK zlM$Xeb&5l75ltEE)mL9FU%tGvpmGevN~ATv@WKoHN1BvlO5+X`5Mcr-X5uR3V(hU0 z&_fSltSoP+D3d!dIkvk_x+>Kwa-t~3U< zy1++Ai?>woa7v*ZrjA3NvJPPs!pi9&vuI;V?+)Y}VPSf)QKv*#R(Rx*M`R9iLajJu zXJ=y~A~B?Rd7cFkQD^f0`|m54qAtcXDWGf+OC~asiDa?N;w7?-N_9+<$t(Y#z|)sY zsCU<30#A|%-c8mK(6_<^o9n`W0Rx;4!JVOX zX5@S#CZ`uSh1!99BP`l|=|}7(3{kxH5Ww3@PeoXBt&XLZoQ(6Kkp8b~Pw&=O* z;K74xH=p<1bB_w^$-_GZb0!rkRN!Ad^?0j*`sIqb?||83O79Nj8({)w1zORS1H@kU zMhJ$$5v2U812oR>o-N*Zx_qPi>*x-kHg>3;n2$BGpzzWnmbDm8D_t5^T!mtUke zYctV}{2{SUzIGtr2n$gUa|m-_{;W+^rX#5DzWc7%bLADnlrwQ9M<4mxS0}%%1&sshW z1+~!fMv|ApC^>~d_*4q63l?9Vn@MlL{t>~(}IExYzSiSU9p#o7hAj!R+o^S%VjhdUGu_Yotd4T z*?r%g8urlzL$_Vy?vECTj?fH!4* zeSLLx)vQurgg_>eK0X@I#(Z#au(`SEbUGUw8*6H6+S=MGDk>%?C$#`D$lBUkRaI4H zW+pp&yu#evoZIbY&&S}V+}zyU-QAg03U0UC=L3FN^*?=%9%c+~V`IbVbjHWWn=h1` zn+q`7%@$<=yB`@D;nv{bAPzq=G7^W+m7$W7l4wVwqoa$wekmy_-12HDii*;EVa60e z1A1B`Qf6mo$HvC;q_-3n7M`7*dAC^0Wo2c}&CRrTc6O$xrvn(MR4K&7#85y8#tRDz z7&o%DwS|}F7G(-t7Z(?~(oIBH1A3T=saD2J2`YqwAHiyqlauKHIgiIf7-rF6&{#G? zyCIlwb#-+@LIQnSTU+bu>hR5~0KUGyo+yCZz|UjQSy@>p zCnpmV6Ff@?t|Gb`(8J8=I8T6WZ*PN&DqAV`NMz0!I#oYXSy|c9(Lsl}xH!o1AO#Gx zwzigk=_V#7;+eV2E{m)i>R^U0JFcCb9b!4@82LOlO&<|m4QOM=+Q_u=z$hIgGcz+V zYj1DI!4hcwOphTNAnB8qmc}E5WI&RlLy=t>?Fj8%U0saH3Lr@1KkyUmtV&8sSSZAG z`iSUiKpQg?u&XIAFDGG!M^8@=t~Dnohix>L=Z7@$+qlc|26gMokOVs!5m6$f7x7u`O8!v%83=2NpVB@1~OOBHDD#Ez)V^c^oeD_P-?(RP=T4l zDeH0wJpV5F4p8_+CIeX%~iNa^*``OQa!>{AmiVY`+5Y~57V5Sp**}QU$EMg-K zV4R#TfX^yw%w|7$i`Yd2CN*o}ADFEVNQ)gcAeik)P5Ye%v$o3Y_`n|k00960imT<3 h00006NklqSow z-kYiFH(Ep^T6`lqeQz}UUBcL@z_)8F3n$m(=HhGfAL_nK+I4r!%+ud0EajIm{a-)d z`{w*FUOn%2-HFa`5dCF({KC8xPUXl$9@7jasrKMu^7Q}8*~=Iv5%+c5GdZc((^7wa z>Wq4A(Pw{)S^mdqA-7E}6Lk_*yd_TJW2&B(UUx_|aMSWdia_5mc)I$ztaD0e0su!X BWN`oh 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 9255b31e7d7743a9e17f6f3da972777751b6bdb0..59a1f39c33832fa9692b7158d18786a22d2aa87a 100644 GIT binary patch literal 13117 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 PT>+C8R}iZaF$nlSYL_`$ literal 4093 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 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 77117e7a64e57b5f71d6335e237e9d6ce2834458..cf7d80d319415902db5fe2cb49c5b5389d490e7f 100644 GIT binary patch literal 1466 zcmZ{kX;2af6vx?O%`%UYlo&kMH8R$ND7DBF^H@bNA))X{GAUWD9K6ytCA?C~Qan<_ z6jxpG)=1HW%r!>L+Ok9?r2}Qjq{Gb6W%lFF?wfh<)sb-Qk^)c2mo<1_40(C+++N6H3GALA z_Y^-|AMNDY1rJ%>l`Y1{$BV{v#JZfZYIk-(UB-Yrbmi-(GtX1o;AgbO7+cJY^6+R2ON4G3EmUCYjakF#Zq;%ZE>$u*YQ+G0 zUbUHj`SPCLF%l^cZ5{4gV$&d13DZw^k!oNDJ-fx~xjAbeoGwQs@+MDcqvJ&!4AdBE z$5SGWP^A$J&L``{Sfzs&Rz?b+;-l1K2Icl6$cqYqyr zZlKb?Cfd9ixxVWbGpaNhW$+`mtD~di?p;VOnknBGqzByKh)}fr*0?aeH+G8^(%n7y zNheP^n*b)KjdQSyCHXL1Y|Wr2B^l@5SF6=MQ5NnQQ&MRbMt^#Jlj5%A1L^`7W><-^ zFgI^(wDT{r)D>MrjRoh%E=Zl%qMXWHm{Fkop=&}4q6||nXz3yqoFq!Sm7fmWxWiL^ z!ZNUK)uzB%g(63OC)DnB@tJaZYU<>C;)%8}+v`gN390#wi=_EjkL?%lH*eq8Cl=~p zFc{R4$ks5p5bc!tafjcoszYhEd2^GKlL_}C9l`!yK+oq>Q=#}SQgeUu(%krM-;4o^ zXIYr~te9Oa0_e3`jbMu_tEz;_@m(Zj5G%0%hdh_K<=Heou>2q(Y%kv$||H zyu@sfyC4tks6aqX$5nK#uqqEGX+N{s?&|95v{kN9#70NUH~CV_2~AI*J$rVJKh>MG z*mo1s9_R2~64|uf!_r8%6|NlF7xd(<$oceoZLG*Kpf1F&IP-UpqT1kUHyVvr_hf#t z8OKxZuNbYYtPHd@-g4QcP#n`y955vHSECkHR-CR>0gqbV*cd<4=c${ru}-$ga;{rD zS%b$#F&8k=|_OP@RxAT;uK|y#h4ur?+i(-)w{&D+jeja!isDy$D zcQ64CDu>#_;MHTxsRE5gBM=;tb~`&eM`bd{KL1G7M?a~7qW)61~eOfF*8$QGvat9D{CVT1OxqLK1t3&#lUXo;UdiSnICi(F4QIdXB~yMz)z#xAQ1* zu19k|uUr_6yc$)yUKK}9gXNt7+k&?iYF@os5ZxM&=-n?Iu*|pmU+f7*?owEG#I>i# RhP6xv`1@ecJg>;izX4!}%eDXj literal 139 zcmeAS@N?(olHy`uVBq!ia0vp^Za}Qe!2~3imA# 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 35a340b6565bfc92ef9d53ef01e30267f65d4ec2..cba2f74bfd88afc345728472bcf5a4fb04a4f9e5 100644 GIT binary patch literal 2470 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 literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k8}blZci7-kcv6U2@FgO46F={RdK8B Pfx--)u6{1-oD!M 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; }