主要变更: 1. 新增UIActionScheduler实现优先级UI任务调度,支持CRITICAL/HIGH/NORMAL/LOW四级优先级 2. 替换原有SendQueue为UIActionScheduler,统一所有UI操作的调度逻辑 3. 为截图、登录、重启等API添加调度器封装,支持超时、限流与fast-fail机制 4. 新增Prometheus监控指标,统计UI任务执行、等待耗时、超时、限流与快速失败情况 5. 为QrCapture添加命令执行超时保护,避免X11操作卡死 6. 扩展StatusResponse与状态接口,暴露调度器指标 7. 新增完整的单元测试覆盖调度器功能 8. 为看门狗注入调度器,实现kill前队列清空与快速失败窗口
613 lines
26 KiB
Python
613 lines
26 KiB
Python
"""FastAPI 应用实例:lifespan / 异常处理 / 中间件 / 路由注册 / 状态初始化。
|
||
|
||
由 server.py(薄入口)导入 app 并交给 uvicorn.run。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import asyncio
|
||
import logging
|
||
import os
|
||
import sys
|
||
import time
|
||
from contextlib import asynccontextmanager
|
||
from typing import AsyncIterator
|
||
|
||
from fastapi import FastAPI, Request
|
||
from fastapi.responses import JSONResponse
|
||
|
||
from woc_bridge.config import _state, BridgeConfig
|
||
from woc_bridge.db.coordinator import _resolve_db_state_tuple, _auto_extract_with_lock
|
||
from woc_bridge.messaging import MessageStreamer
|
||
from woc_bridge.models import BridgeError, ErrorResponse
|
||
from woc_bridge.version import BRIDGE_VERSION
|
||
from woc_bridge.db import DbReader
|
||
from woc_bridge.messaging import SendQueue
|
||
from woc_bridge.messaging.ui_action_scheduler import UIActionScheduler
|
||
from woc_bridge.ui import QrCapture, XdotoolDriver
|
||
|
||
# 路由
|
||
from woc_bridge.routes import status as routes_status
|
||
from woc_bridge.routes import db as routes_db
|
||
from woc_bridge.routes import messages as routes_messages
|
||
from woc_bridge.routes import send as routes_send
|
||
from woc_bridge.routes import login as routes_login
|
||
from woc_bridge.routes import contacts as routes_contacts
|
||
from woc_bridge.routes import moments as routes_moments
|
||
from woc_bridge.routes import media as routes_media
|
||
from woc_bridge.routes import diagnostic as routes_diagnostic
|
||
from woc_bridge.routes import screenshot as routes_screenshot
|
||
from woc_bridge.routes import export as routes_export
|
||
from woc_bridge.routes import batch as routes_batch
|
||
|
||
|
||
# 模块级 logger
|
||
logger = logging.getLogger("woc-bridge")
|
||
|
||
# 为 woc-bridge logger 配置输出到 stderr 的 handler
|
||
if not logger.handlers:
|
||
_handler = logging.StreamHandler(sys.stderr)
|
||
_handler.setFormatter(
|
||
logging.Formatter(
|
||
"%(asctime)s [%(levelname)s] [%(trace_id)s] %(name)s: %(message)s"
|
||
)
|
||
)
|
||
logger.addHandler(_handler)
|
||
# P3:注入 TraceFilter 让日志带 trace_id
|
||
from woc_bridge.ui.trace import TraceFilter
|
||
_handler.addFilter(TraceFilter())
|
||
logger.setLevel(logging.INFO)
|
||
logger.propagate = False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# lifespan
|
||
# ---------------------------------------------------------------------------
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||
"""应用生命周期。
|
||
|
||
兼容两种启动方式:
|
||
1. `python3 server.py`:__main__ 已 _init_state,lifespan 直接用
|
||
2. `uvicorn server:app`:__main__ 未执行,lifespan 内兜底初始化
|
||
|
||
启动 send_queue,yield,停止 send_queue。
|
||
"""
|
||
# 兜底初始化:兼容 uvicorn server:app 启动方式
|
||
if _state.xdotool is None:
|
||
_init_state_from_env()
|
||
_state.start_time = time.monotonic()
|
||
if _state.send_queue is not 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:
|
||
await _state.watchdog.start()
|
||
except Exception as e:
|
||
logger.warning("[lifespan] watchdog start failed: %s", e)
|
||
if _state.resource_reaper is not None:
|
||
try:
|
||
await _state.resource_reaper.start()
|
||
except Exception as e:
|
||
logger.warning("[lifespan] resource_reaper start failed: %s", e)
|
||
# 启动时全量清场:上次崩溃可能残留脏状态(搜索框打开/输入框有内容)
|
||
# 失败仅告警不阻塞启动(spec Requirement: 启动全量清场)
|
||
if _state.xdotool is not None:
|
||
try:
|
||
cleanup_ok = await _state.xdotool._full_cleanup_on_startup()
|
||
if cleanup_ok:
|
||
logger.info("[lifespan] 启动清场完成")
|
||
else:
|
||
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:
|
||
try:
|
||
# 必须先 set_enabled(True) 再 start():FriendRequestWatcher 构造时
|
||
# _enabled_event 初始为 cleared,_watch_loop 会阻塞在 wait() 上不轮询
|
||
_state.friend_watcher.set_enabled(True)
|
||
await _state.friend_watcher.start()
|
||
except Exception as e:
|
||
logger.warning("[lifespan] friend_watcher.start() 失败: %s", e)
|
||
try:
|
||
yield
|
||
finally:
|
||
# 先停 friend_watcher(业务),再停 login_guard,最后停基础设施
|
||
if _state.friend_watcher is not None:
|
||
try:
|
||
await asyncio.wait_for(
|
||
_state.friend_watcher.stop(), timeout=5.0
|
||
)
|
||
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:
|
||
await asyncio.wait_for(_state.message_streamer.stop(), timeout=5.0)
|
||
except (asyncio.TimeoutError, Exception):
|
||
logger.warning("[lifespan] message_streamer.stop() 超时或异常,强制继续")
|
||
# 清理 SSE 发送结果校验总线:resolve 所有 pending 等待为 False
|
||
if _state.verify_bus is not None:
|
||
try:
|
||
_state.verify_bus.clear()
|
||
except Exception:
|
||
logger.warning("[lifespan] verify_bus.clear() 异常,强制继续")
|
||
# 停止群发后台协程(LIFO:先于 send_queue 停止,避免新任务继续入队)
|
||
if _state.batch_worker is not None:
|
||
try:
|
||
await asyncio.wait_for(_state.batch_worker.stop(), timeout=5.0)
|
||
except (asyncio.TimeoutError, Exception):
|
||
logger.warning("[lifespan] batch_worker.stop() 超时或异常,强制继续")
|
||
# 停止发送队列(带超时)
|
||
if _state.send_queue is not None:
|
||
try:
|
||
await asyncio.wait_for(_state.send_queue.stop(), timeout=5.0)
|
||
except (asyncio.TimeoutError, Exception):
|
||
logger.warning("[lifespan] send_queue.stop() 超时或异常,强制继续")
|
||
# P3:停止 HA 模块
|
||
if _state.watchdog is not None:
|
||
try:
|
||
await asyncio.wait_for(_state.watchdog.stop(), timeout=5.0)
|
||
except (asyncio.TimeoutError, Exception):
|
||
logger.warning("[lifespan] watchdog.stop() 超时或异常,强制继续")
|
||
if _state.resource_reaper is not None:
|
||
try:
|
||
await asyncio.wait_for(_state.resource_reaper.stop(), timeout=5.0)
|
||
except (asyncio.TimeoutError, Exception):
|
||
logger.warning("[lifespan] resource_reaper.stop() 超时或异常,强制继续")
|
||
# 取消所有运行中的导出任务(Task 21),避免残留 asyncio.Task
|
||
try:
|
||
from woc_bridge.routes.export import cancel_all_export_tasks_on_shutdown
|
||
await asyncio.wait_for(
|
||
cancel_all_export_tasks_on_shutdown(), timeout=5.0
|
||
)
|
||
except (asyncio.TimeoutError, Exception):
|
||
logger.warning("[lifespan] export tasks cancel 超时或异常,强制继续")
|
||
|
||
|
||
def _init_state_from_env() -> None:
|
||
"""从环境变量初始化全局状态(uvicorn 启动兜底路径)。
|
||
|
||
构造与 `python3 server.py` 等价的 BridgeConfig:命令行参数取默认值,
|
||
其余字段由 BridgeConfig.from_args_and_env 从环境变量读取。
|
||
"""
|
||
args = argparse.Namespace(
|
||
listen=os.environ.get("WOC_BRIDGE_LISTEN", "0.0.0.0:8088"),
|
||
display=os.environ.get("DISPLAY", ":1") or ":1",
|
||
wechat_db=os.environ.get("WOC_WECHAT_DB", "/config"),
|
||
)
|
||
cfg = BridgeConfig.from_args_and_env(args)
|
||
_init_state(cfg)
|
||
|
||
|
||
app = FastAPI(title="woc-bridge", version=BRIDGE_VERSION, lifespan=lifespan)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 全局异常处理
|
||
# ---------------------------------------------------------------------------
|
||
@app.exception_handler(BridgeError)
|
||
async def bridge_error_handler(request: Request, exc: BridgeError) -> JSONResponse:
|
||
"""捕获 BridgeError,返回统一错误结构。
|
||
|
||
对 RATE_LIMITED 错误额外设置 Retry-After 响应头,供客户端退避。
|
||
"""
|
||
headers: dict[str, str] | None = None
|
||
if exc.code == "RATE_LIMITED" and exc.details:
|
||
retry_after = exc.details.get("retry_after")
|
||
if isinstance(retry_after, int):
|
||
headers = {"Retry-After": str(retry_after)}
|
||
return JSONResponse(
|
||
status_code=exc.http_status,
|
||
content=ErrorResponse(
|
||
success=False,
|
||
error={
|
||
"code": exc.code,
|
||
"message": exc.message,
|
||
"details": exc.details,
|
||
},
|
||
).model_dump(),
|
||
headers=headers,
|
||
)
|
||
|
||
|
||
@app.exception_handler(Exception)
|
||
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||
"""捕获其他异常,返回 BRIDGE_INTERNAL_ERROR。
|
||
|
||
日志里记完整异常信息便于排障;响应只回通用 message,不泄露内部细节。
|
||
"""
|
||
logger.exception("unhandled exception on %s %s", request.method, request.url.path)
|
||
return JSONResponse(
|
||
status_code=500,
|
||
content=ErrorResponse(
|
||
success=False,
|
||
error={
|
||
"code": "BRIDGE_INTERNAL_ERROR",
|
||
"message": "bridge internal error",
|
||
"details": None,
|
||
},
|
||
).model_dump(),
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 请求/响应日志中间件
|
||
# ---------------------------------------------------------------------------
|
||
_SLOW_REQUEST_MS = 2000
|
||
_QUIET_ENTRY_PATHS: set[str] = set()
|
||
|
||
|
||
@app.middleware("http")
|
||
async def request_logging_middleware(request: Request, call_next):
|
||
"""统一 HTTP 请求/响应日志。
|
||
|
||
入口:INFO 级别,打印 method + path + query(仅非 quiet 路径)
|
||
出口:INFO 级别,打印 method + path + status_code + 耗时ms
|
||
- 4xx/5xx 升级为 WARNING/ERROR
|
||
- 超过 _SLOW_REQUEST_MS 升级为 WARNING(带 ⚠️ 标记)
|
||
- 响应体含 error.code 时额外打印错误码与 message
|
||
"""
|
||
method = request.method
|
||
path = request.url.path
|
||
query = str(request.url.query) if request.url.query else ""
|
||
|
||
# 入口日志
|
||
if path not in _QUIET_ENTRY_PATHS:
|
||
if query:
|
||
logger.info("→ %s %s?%s", method, path, query)
|
||
else:
|
||
logger.info("→ %s %s", method, path)
|
||
|
||
start = time.perf_counter()
|
||
|
||
try:
|
||
response = await call_next(request)
|
||
except Exception as exc:
|
||
# 未被异常处理器捕获的极端情况
|
||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||
logger.error(
|
||
"← %s %s → EXCEPTION %s %.1fms: %s",
|
||
method, path, type(exc).__name__, elapsed_ms, exc,
|
||
)
|
||
raise
|
||
|
||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||
|
||
# 出口日志级别按状态码分级
|
||
status = response.status_code
|
||
if status >= 500:
|
||
log_level = logging.ERROR
|
||
elif status >= 400:
|
||
log_level = logging.WARNING
|
||
else:
|
||
log_level = logging.INFO
|
||
|
||
# 慢请求升级为 WARNING
|
||
slow_marker = ""
|
||
if elapsed_ms > _SLOW_REQUEST_MS and log_level == logging.INFO:
|
||
log_level = logging.WARNING
|
||
slow_marker = " ⚠️ slow"
|
||
|
||
logger.log(
|
||
log_level,
|
||
"← %s %s → %d %.1fms%s",
|
||
method, path, status, elapsed_ms, slow_marker,
|
||
)
|
||
|
||
return response
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 路由注册
|
||
# ---------------------------------------------------------------------------
|
||
app.include_router(routes_status.router)
|
||
app.include_router(routes_db.router)
|
||
app.include_router(routes_messages.router)
|
||
app.include_router(routes_send.router)
|
||
app.include_router(routes_login.router)
|
||
app.include_router(routes_contacts.router)
|
||
app.include_router(routes_moments.router)
|
||
app.include_router(routes_media.router)
|
||
app.include_router(routes_diagnostic.router)
|
||
app.include_router(routes_screenshot.router)
|
||
app.include_router(routes_export.router)
|
||
app.include_router(routes_batch.router)
|
||
|
||
# P3:Prometheus /metrics 端点
|
||
try:
|
||
from prometheus_client import make_asgi_app
|
||
app.mount("/metrics", make_asgi_app())
|
||
logger.info("[init] /metrics endpoint mounted")
|
||
except ImportError:
|
||
logger.warning("[init] prometheus_client not available, /metrics disabled")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 状态初始化
|
||
# ---------------------------------------------------------------------------
|
||
def _detect_display_resolution(display: str) -> tuple[int, int]:
|
||
"""通过 xdotool getdisplaygeometry 检测真实分辨率。
|
||
|
||
失败时回退到 1920x1080(保守默认,兼容旧 profile)。
|
||
"""
|
||
import subprocess
|
||
try:
|
||
result = subprocess.run(
|
||
["xdotool", "getdisplaygeometry"],
|
||
capture_output=True, text=True, timeout=3.0,
|
||
env={**os.environ, "DISPLAY": display},
|
||
)
|
||
if result.returncode == 0:
|
||
parts = result.stdout.strip().split()
|
||
if len(parts) == 2:
|
||
w, h = int(parts[0]), int(parts[1])
|
||
if w > 0 and h > 0:
|
||
return (w, h)
|
||
except Exception as e:
|
||
logger.warning("[app] 检测分辨率失败,回退 1920x1080: %s", e)
|
||
return (1920, 1080)
|
||
|
||
|
||
def _init_state(cfg: BridgeConfig) -> None:
|
||
"""根据 BridgeConfig 初始化全局状态(运行期实例)。"""
|
||
_state.config = cfg
|
||
_state.xdotool = XdotoolDriver(display=cfg.display)
|
||
# DbReader 共享 AppState 中的 key_cache(单一密钥来源)
|
||
_state.db_reader = DbReader(db_root=cfg.wechat_db, key_cache=_state.key_cache)
|
||
_state.qr_capture = QrCapture(display=cfg.display)
|
||
|
||
_state.send_queue = UIActionScheduler(
|
||
send_delay_ms=cfg.send_delay_ms,
|
||
max_calls_per_sec=cfg.max_calls_per_sec,
|
||
max_queue_size=cfg.max_queue_size,
|
||
)
|
||
|
||
# SSE 发送结果校验总线:send_text 发送后注册等待,
|
||
# MessageStreamer 广播消息时 notify 匹配 talker + content。
|
||
# 替代 DB 轮询校验,避免熔断器雪崩。
|
||
from woc_bridge.messaging.verify_bus import SSEVerifyBus
|
||
_state.verify_bus = SSEVerifyBus(default_timeout_sec=2.5)
|
||
|
||
# 消息流推送器:内部轮询 DB + SSE 广播(lifespan 中 start/stop)
|
||
# 注入 verify_bus:广播 messages 事件时 notify 匹配 pending 的发送等待
|
||
_state.message_streamer = MessageStreamer(
|
||
db_reader=_state.db_reader,
|
||
resolve_db_state=_resolve_db_state_tuple,
|
||
extract_key=lambda: _auto_extract_with_lock(force=True),
|
||
verify_bus=_state.verify_bus,
|
||
)
|
||
|
||
# 读取 WOC_DB_KEY 环境变量,注入 DbReader
|
||
# 用 set_default_key 而非 set_key:保留 woc-keys.json 已持久化的多 salt 映射,
|
||
# env key 仅作为默认兜底,首次查询时按 salt 验证后入库
|
||
# db_reader.set_default_key 已转发到 key_cache.set_default_key,无需重复调用
|
||
if cfg.db_key:
|
||
_state.db_reader.set_default_key(cfg.db_key)
|
||
logger.info("从 WOC_DB_KEY 环境变量加载默认 DB 密钥(未验证,首次查询时验证)")
|
||
|
||
# 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
|
||
from woc_bridge.ui.locators.registry import LocatorRegistry
|
||
from woc_bridge.ui.actions import Actions
|
||
from woc_bridge.ui.idem_cache import IdemCache
|
||
from woc_bridge.ui.orchestrator import FlowOrchestrator
|
||
from woc_bridge.ui.flows.send_file import SendFileFlow
|
||
from woc_bridge.ui.capabilities import WeChatCapabilities
|
||
from woc_bridge.ui.watchdog import WeChatWatchdog
|
||
from woc_bridge.ui.resource_reaper import ResourceReaper
|
||
|
||
# 构造后端
|
||
_state.xdotool_backend = XdotoolBackend(display=cfg.display)
|
||
template_dir = os.path.join(
|
||
os.path.dirname(__file__), "ui", "profiles", "templates", "wechat_4.0", "light"
|
||
)
|
||
_state.opencv_backend = OpenCVBackend(template_dir=template_dir)
|
||
|
||
# 构造 Locator + Actions
|
||
profile_dir = os.path.join(os.path.dirname(__file__), "ui", "profiles")
|
||
_state.locator = LocatorRegistry(profile_dir=profile_dir, opencv=_state.opencv_backend)
|
||
|
||
# P0 修复:检测真实分辨率而非硬编码 1920x1080。
|
||
# VNC 默认 1280x720,硬编码 1920x1080 会导致 1280x720 profile 永不加载,
|
||
# 几何兜底 offset 偏差(如 verify_button 横向偏移 27px 可能点到按钮外)。
|
||
real_resolution = _detect_display_resolution(cfg.display)
|
||
_state.locator.load("4.0", real_resolution)
|
||
logger.info(
|
||
"[app] UI profile 加载: wechat_4.0 %s", real_resolution,
|
||
)
|
||
|
||
_state.actions = Actions(backend=_state.xdotool_backend, locator=_state.locator)
|
||
|
||
# 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 路径输入文件选择器),
|
||
# orchestrator 未持有 XdotoolDriver 引用,必须在此处构造后注入
|
||
send_file_flow = SendFileFlow(
|
||
actions=_state.actions,
|
||
xdotool=_state.xdotool,
|
||
db_reader=_state.db_reader,
|
||
)
|
||
|
||
# 构造幂等缓存 + 编排器
|
||
_state.idem_cache = IdemCache(ttl=300, max_size=1000)
|
||
_state.orchestrator = FlowOrchestrator(
|
||
send_queue=_state.send_queue,
|
||
idem_cache=_state.idem_cache,
|
||
actions=_state.actions,
|
||
db_reader=_state.db_reader,
|
||
config=cfg,
|
||
send_file_flow=send_file_flow,
|
||
verify_bus=_state.verify_bus,
|
||
)
|
||
|
||
# 构造 Capabilities
|
||
_state.capabilities = WeChatCapabilities(
|
||
orchestrator=_state.orchestrator,
|
||
xdotool_driver=_state.xdotool, # 保留旧驱动供 P1 兼容
|
||
)
|
||
|
||
# 构造群发后台协程(Task 13-16)
|
||
# 注入 orchestrator(幂等/熔断/会话缓存)+ send_queue(串行化)+
|
||
# db_reader(nickname 查询用于 {nickname} 个性化)+ config(风控参数)
|
||
from woc_bridge.messaging.batch_worker import BatchSendWorker
|
||
_state.batch_worker = BatchSendWorker(
|
||
orchestrator=_state.orchestrator,
|
||
send_queue=_state.send_queue,
|
||
db_reader=_state.db_reader,
|
||
config=cfg,
|
||
)
|
||
|
||
# 构造 HA 模块
|
||
_state.watchdog = WeChatWatchdog(
|
||
backend=_state.xdotool_backend,
|
||
interval=10.0,
|
||
scheduler=_state.send_queue,
|
||
)
|
||
_state.resource_reaper = ResourceReaper(
|
||
debug_screenshot_dir="/tmp/woc_debug",
|
||
max_age_hours=24,
|
||
max_total_mb=100,
|
||
)
|
||
|
||
logger.info("[init] UI 新架构组件构造完成 (ui_backend=%s)", cfg.ui_backend)
|
||
except Exception as exc:
|
||
logger.error("[init] UI 新架构构造失败,回退 legacy: %s", exc)
|
||
# 构造失败不阻塞启动,回退到 legacy
|
||
# 清理已构造的部分组件,避免半初始化对象残留导致后续访问异常
|
||
_state.orchestrator = None
|
||
_state.capabilities = None
|
||
_state.actions = None
|
||
_state.locator = None
|
||
_state.idem_cache = None
|
||
_state.xdotool_backend = None
|
||
_state.opencv_backend = 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
|
||
|
||
accept_config = AcceptRuleConfig(
|
||
enabled=cfg.auto_accept_enabled,
|
||
accept_all=cfg.auto_accept_all,
|
||
whitelist_wxids=cfg.auto_accept_whitelist_wxids,
|
||
whitelist_nicknames=cfg.auto_accept_whitelist_nicknames,
|
||
keywords=cfg.auto_accept_keywords,
|
||
blacklist_wxids=cfg.auto_accept_blacklist_wxids,
|
||
blacklist_nicknames=cfg.auto_accept_blacklist_nicknames,
|
||
allow_scenes=cfg.auto_accept_allow_scenes,
|
||
)
|
||
_state.rule_engine = AcceptRuleEngine(accept_config)
|
||
|
||
# 熔断器(failure_threshold=5, recovery_timeout=60s,比 send_text 的 30s 更长)
|
||
_state.accept_breaker = CircuitBreaker(
|
||
"accept_verify", failure_threshold=5, recovery_timeout=60.0,
|
||
)
|
||
|
||
# 幂等缓存:独立构造,避免 ui_backend=legacy 时 _state.idem_cache 为 None
|
||
# (好友自动通过不依赖 P3 UI 架构,自持 IdemCache 实例)
|
||
accept_idem_cache = IdemCache(ttl=300, max_size=1000)
|
||
|
||
# 登录守卫:后台检测微信登录状态,供 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,
|
||
send_queue=_state.send_queue,
|
||
idem_cache=accept_idem_cache,
|
||
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)
|
||
except Exception as exc:
|
||
logger.error("[init] 好友自动通过组件构造失败: %s", exc)
|
||
# 构造失败不阻塞启动,相关 API 会返回 BRIDGE_INTERNAL_ERROR
|
||
_state.rule_engine = None
|
||
_state.friend_watcher = None
|
||
_state.accept_breaker = None
|
||
_state.login_guard = None
|