2026-07-08 23:25:58 +08:00
|
|
|
|
"""bridge 配置与全局状态。
|
|
|
|
|
|
|
|
|
|
|
|
集中管理:
|
|
|
|
|
|
- DIAGNOSTIC_ITEMS:诊断项定义
|
|
|
|
|
|
- InitState:DB 初始化后台任务状态
|
|
|
|
|
|
- BridgeConfig:命令行参数 + 环境变量配置
|
|
|
|
|
|
- AppState:运行期共享状态容器(单例 _state)
|
|
|
|
|
|
- _require_* 辅助函数:依赖检查(未初始化时抛 BridgeError)
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import os
|
|
|
|
|
|
import threading
|
2026-07-16 13:03:58 +08:00
|
|
|
|
from dataclasses import dataclass, field
|
2026-07-16 01:19:47 +08:00
|
|
|
|
from typing import Any, Callable, Optional
|
2026-07-08 23:25:58 +08:00
|
|
|
|
|
|
|
|
|
|
from woc_bridge.db import DbReader, KeyCache
|
|
|
|
|
|
from woc_bridge.messaging import MessageStreamer, SendQueue
|
|
|
|
|
|
from woc_bridge.models import BridgeError
|
|
|
|
|
|
from woc_bridge.ui import QrCapture, XdotoolDriver
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 诊断项定义(spec 11.1 节)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
DIAGNOSTIC_ITEMS = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"check_id": "wechat_running",
|
|
|
|
|
|
"name": "微信进程检查",
|
|
|
|
|
|
"severity": "critical",
|
|
|
|
|
|
"description": "检查微信客户端是否运行",
|
|
|
|
|
|
"auto_repairable": True,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"check_id": "login_state",
|
|
|
|
|
|
"name": "登录状态检查",
|
|
|
|
|
|
"severity": "critical",
|
|
|
|
|
|
"description": "检查微信是否已登录",
|
|
|
|
|
|
"auto_repairable": False,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"check_id": "db_accessible",
|
|
|
|
|
|
"name": "数据库可达性",
|
|
|
|
|
|
"severity": "error",
|
|
|
|
|
|
"description": "检查微信 DB 是否可读",
|
|
|
|
|
|
"auto_repairable": True, # 可通过自动提取 key 修复
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"check_id": "xdotool_available",
|
|
|
|
|
|
"name": "xdotool 可用性",
|
|
|
|
|
|
"severity": "error",
|
|
|
|
|
|
"description": "检查 xdotool 是否可用",
|
|
|
|
|
|
"auto_repairable": True,
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 诊断项 check_id → 定义映射,便于查找
|
|
|
|
|
|
_DIAGNOSTIC_ITEM_BY_ID = {item["check_id"]: item for item in DIAGNOSTIC_ITEMS}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# DB 初始化后台任务状态
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class InitState:
|
|
|
|
|
|
"""DB 初始化(密钥提取)后台任务状态。"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
|
self.state: str = "idle" # idle / running / success / failed
|
|
|
|
|
|
self.progress_pct: Optional[float] = None
|
|
|
|
|
|
self.message: Optional[str] = None
|
|
|
|
|
|
self.key_count: Optional[int] = None
|
|
|
|
|
|
self.error: Optional[str] = None
|
|
|
|
|
|
self._thread: Optional[threading.Thread] = None
|
|
|
|
|
|
|
|
|
|
|
|
def start(self, target: Callable, args: tuple = ()) -> None:
|
|
|
|
|
|
"""启动后台初始化线程。
|
|
|
|
|
|
|
|
|
|
|
|
若前一个线程仍存活,拒绝重复启动(调用方应先检查 state == running)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
if self._thread is not None and self._thread.is_alive():
|
|
|
|
|
|
return
|
|
|
|
|
|
self.state = "running"
|
|
|
|
|
|
self.progress_pct = 0.0
|
|
|
|
|
|
self.message = "开始初始化"
|
|
|
|
|
|
self.error = None
|
|
|
|
|
|
self._thread = threading.Thread(target=target, args=args, daemon=True)
|
|
|
|
|
|
self._thread.start()
|
|
|
|
|
|
|
|
|
|
|
|
def set_progress(self, pct: float, message: str) -> None:
|
|
|
|
|
|
self.progress_pct = pct
|
|
|
|
|
|
self.message = message
|
|
|
|
|
|
|
|
|
|
|
|
def set_success(self, key_count: int, message: str) -> None:
|
|
|
|
|
|
self.state = "success"
|
|
|
|
|
|
self.progress_pct = 100.0
|
|
|
|
|
|
self.key_count = key_count
|
|
|
|
|
|
self.message = message
|
|
|
|
|
|
self.error = None
|
|
|
|
|
|
|
|
|
|
|
|
def set_failed(self, error: str) -> None:
|
|
|
|
|
|
self.state = "failed"
|
|
|
|
|
|
self.progress_pct = None
|
|
|
|
|
|
self.error = error
|
|
|
|
|
|
self.message = f"初始化失败: {error}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# BridgeConfig
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
2026-07-16 13:03:58 +08:00
|
|
|
|
def _parse_list(s: str) -> list[str]:
|
|
|
|
|
|
"""逗号分隔字符串转列表(供 BridgeConfig 解析环境变量用)。"""
|
|
|
|
|
|
if not s.strip():
|
|
|
|
|
|
return []
|
|
|
|
|
|
return [item.strip() for item in s.split(",") if item.strip()]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-08 23:25:58 +08:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class BridgeConfig:
|
|
|
|
|
|
"""bridge 服务的配置项(来自命令行参数 + 环境变量)。
|
|
|
|
|
|
|
|
|
|
|
|
在启动时一次性构造并注入 AppState,运行期只读不写。
|
|
|
|
|
|
所有 os.environ.get / 类型转换集中在此处,避免散落。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
# 命令行参数
|
|
|
|
|
|
listen: str = "0.0.0.0:8088"
|
|
|
|
|
|
wechat_db: str = "/config"
|
|
|
|
|
|
display: str = ":1"
|
|
|
|
|
|
# 环境变量
|
2026-07-15 20:14:33 +08:00
|
|
|
|
send_delay_ms: int = 3000
|
2026-07-08 23:25:58 +08:00
|
|
|
|
max_calls_per_sec: int = 10
|
|
|
|
|
|
max_batch_size: int = 50
|
|
|
|
|
|
poll_interval_ms: int = 2000
|
|
|
|
|
|
db_key: str = ""
|
|
|
|
|
|
auto_extract_enabled: bool = True
|
2026-07-16 01:19:47 +08:00
|
|
|
|
# UI 自动化后端选择:flow(新六层架构,默认)/ legacy(旧 xdotool_driver 单文件)
|
|
|
|
|
|
ui_backend: str = "flow"
|
|
|
|
|
|
# 调试截图写盘开关:生产环境必须 false,调试模式 true 时异步队列后台写盘
|
|
|
|
|
|
ui_debug_shots: bool = False
|
2026-07-16 13:03:58 +08:00
|
|
|
|
# 好友自动通过配置
|
|
|
|
|
|
auto_accept_enabled: bool = False
|
|
|
|
|
|
auto_accept_all: bool = False
|
|
|
|
|
|
auto_accept_whitelist_wxids: list[str] = field(default_factory=list)
|
|
|
|
|
|
auto_accept_whitelist_nicknames: list[str] = field(default_factory=list)
|
|
|
|
|
|
auto_accept_keywords: list[str] = field(default_factory=list)
|
|
|
|
|
|
auto_accept_blacklist_wxids: list[str] = field(default_factory=list)
|
|
|
|
|
|
auto_accept_blacklist_nicknames: list[str] = field(default_factory=list)
|
|
|
|
|
|
auto_accept_allow_scenes: list[str] = field(default_factory=list)
|
|
|
|
|
|
auto_accept_poll_interval: float = 3.0
|
2026-07-16 16:53:42 +08:00
|
|
|
|
# 聊天记录导出(Task 17-21)
|
|
|
|
|
|
# 导出文件根目录,每个任务建 <task_id>/ 子目录
|
|
|
|
|
|
export_dir: str = "/config/woc-export"
|
|
|
|
|
|
# 导出任务 TTL(秒),过期后下载链接失效并被懒清理
|
|
|
|
|
|
export_task_ttl_sec: int = 7200
|
|
|
|
|
|
# 群发消息风控(Task 13-16)
|
|
|
|
|
|
batch_max_per_batch: int = 200 # 单批目标数上限(去重后)
|
|
|
|
|
|
batch_daily_limit: int = 3 # 每日群发批次数上限
|
|
|
|
|
|
batch_min_interval_sec: int = 7200 # 相邻两批群发最小间隔(秒,默认 2 小时)
|
2026-07-08 23:25:58 +08:00
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_args_and_env(cls, args: argparse.Namespace) -> "BridgeConfig":
|
|
|
|
|
|
"""从 argparse 结果 + 环境变量一次性构造配置。"""
|
|
|
|
|
|
return cls(
|
|
|
|
|
|
listen=args.listen,
|
|
|
|
|
|
wechat_db=args.wechat_db,
|
|
|
|
|
|
display=args.display,
|
2026-07-15 20:14:33 +08:00
|
|
|
|
send_delay_ms=int(os.environ.get("WOC_BRIDGE_SEND_DELAY_MS", "3000")),
|
2026-07-08 23:25:58 +08:00
|
|
|
|
max_calls_per_sec=int(os.environ.get("WOC_BRIDGE_MAX_CALLS_PER_SEC", "10")),
|
|
|
|
|
|
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(),
|
|
|
|
|
|
auto_extract_enabled=os.environ.get(
|
|
|
|
|
|
"WOC_DB_KEY_AUTO_EXTRACT", "true"
|
|
|
|
|
|
).lower() not in ("false", "0", "no", "off"),
|
2026-07-16 01:19:47 +08:00
|
|
|
|
ui_backend=os.environ.get("WOC_UI_BACKEND", "flow").lower(),
|
|
|
|
|
|
ui_debug_shots=os.environ.get("WOC_UI_DEBUG_SHOTS", "false").lower() in ("true", "1", "yes", "on"),
|
2026-07-16 13:03:58 +08:00
|
|
|
|
auto_accept_enabled=os.environ.get(
|
|
|
|
|
|
"WOC_AUTO_ACCEPT_ENABLED", "false"
|
|
|
|
|
|
).lower() in ("true", "1", "yes", "on"),
|
|
|
|
|
|
auto_accept_all=os.environ.get(
|
|
|
|
|
|
"WOC_AUTO_ACCEPT_ALL", "false"
|
|
|
|
|
|
).lower() in ("true", "1", "yes", "on"),
|
|
|
|
|
|
auto_accept_whitelist_wxids=_parse_list(
|
|
|
|
|
|
os.environ.get("WOC_AUTO_ACCEPT_WHITELIST_WXIDS", "")
|
|
|
|
|
|
),
|
|
|
|
|
|
auto_accept_whitelist_nicknames=_parse_list(
|
|
|
|
|
|
os.environ.get("WOC_AUTO_ACCEPT_WHITELIST_NICKNAMES", "")
|
|
|
|
|
|
),
|
|
|
|
|
|
auto_accept_keywords=_parse_list(
|
|
|
|
|
|
os.environ.get("WOC_AUTO_ACCEPT_KEYWORDS", "")
|
|
|
|
|
|
),
|
|
|
|
|
|
auto_accept_blacklist_wxids=_parse_list(
|
|
|
|
|
|
os.environ.get("WOC_AUTO_ACCEPT_BLACKLIST_WXIDS", "")
|
|
|
|
|
|
),
|
|
|
|
|
|
auto_accept_blacklist_nicknames=_parse_list(
|
|
|
|
|
|
os.environ.get("WOC_AUTO_ACCEPT_BLACKLIST_NICKNAMES", "")
|
|
|
|
|
|
),
|
|
|
|
|
|
auto_accept_allow_scenes=_parse_list(
|
|
|
|
|
|
os.environ.get("WOC_AUTO_ACCEPT_ALLOW_SCENES", "")
|
|
|
|
|
|
),
|
|
|
|
|
|
auto_accept_poll_interval=float(
|
|
|
|
|
|
os.environ.get("WOC_AUTO_ACCEPT_POLL_INTERVAL", "3")
|
|
|
|
|
|
),
|
2026-07-16 16:53:42 +08:00
|
|
|
|
# 聊天记录导出(Task 17-21)
|
|
|
|
|
|
export_dir=os.environ.get("WOC_EXPORT_DIR", "/config/woc-export").strip(),
|
|
|
|
|
|
export_task_ttl_sec=int(
|
|
|
|
|
|
os.environ.get("WOC_EXPORT_TASK_TTL_SEC", "7200")
|
|
|
|
|
|
),
|
|
|
|
|
|
# 群发消息风控(Task 13-16)
|
|
|
|
|
|
batch_max_per_batch=int(
|
|
|
|
|
|
os.environ.get("WOC_BATCH_MAX_PER_BATCH", "200")
|
|
|
|
|
|
),
|
|
|
|
|
|
batch_daily_limit=int(
|
|
|
|
|
|
os.environ.get("WOC_BATCH_DAILY_LIMIT", "3")
|
|
|
|
|
|
),
|
|
|
|
|
|
batch_min_interval_sec=int(
|
|
|
|
|
|
os.environ.get("WOC_BATCH_MIN_INTERVAL_SEC", "7200")
|
|
|
|
|
|
),
|
2026-07-08 23:25:58 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# AppState
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class AppState:
|
|
|
|
|
|
"""运行期共享状态容器。"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
|
self.config: BridgeConfig = BridgeConfig()
|
|
|
|
|
|
self.xdotool: XdotoolDriver | None = None
|
|
|
|
|
|
self.db_reader: DbReader | None = None
|
|
|
|
|
|
self.send_queue: SendQueue | None = None
|
|
|
|
|
|
self.qr_capture: QrCapture | None = None
|
|
|
|
|
|
self.message_streamer: MessageStreamer | None = None
|
|
|
|
|
|
self.start_time: float = 0.0
|
|
|
|
|
|
# DB 解密密钥缓存(env / api / auto_extract / file 注入)
|
|
|
|
|
|
self.key_cache = KeyCache()
|
|
|
|
|
|
# DB 初始化后台任务状态
|
|
|
|
|
|
self.init_state = InitState()
|
|
|
|
|
|
# 自动提取异步锁:防止并发请求同时触发内存扫描
|
|
|
|
|
|
# asyncio.Lock 需在事件循环中创建,延迟到 lifespan 首次使用时初始化
|
|
|
|
|
|
self._extract_lock: asyncio.Lock | None = None
|
2026-07-16 01:19:47 +08:00
|
|
|
|
# P3 新增:UI 自动化新架构组件(仅 ui_backend != "legacy" 时构造)
|
|
|
|
|
|
# 使用 Optional[Any] 避免循环 import(不直接 import UI 模块类型)
|
|
|
|
|
|
self.capabilities: Optional[Any] = None # WeChatCapabilities
|
|
|
|
|
|
self.orchestrator: Optional[Any] = None # FlowOrchestrator
|
|
|
|
|
|
self.watchdog: Optional[Any] = None # WeChatWatchdog
|
|
|
|
|
|
self.resource_reaper: Optional[Any] = None # ResourceReaper
|
|
|
|
|
|
self.actions: Optional[Any] = None # Actions
|
|
|
|
|
|
self.locator: Optional[Any] = None # LocatorRegistry
|
|
|
|
|
|
self.opencv_backend: Optional[Any] = None # OpenCVBackend
|
|
|
|
|
|
self.xdotool_backend: Optional[Any] = None # XdotoolBackend
|
|
|
|
|
|
self.idem_cache: Optional[Any] = None # IdemCache
|
2026-07-16 13:03:58 +08:00
|
|
|
|
# 好友自动通过组件
|
|
|
|
|
|
self.friend_watcher: Optional[Any] = None # FriendRequestWatcher
|
|
|
|
|
|
self.rule_engine: Optional[Any] = None # AcceptRuleEngine
|
|
|
|
|
|
self.accept_breaker: Optional[Any] = None # CircuitBreaker
|
2026-07-16 16:53:42 +08:00
|
|
|
|
# 群发消息后台协程(Task 13-16)
|
|
|
|
|
|
self.batch_worker: Optional[Any] = None # BatchSendWorker
|
2026-07-08 23:25:58 +08:00
|
|
|
|
|
|
|
|
|
|
def extract_lock(self) -> asyncio.Lock:
|
|
|
|
|
|
"""懒初始化 asyncio.Lock(需在事件循环中创建)。"""
|
|
|
|
|
|
if self._extract_lock is None:
|
|
|
|
|
|
self._extract_lock = asyncio.Lock()
|
|
|
|
|
|
return self._extract_lock
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_state = AppState()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 依赖检查辅助函数(替代 assert,兼容 python -O)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _require_xdotool() -> XdotoolDriver:
|
|
|
|
|
|
"""返回 xdotool 驱动,未初始化时抛 BridgeError。"""
|
|
|
|
|
|
if _state.xdotool is None:
|
|
|
|
|
|
raise BridgeError(
|
|
|
|
|
|
code="BRIDGE_INTERNAL_ERROR",
|
|
|
|
|
|
message="xdotool driver 未初始化",
|
|
|
|
|
|
)
|
|
|
|
|
|
return _state.xdotool
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _require_db_reader() -> DbReader:
|
|
|
|
|
|
"""返回 DB 读取器,未初始化时抛 BridgeError。"""
|
|
|
|
|
|
if _state.db_reader is None:
|
|
|
|
|
|
raise BridgeError(
|
|
|
|
|
|
code="BRIDGE_INTERNAL_ERROR",
|
|
|
|
|
|
message="db_reader 未初始化",
|
|
|
|
|
|
)
|
|
|
|
|
|
return _state.db_reader
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _require_send_queue() -> SendQueue:
|
|
|
|
|
|
"""返回发送队列,未初始化时抛 BridgeError。"""
|
|
|
|
|
|
if _state.send_queue is None:
|
|
|
|
|
|
raise BridgeError(
|
|
|
|
|
|
code="BRIDGE_INTERNAL_ERROR",
|
|
|
|
|
|
message="send_queue 未初始化",
|
|
|
|
|
|
)
|
|
|
|
|
|
return _state.send_queue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _require_qr_capture() -> QrCapture:
|
|
|
|
|
|
"""返回二维码截图器,未初始化时抛 BridgeError。"""
|
|
|
|
|
|
if _state.qr_capture is None:
|
|
|
|
|
|
raise BridgeError(
|
|
|
|
|
|
code="BRIDGE_INTERNAL_ERROR",
|
|
|
|
|
|
message="qr_capture 未初始化",
|
|
|
|
|
|
)
|
|
|
|
|
|
return _state.qr_capture
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _require_message_streamer() -> MessageStreamer:
|
|
|
|
|
|
"""返回消息流推送器,未初始化时抛 BridgeError。"""
|
|
|
|
|
|
if _state.message_streamer is None:
|
|
|
|
|
|
raise BridgeError(
|
|
|
|
|
|
code="BRIDGE_INTERNAL_ERROR",
|
|
|
|
|
|
message="message_streamer 未初始化",
|
|
|
|
|
|
)
|
|
|
|
|
|
return _state.message_streamer
|
2026-07-16 13:03:58 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _require_rule_engine():
|
|
|
|
|
|
"""返回好友自动通过规则引擎,未初始化时抛 BridgeError。"""
|
|
|
|
|
|
if _state.rule_engine is None:
|
|
|
|
|
|
raise BridgeError(
|
|
|
|
|
|
code="BRIDGE_INTERNAL_ERROR",
|
|
|
|
|
|
message="rule_engine 未初始化",
|
|
|
|
|
|
)
|
|
|
|
|
|
return _state.rule_engine
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _require_friend_watcher():
|
|
|
|
|
|
"""返回好友申请监听器,未初始化时抛 BridgeError。"""
|
|
|
|
|
|
if _state.friend_watcher is None:
|
|
|
|
|
|
raise BridgeError(
|
|
|
|
|
|
code="BRIDGE_INTERNAL_ERROR",
|
|
|
|
|
|
message="friend_watcher 未初始化",
|
|
|
|
|
|
)
|
|
|
|
|
|
return _state.friend_watcher
|
2026-07-16 16:53:42 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _require_batch_worker():
|
|
|
|
|
|
"""返回群发消息后台协程,未初始化时抛 BridgeError。"""
|
|
|
|
|
|
if _state.batch_worker is None:
|
|
|
|
|
|
raise BridgeError(
|
|
|
|
|
|
code="BRIDGE_INTERNAL_ERROR",
|
|
|
|
|
|
message="batch_worker 未初始化(群发功能不可用)",
|
|
|
|
|
|
)
|
|
|
|
|
|
return _state.batch_worker
|