"""woc-bridge FastAPI 主入口(bridge 1.4.0)。 监听容器内 :8088,通过 xdotool/xclip 操作微信窗口、读取微信本地 DB, 提供收发消息等能力。 由 s6-rc 服务 `svc-woc-bridge` 常驻拉起(见 bridge/s6/woc-bridge/run), 启动前会等待微信窗口出现(最多 5 分钟),以确保 X 会话就绪。 版本演进: - 1.0.0:P0 接口闭环(消息/联系人/群/发送/登录/重启/诊断基础能力) - 1.1.0:补齐 6 项契约与能力缺口(W-05~W-10) - W-05 发送响应字段 channel_msg_id → local_send_id,新增 placeholder - W-06 群成员 is_admin 反映真实群主/管理员状态 - W-07 /api/status 新增 send_queue_pending / media_supported / bridge_capabilities / db_error_code 字段 - W-08 /api/media 加密时返回 503 DB_ENCRYPTED(不再兜底 500) - W-09 限流响应(429)携带 Retry-After 头 - W-10 diagnostic autofix pkill 后 autostart 未拉起时显式启动兜底 - 1.2.0:新增 SQLCipher DB 解密能力 - 1.3.0:KeyCache 统一密钥来源;_resolve_db_state 统一 DB 状态解析; 修复 env/api key 验证失败误清持久化 key;修复 _init_task start_time 硬编码 - 1.4.0: - _check_db_readable 改为非阻塞:触发后台提取后立即返回 503,不再卡住请求 - 提取公共 _auto_extract_with_lock,diagnostic_autofix 复用同一加锁路径 - login_qr_start 改用非阻塞窗口激活,避免 VNC 无人时死等 - /api/status DB 状态加 1s 短时缓存,减少高频轮询时的重复 I/O - send_text/display_name 解析加 LRU 缓存(5min TTL) - 所有 assert 替换为显式 BridgeError,兼容 python -O - _send_file 文件校验改用 isfile+access - 移除 _read_current_wxid 死代码 路由总览(20 个): - GET /api/status 状态聚合(进程/窗口/登录态/DB/版本/能力) - GET /api/messages/since 增量消息拉取(按 create_time 游标) - POST /api/send/text 发送文本消息(placeholder=false) - POST /api/send/image 发送图片消息(占位实现,placeholder=true) - POST /api/send/file 发送文件消息(占位实现,placeholder=true) - POST /api/login/qr/start 启动扫码登录(截取二维码 data URL) - GET /api/login/qr/wait 轮询等待扫码登录完成 - POST /api/login/logout 退出微信登录(UI 操作,幂等) - POST /api/wechat/restart 重启微信进程(pkill + autostart 拉起) - GET /api/contacts 联系人列表(模糊匹配 wxid/昵称/备注) - GET /api/contacts/{wxid} 单条联系人详情 - GET /api/groups 群聊列表(username LIKE %@chatroom) - GET /api/groups/{wxid}/members 群成员列表(含 is_admin 群主/管理员标识) - GET /api/media/avatar/{wxid} 联系人头像(W-04 未实施,返回 404) - GET /api/media/{msg_id} 下载消息媒体文件(DB 加密时返回 503) - GET /api/diagnostic/connectivity 连通性检查(恒返回 reachable=true) - GET /api/diagnostic/items 诊断项定义列表 - POST /api/diagnostic/run/{check_id} 执行单个诊断项检查 - POST /api/diagnostic/autofix/{check_id} 自动修复(pkill + autostart + 显式启动兜底) - POST /api/screenshot 截取完整屏幕 PNG 错误处理: - 业务错误统一抛 `BridgeError(code, message, details)`,由全局异常处理器 转换为 `{"success": false, "error": {"code", "message", "details"}}` 结构 - RATE_LIMITED (429) 额外携带 `Retry-After` 响应头 - 非业务错误由兜底处理器捕获,返回 `BRIDGE_INTERNAL_ERROR` (HTTP 500), 详细堆栈进日志,响应不泄露内部细节 """ from __future__ import annotations import argparse import asyncio import logging import mimetypes import os import sys import shutil import threading import time import urllib.error import urllib.request from contextlib import asynccontextmanager from dataclasses import dataclass from typing import AsyncIterator, Callable, Optional import uvicorn from fastapi import FastAPI, Request from fastapi.responses import JSONResponse, Response from db_reader import DbReader from models import ( BridgeError, ConnectivityResponse, Contact, ContactsResponse, DbDecryptRequest, DbDecryptResponse, DbInitRequest, DbInitResponse, DbInitStatusResponse, DbKeyStatusResponse, DiagnosticItem, DiagnosticRunResult, ErrorResponse, GroupMembersResponse, GroupsResponse, LoginState, LogoutResponse, MessagesResponse, QrLoginStartResult, QrLoginWaitResult, RestartResponse, SendFileRequest, SendResponse, SendTextRequest, StatusResponse, ) from qr_capture import QrCapture from send_queue import SendQueue from xdotool_driver import XdotoolDriver # 模块级 logger:s6/supervise 会把 stderr 收进日志,便于排障 logger = logging.getLogger("woc-bridge") # 为 woc-bridge logger 配置输出到 stderr 的 handler(默认 propagate=False,避免重复) # uvicorn 自身会配置 root logger,这里单独配置确保业务日志一定可见 if not logger.handlers: _handler = logging.StreamHandler(sys.stderr) _handler.setFormatter( logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s") ) logger.addHandler(_handler) logger.setLevel(logging.INFO) logger.propagate = False # bridge 版本号 # 1.0.0 → 1.1.0:新增 send_queue_pending / media_supported / bridge_capabilities # / db_error_code / placeholder / local_send_id / Retry-After 等字段与能力 # 1.1.0 → 1.2.0:新增 SQLCipher DB 解密能力 # 1.2.0 → 1.3.0:KeyCache 统一密钥来源;_resolve_db_state 统一 DB 状态解析; # 修复 env/api key 验证失败误清持久化 key;修复 _init_task start_time 硬编码; # _verify_db_key 内置 key_mode 返回;_check_db_readable 提取结果同步 init_state # 1.3.0 → 1.4.0:_check_db_readable 非阻塞化;_auto_extract_with_lock 公共加锁提取; # login_qr_start 非阻塞激活;DB 状态 1s 缓存;display_name LRU 缓存; # assert → 显式 BridgeError;_send_file isfile+access;移除死代码 BRIDGE_VERSION = "1.4.0" # bridge 能力清单(供客户端通过 /api/status 协商)。 # 当前已落地能力:text_send(文本发送真实可用)、db_decrypt(SQLCipher DB 解密)。 # 待落地能力(占位声明,未真正实现): # - image_send:W-02 完成后追加 # - file_send:W-02 完成后追加 # - long_poll:W-03 完成后追加 # - avatar:W-04 完成后追加 # - group_admin:W-06 完成后追加 BRIDGE_CAPABILITIES = ["text_send", "db_decrypt"] # 媒体发送是否已实现真实传输(W-02 完成后改 True)。 # 客户端据此决定是否调 /api/send/image 与 /api/send/file。 # # **联动点**:W-02 完成时需同步修改三处: # 1. 本常量 MEDIA_SUPPORTED = True # 2. BRIDGE_CAPABILITIES 追加 "image_send" / "file_send" # 3. _send_file 路由的 placeholder=True 改为 False MEDIA_SUPPORTED = False # --------------------------------------------------------------------------- # 诊断项定义(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} # --------------------------------------------------------------------------- # 全局对象(在 lifespan 中初始化) # --------------------------------------------------------------------------- # KeyCache 已抽到独立模块 key_cache.py,作为单一密钥来源(single source of truth) # - DbReader 通过 key_cache.get_key(salt) 查询解密密钥 # - server.py 通过 key_cache.set_key / set_key_map / clear 注入 # - 持久化(woc-keys.json)由 KeyCache 内部负责,避免双份同步 from key_cache import KeyCache 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}" @dataclass class BridgeConfig: """bridge 服务的配置项(来自命令行参数 + 环境变量)。 在启动时一次性构造并注入 AppState,运行期只读不写。 所有 os.environ.get / 类型转换集中在此处,避免散落。 """ # 命令行参数 listen: str = "0.0.0.0:8088" wechat_db: str = "/config" display: str = ":1" # 环境变量 send_delay_ms: int = 800 max_calls_per_sec: int = 10 max_batch_size: int = 50 poll_interval_ms: int = 2000 db_key: str = "" auto_extract_enabled: bool = True @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, send_delay_ms=int(os.environ.get("WOC_BRIDGE_SEND_DELAY_MS", "800")), 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"), ) 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.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 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 # --------------------------------------------------------------------------- # 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() try: yield finally: if _state.send_queue is not None: await _state.send_queue.stop() def _init_state_from_env() -> None: """从环境变量初始化全局状态(uvicorn 启动兜底路径)。""" args = argparse.Namespace( display=os.environ.get("DISPLAY", ":1") or ":1", wechat_db=os.environ.get("WOC_WECHAT_DB", "/config"), ) _init_state(args) 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(), ) # --------------------------------------------------------------------------- # 请求/响应日志中间件:统一打印入口/出口日志与耗时 # --------------------------------------------------------------------------- # 慢请求阈值(毫秒):超过则升级为 WARNING _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 # --------------------------------------------------------------------------- # 路由:GET /api/status # --------------------------------------------------------------------------- @app.get("/api/status", response_model=StatusResponse) async def get_status() -> StatusResponse: """聚合返回 bridge 运行状态。 一次请求内完成 4 类检测:进程运行、窗口存在、登录态、DB 可达性, 并返回当前账号信息与客户端轮询建议参数。所有调用方(channels 适配器、 面板健康检查、诊断 autofix)都应优先调本接口判断前置条件。 Returns: StatusResponse:含 bridge_version / wechat_running / wechat_window_found / login_state / db_accessible / current_wxid / current_nickname / uptime_seconds / display / max_batch_size / poll_interval_ms Notes: - 不抛业务错误:即使微信未运行 / DB 加密,也返回 200 + 对应 false 字段, 让调用方按字段判定下一步动作(而非按 HTTP 状态码) - login_state 内联判定(不调 detect_login_state),避免重复 pgrep/xdotool - DB 加密时细分:有 key 且验证过 → encrypted_key_ok + db_accessible=true; 无 key 且自动提取未启用 → encrypted_no_key; 自动提取已尝试但失败 → key_extract_failed; 二者 db_accessible 均为 false - current_wxid/nickname 仅在 db_accessible=true 且 login_state=logged_in 时才尝试读 DB,失败静默为空字符串 """ xdotool = _require_xdotool() db_reader = _require_db_reader() # 一次 status 请求内复用 pgrep/xdotool 结果,避免重复调用 wechat_running = await xdotool.is_wechat_running() window_id = await xdotool.find_wechat_window() if wechat_running else None wechat_window_found = window_id is not None # 登录态内联判定,避免 detect_login_state 内部再次 pgrep/xdotool search if not wechat_running: login_state = "not_running" elif not wechat_window_found: login_state = "not_logged_in" else: login_state = "logged_in" # DB 状态:统一走 _resolve_db_state(不触发自动提取,仅解析) db_state = await _resolve_db_state() db_accessible = db_state.db_accessible db_error_code = db_state.db_error_code init_in_progress = _state.init_state.state == "running" init_progress_pct = _state.init_state.progress_pct init_message = _state.init_state.message # current_wxid / current_nickname:从 DB 读,失败为空 current_wxid = "" current_nickname = "" if db_accessible and login_state == "logged_in": try: self_info = await asyncio.to_thread(db_reader.get_self_info) current_wxid = self_info.get("wxid", "") current_nickname = self_info.get("nickname", "") except Exception: current_wxid = "" current_nickname = "" uptime_seconds = time.monotonic() - _state.start_time return StatusResponse( bridge_version=BRIDGE_VERSION, wechat_running=wechat_running, wechat_window_found=wechat_window_found, login_state=login_state, db_accessible=db_accessible, db_error_code=db_error_code, init_in_progress=init_in_progress, init_progress_pct=init_progress_pct, init_message=init_message, current_wxid=current_wxid, current_nickname=current_nickname, uptime_seconds=uptime_seconds, display=_state.config.display, max_batch_size=_state.config.max_batch_size, poll_interval_ms=_state.config.poll_interval_ms, send_queue_pending=_state.send_queue.pending_count() if _state.send_queue else 0, media_supported=MEDIA_SUPPORTED, bridge_capabilities=list(BRIDGE_CAPABILITIES), ) @dataclass class DbState: """DB 状态解析结果(由 _resolve_db_state 返回)。 统一三处调用方(get_status / _check_db_readable / diagnostic_run)的 DB 状态判断逻辑,避免重复代码与行为不一致。 """ status: str # ok / not_found / need_init / key_invalid / unreadable db_accessible: bool # 是否可读(ok 或 有有效 key) db_error_code: Optional[str] # 不可读时的错误码 salt_hex: Optional[str] # 当前 DB 的 salt(加密 DB 才有) key_hex: Optional[str] # 当前 DB 对应的有效 key(无则 None) need_extract: bool # 无有效 key 且需要自动提取 # DB 状态短时缓存:避免高频 /api/status 重复读 salt/验证 key _db_state_cache: Optional[tuple[float, DbState]] = None _DB_STATE_CACHE_TTL = 1.0 # 秒 def _invalidate_db_state_cache() -> None: """失效 DB 状态缓存(key 变化、提取完成时调用)。""" global _db_state_cache _db_state_cache = None async def _resolve_db_state() -> DbState: """统一解析 DB 状态、salt、有效 key、pid 失效检测、env/api key 验证。 本函数只做"状态解析",不触发自动提取;提取由调用方按 need_extract 决定。 单次请求内 salt/pid 只读一次,结果携带在 DbState 中供调用方复用。 带 1 秒短时缓存,减少高频轮询时的重复 I/O。 Returns: DbState:含 status / db_accessible / db_error_code / salt_hex / key_hex / need_extract """ global _db_state_cache # 检查缓存 if _db_state_cache is not None: cached_at, cached_state = _db_state_cache if time.monotonic() - cached_at < _DB_STATE_CACHE_TTL: return cached_state db_reader = _require_db_reader() status = await asyncio.to_thread(db_reader.check_db_status) # 明文 DB 或不可达:直接返回 if status == "ok": result = DbState( status=status, db_accessible=True, db_error_code=None, salt_hex=None, key_hex=None, need_extract=False, ) _db_state_cache = (time.monotonic(), result) return result if status in ("not_found", "unreadable"): result = DbState( status=status, db_accessible=False, db_error_code=status, salt_hex=None, key_hex=None, need_extract=False, ) _db_state_cache = (time.monotonic(), result) return result # status in ("need_init", "key_invalid"):加密 DB salt_hex = await asyncio.to_thread(_read_db_salt, db_reader) key = db_reader._get_effective_key(salt_hex) if salt_hex else db_reader._get_effective_key() # auto_extract 的 key 在进程重启后失效 if key and _state.key_cache.cached and _state.key_cache.source == "auto_extract": wechat_pid_info = await _get_wechat_pid() current_pid = wechat_pid_info[0] if wechat_pid_info else None current_start_time = wechat_pid_info[1] if wechat_pid_info else None if not _state.key_cache.is_valid_for_pid(current_pid, current_start_time): _state.key_cache.clear() db_reader.invalidate_decrypted_cache() key = None # env/api 注入的 key 若未验证,快速验证 page1(4KB),避免无效 key 反复失败 if key and _state.key_cache.cached and _state.key_cache.source in ("env", "api") and not _state.key_cache.verified: verified, _key_mode = await asyncio.to_thread(_verify_db_key, db_reader, key) if verified: _state.key_cache.set_meta(verified=True) else: # env/api key 验证失败:仅清默认 key,保留持久化 salt_to_key db_reader.clear_default_key() key = None # 有有效 key:DB 可读 if key: result = DbState( status=status, db_accessible=True, db_error_code="encrypted_key_ok", salt_hex=salt_hex, key_hex=key, need_extract=False, ) _db_state_cache = (time.monotonic(), result) return result # 无有效 key init_in_progress = _state.init_state.state == "running" if init_in_progress: db_error_code = "init_in_progress" elif _state.config.auto_extract_enabled: db_error_code = "need_init" else: db_error_code = "encrypted_no_key" result = DbState( status=status, db_accessible=False, db_error_code=db_error_code, salt_hex=salt_hex, key_hex=None, need_extract=_state.config.auto_extract_enabled and not init_in_progress, ) _db_state_cache = (time.monotonic(), result) return result async def _auto_extract_with_lock() -> Optional[dict[str, str]]: """带锁的自动密钥提取,结果同步到 init_state。 供 diagnostic_autofix 直接调用(阻塞等待结果); _check_db_readable 通过 asyncio.create_task 在后台调用(非阻塞)。 串行化:通过 extract_lock 确保同一时间只有一个提取任务。 Returns: salt_hex -> enc_key_hex 映射;失败返回 None """ async with _state.extract_lock(): # 双重检查:持锁后可能已被其他请求提取成功 state = await _resolve_db_state() if state.db_accessible: return _state.db_reader.get_keys() if _state.db_reader else None if _state.init_state.state == "running": # 已在运行(不应该发生,lock 串行化),返回 None return None # 标记运行中,阻止后续并发请求重复扫描 _state.init_state.state = "running" _state.init_state.progress_pct = 0.0 _state.init_state.message = "正在扫描内存提取密钥" try: extracted = await _try_auto_extract_key() if extracted: _state.init_state.set_success( len(extracted), f"成功提取 {len(extracted)} 个密钥", ) else: _state.init_state.set_failed("自动提取密钥失败") return extracted except Exception as e: _state.init_state.set_failed(str(e)) return None finally: # 兜底:异常路径未设置 success/failed 时回退 idle if _state.init_state.state == "running": _state.init_state.state = "idle" _state.init_state.progress_pct = None _state.init_state.message = None # 失效 DB 状态缓存,让下次查询看到新 key _invalidate_db_state_cache() async def _check_db_readable() -> None: """检查 DB 是否可读,不可读时抛出对应错误码。 非阻塞设计(1.4.0+): - not_found → DB_NOT_FOUND - unreadable → DB_NOT_FOUND - ok 或有有效 key → 不抛异常 - 无有效 key 且提取进行中 → 抛 DB_ENCRYPTED(提示稍后重试) - 无有效 key 且需提取 → 后台触发提取(不等待),立即抛 DB_ENCRYPTED - 无有效 key 且提取已失败 → 抛 DB_ENCRYPTED(提示注入 key) - 自动提取未启用 → 抛 DB_ENCRYPTED(提示注入 key) Raises: BridgeError(DB_NOT_FOUND / DB_ENCRYPTED) """ db_reader = _require_db_reader() state = await _resolve_db_state() if state.status == "not_found": raise BridgeError(code="DB_NOT_FOUND", message="未在 /config 下找到微信消息 DB") if state.status == "unreadable": raise BridgeError(code="DB_NOT_FOUND", message="DB 文件存在但不可读(权限问题)") if state.db_accessible: # ok 或有有效 key:不抛异常 return # 无有效 key init_state = _state.init_state.state if init_state == "running": pct = _state.init_state.progress_pct or 0 raise BridgeError( code="DB_ENCRYPTED", message=f"DB 已加密,密钥提取进行中({pct:.0f}%),请稍后重试", ) if not state.need_extract: # 自动提取未启用,或已提取过但当前 salt 无匹配 if init_state == "failed": raise BridgeError( code="DB_ENCRYPTED", message="DB 已加密(SQLCipher),自动提取密钥失败,需通过 POST /api/db/decrypt 注入密钥或设置 WOC_DB_KEY", ) raise BridgeError( code="DB_ENCRYPTED", message="DB 已加密(SQLCipher),需通过 POST /api/db/decrypt 注入密钥或设置 WOC_DB_KEY", ) # need_extract=True:后台触发提取(非阻塞),立即返回 503 asyncio.create_task(_auto_extract_with_lock()) raise BridgeError( code="DB_ENCRYPTED", message="DB 已加密(SQLCipher),已触发后台密钥提取,请稍后重试", ) def _verify_db_key( db_reader: DbReader, key_hex: str, salt_hex: str | None = None, ) -> tuple[bool, Optional[str]]: """验证 key 是否匹配当前 DB 文件,并返回 key_mode。 用 db_reader._find_db_path 找到 DB 文件,调 Decryptor.verify_key 验证。 验证通过时一并返回 key_mode(raw_enc_key / sqlcipher_passphrase), 避免调用方再读一次 page1。 Args: db_reader: DB 读取器 key_hex: 64 位十六进制密钥 salt_hex: 未使用(保留参数兼容旧调用),自动读取当前 DB salt Returns: (verified, key_mode): verified 为是否验证通过; key_mode 为密钥形态(验证失败时为 None) """ from decryptor import Decryptor db_path = db_reader._find_db_path() if db_path is None: return (False, None) try: decryptor = Decryptor(key_hex) verified = decryptor.verify_key(db_path) if not verified: return (False, None) # 验证通过,获取 key_mode(复用已读的 page1 逻辑) key_mode = _get_key_mode(key_hex, db_path) return (True, key_mode) except Exception: return (False, None) def _read_db_salt(db_reader: DbReader) -> str | None: """读取当前 DB 文件的 salt(同步阻塞)。""" db_path = db_reader._find_db_path() if db_path is None: return None return db_reader._read_db_salt(db_path) async def _get_wechat_pid() -> tuple[int, float] | None: """获取微信主进程 PID 与启动时间。 使用与 key_extractor 一致的进程识别逻辑(检查 /proc//comm 与 exe), 避免 `pgrep -f xwechat` 漏掉主进程(主进程命令行不含 xwechat)。 Returns: (pid, start_time) 或 None(进程未运行) """ try: from key_extractor import _get_wechat_pids pids = await asyncio.to_thread(_get_wechat_pids) if not pids: return None # 取内存占用最大的进程作为主进程 pid, _rss_kb = pids[0] # 读 /proc//stat 的第 22 字段(start_time,单位 jiffies) try: with open(f"/proc/{pid}/stat", "r") as f: stat_fields = f.read().split() start_time = float(stat_fields[21]) if len(stat_fields) > 21 else 0.0 except (OSError, IndexError, ValueError): start_time = 0.0 return (pid, start_time) except Exception: return None async def _try_auto_extract_key() -> dict[str, str] | None: """尝试从微信进程内存自动提取 SQLCipher 密钥。 流程: 1. 检查 auto_extract_enabled 开关 2. 检查微信进程是否运行(pgrep) 3. 获取全部微信 PID 候选(不只取第一个) 4. 找到加密 DB 目录作为探针 5. 对每个 PID 调 KeyExtractor.extract_all_keys() 提取 salt->enc_key 映射 6. 成功则缓存到 KeyCache(含 pid / start_time)并注入 DbReader Returns: salt_hex -> enc_key_hex 映射字典;失败返回 None """ if not _state.config.auto_extract_enabled: return None _require_db_reader() _require_xdotool() # 检查微信进程是否运行 wechat_running = await _state.xdotool.is_wechat_running() if not wechat_running: return None # 找到加密 DB 目录作为探针 db_path = await asyncio.to_thread(_state.db_reader._find_db_path) if db_path is None: logger.warning("自动提取密钥失败:未找到 DB 路径") return None # 优先扫描整个 /config/xwechat_files,让 KeyExtractor 自己探测所有微信 PID # 原 pgrep -f xwechat 会漏掉主进程(主进程命令行里没有 xwechat 字样) db_dir = "/config/xwechat_files" if not os.path.isdir(db_dir): db_dir = os.path.dirname(db_path) logger.info("自动提取密钥开始,探针目录: %s", db_dir) from key_extractor import KeyExtractor try: # pid=0 让 KeyExtractor 用 /proc 自探测,比 pgrep 更可靠 extractor = KeyExtractor(0, db_path) key_map = await asyncio.to_thread(extractor.extract_all_keys, db_dir) if key_map: # 记录成功时关联的 PID(取 key_map 中第一个地址反推太麻烦, # 这里用 _get_wechat_pid 拿到主进程 PID 做缓存校验) wechat_pid_info = await _get_wechat_pid() wechat_pid = wechat_pid_info[0] if wechat_pid_info else 0 start_time = wechat_pid_info[1] if wechat_pid_info else 0.0 # KeyExtractor 内部已通过 page 1 HMAC 验证,无需再次全库验证 # 统一通过 db_reader.set_keys 转发到 KeyCache.set_key_map, # 避免 key_cache 与 db_reader 两份重复同步 _state.db_reader.set_keys( key_map, source="auto_extract", pid=wechat_pid, start_time=start_time, ) _invalidate_db_state_cache() logger.info( "自动提取 DB 密钥成功(主 PID=%s,%d salts),来源:微信进程内存扫描", wechat_pid, len(key_map), ) return key_map except Exception as e: logger.exception("自动提取密钥异常: %s", e) logger.warning("自动提取 DB 密钥失败:扫描全部微信进程后未找到有效密钥") return None def _get_key_mode(key_hex: str, db_path: str) -> str | None: """获取 key 形态(raw_enc_key / sqlcipher_passphrase)。 通过读取 DB 第 1 页 + _resolve_page1_key_material 验证后返回 mode。 """ try: from decryptor import _resolve_page1_key_material with open(db_path, "rb") as f: page1 = f.read(4096) key_bytes = bytes.fromhex(key_hex) result = _resolve_page1_key_material(key_bytes, page1) if result: return result[2] except Exception: pass return None # --------------------------------------------------------------------------- # 路由:POST /api/db/decrypt(手动 key 注入) # --------------------------------------------------------------------------- @app.post("/api/db/decrypt", response_model=DbDecryptResponse) async def decrypt_db_with_key(request: DbDecryptRequest) -> DbDecryptResponse: """手动传入 SQLCipher 密钥触发解密验证与缓存。 外部系统(如 ForcePilot)或管理员通过本接口注入 64 位十六进制密钥, bridge 验证 key 是否匹配当前 DB,验证通过后按 salt 缓存到 KeyCache, 后续所有 DB 查询接口立即恢复可用。 请求体除 key 外,可选传 salt 字段指定该 key 对应的 salt;不传时 bridge 自动读取当前 DB 的 salt 进行匹配。 Args: request: 含 key 字段(64 位十六进制字符串),可选 salt 字段 Returns: DbDecryptResponse:含 success / verified / key_mode / error """ key = request.key.strip() # 格式校验 if len(key) != 64 or not all(c in "0123456789abcdefABCDEF" for c in key): raise BridgeError( code="INVALID_PARAMS", message="key 必须是 64 位十六进制字符串", ) db_reader = _require_db_reader() # 检查 DB 是否存在 db_status = await asyncio.to_thread(db_reader.check_db_status) if db_status == "not_found": raise BridgeError( code="DB_NOT_FOUND", message="未找到微信 DB,无法验证 key", ) if db_status == "unreadable": raise BridgeError( code="DB_NOT_FOUND", message="DB 文件存在但不可读(权限问题),无法验证 key", ) # 明文 DB 无需 key,直接返回提示 if db_status == "ok": return DbDecryptResponse( success=True, verified=False, key_mode=None, error="db_not_encrypted", ) # 获取当前 DB 的 salt(用户未指定时自动识别) salt_hex = getattr(request, "salt", None) if not salt_hex: salt_hex = await asyncio.to_thread(_read_db_salt, db_reader) if not salt_hex: raise BridgeError( code="DB_ENCRYPTED", message="无法读取当前 DB 的 salt", ) # 验证 key(_verify_db_key 已内置 key_mode 获取,无需再读一次 page1) try: verified, key_mode = await asyncio.to_thread(_verify_db_key, db_reader, key) except Exception: verified, key_mode = False, None if verified: # 缓存 key 并注入 DbReader(按 salt 存储) # db_reader.set_key 已转发到 key_cache.set_key,避免双份同步 db_reader.set_key(key, salt_hex=salt_hex) _invalidate_db_state_cache() # 补充元信息(source/verified 在 set_key 默认为 auto_extract/True,这里覆盖为 api) _state.key_cache.set_meta(source="api", verified=True) logger.info( "db/decrypt: key=%s...%s salt=%s → 验证成功, key_mode=%s", key[:4], key[-4:], salt_hex, key_mode, ) return DbDecryptResponse( success=True, verified=True, key_mode=key_mode, error=None, ) else: logger.warning( "db/decrypt: key=%s...%s salt=%s → 验证失败(key 不匹配)", key[:4], key[-4:], salt_hex, ) return DbDecryptResponse( success=True, verified=False, key_mode=None, error="key_mismatch", ) # --------------------------------------------------------------------------- # 路由:GET /api/db/key/status(key 缓存状态查询) # --------------------------------------------------------------------------- @app.get("/api/db/key/status", response_model=DbKeyStatusResponse) async def get_db_key_status() -> DbKeyStatusResponse: """返回当前 DB 解密密钥缓存状态。 供调用方判断是否需要注入 key 或等待自动提取。 Returns: DbKeyStatusResponse:含 cached / source / verified / key_prefix """ return DbKeyStatusResponse( cached=_state.key_cache.cached, source=_state.key_cache.source, verified=_state.key_cache.verified, key_prefix=_state.key_cache.key_prefix(), ) # --------------------------------------------------------------------------- # 路由:POST /api/db/init(显式触发密钥提取) # --------------------------------------------------------------------------- @app.post("/api/db/init", response_model=DbInitResponse) async def init_db(request: DbInitRequest = DbInitRequest()) -> DbInitResponse: """显式触发 DB 初始化(密钥提取)。 后台线程执行内存扫描,避免阻塞 /api/status 等接口。调用方可通过 GET /api/db/init/status 轮询进度,或观察 /api/status 中的 init_in_progress / init_progress_pct / init_message 字段。 Args: request: 可选 pid / db_dir / force Returns: DbInitResponse:请求受理状态 """ _require_db_reader() if _state.init_state.state == "running": return DbInitResponse( success=True, state="in_progress", message=_state.init_state.message or "初始化中,请稍后", key_count=None, ) if _state.db_reader.has_keys() and not request.force: key_count = len(_state.db_reader.get_keys()) return DbInitResponse( success=True, state="already_done", message=f"已初始化({key_count} 个密钥),使用 force=true 可强制重新提取", key_count=key_count, ) def _init_task(pid: Optional[int], db_dir: Optional[str], force: bool) -> None: from key_extractor import KeyExtractor, _get_wechat_pids try: _state.init_state.set_progress(10.0, "正在检测微信进程") if pid: pids = [(pid, 0)] else: pids = _get_wechat_pids() if not pids: _state.init_state.set_failed("微信进程未运行") return _state.init_state.set_progress(20.0, "正在定位数据库目录") if db_dir is None: db_path = _state.db_reader._find_db_path() if db_path is None: _state.init_state.set_failed("未找到微信数据库目录") return db_dir = os.path.dirname(db_path) scan_dir = "/config/xwechat_files" if os.path.isdir("/config/xwechat_files") else db_dir _state.init_state.set_progress(30.0, "正在扫描内存提取密钥") extractor = KeyExtractor(pid or 0, db_dir) key_map = extractor.extract_all_keys(scan_dir) if not key_map: _state.init_state.set_failed("未提取到任何有效密钥") return _state.init_state.set_progress(80.0, "正在保存密钥到文件") wechat_pid = pids[0][0] # 读取真实 start_time,用于进程重启后 key 缓存失效检测 start_time = 0.0 try: with open(f"/proc/{wechat_pid}/stat", "r") as f: stat_fields = f.read().split() start_time = float(stat_fields[21]) if len(stat_fields) > 21 else 0.0 except (OSError, IndexError, ValueError): start_time = 0.0 _state.db_reader.set_keys( key_map, source="auto_extract", pid=wechat_pid, start_time=start_time, ) _invalidate_db_state_cache() _state.init_state.set_success( len(key_map), f"成功提取 {len(key_map)} 个密钥", ) except Exception as e: _state.init_state.set_failed(str(e)) _state.init_state.start(_init_task, args=(request.pid, request.db_dir, request.force)) return DbInitResponse( success=True, state="started", message="初始化已启动,可通过 /api/db/init/status 查询进度", key_count=None, ) # --------------------------------------------------------------------------- # 路由:GET /api/db/init/status # --------------------------------------------------------------------------- @app.get("/api/db/init/status", response_model=DbInitStatusResponse) async def get_init_status() -> DbInitStatusResponse: """查询 DB 初始化后台任务状态。""" return DbInitStatusResponse( state=_state.init_state.state, progress_pct=_state.init_state.progress_pct, message=_state.init_state.message, key_count=_state.init_state.key_count, error=_state.init_state.error, ) # --------------------------------------------------------------------------- # 路由:GET /api/messages/since # --------------------------------------------------------------------------- @app.get("/api/messages/since", response_model=MessagesResponse) async def get_messages_since(cursor: int = 0, limit: int = 50) -> MessagesResponse: """增量消息拉取(按 create_time 游标)。 channels PullerAdapter 的核心接口:调用方维护本地 cursor(首次为 0), 每次拉取 create_time > cursor 的消息,把响应中 next_cursor 存下来作为 下次的 cursor。has_more=true 时应立即继续拉取,否则按 poll_interval_ms 轮询。 Args: cursor: 上次拉取的最大 create_time,首次传 0(拉全量) limit: 最多返回条数,1~200,默认 50。建议用 /api/status 返回的 max_batch_size 作为上限参考 Returns: MessagesResponse:含 messages 列表 / next_cursor / has_more Raises: BridgeError(INVALID_PARAMS): cursor<0 或 limit 越界(HTTP 400) BridgeError(DB_NOT_FOUND): 未找到微信消息 DB(HTTP 500) BridgeError(DB_ENCRYPTED): DB 已加密,需 SQLCipher 密钥(HTTP 503) Notes: - DB 读取通过 cp 快照避免锁定原文件,sqlite3 同步阻塞,故用 asyncio.to_thread 包装,避免阻塞 event loop - 群消息 content 形如 "wxid:\\n正文",DbReader 会拆出 sender 字段 - has_more=true 当且仅当返回条数 >= limit(可能还有更多未拉取) """ # 参数校验 if cursor < 0: raise BridgeError( code="INVALID_PARAMS", message=f"cursor 必须 >= 0,收到 {cursor}", ) if limit < 1 or limit > 200: raise BridgeError( code="INVALID_PARAMS", message=f"limit 必须在 1~200 之间,收到 {limit}", ) db_reader = _require_db_reader() # DB 加密时返回 DB_ENCRYPTED,而非空数据 await _check_db_readable() # DB 读取是同步阻塞操作,用 to_thread 包装 result = await asyncio.to_thread(db_reader.get_messages_since, cursor, limit) msg_count = len(result.get("messages", [])) logger.info( "messages/since: cursor=%d limit=%d → 返回 %d 条, next_cursor=%s has_more=%s", cursor, limit, msg_count, result.get("next_cursor"), result.get("has_more"), ) return MessagesResponse(**result) # --------------------------------------------------------------------------- # 路由:POST /api/send/text # --------------------------------------------------------------------------- # display_name 解析缓存:wxid -> (display_name, timestamp) # 联系人备注/昵称不常变,5 分钟 TTL 足够;最大 256 条防内存膨胀 _display_name_cache: dict[str, tuple[str, float]] = {} _DISPLAY_NAME_CACHE_TTL = 300.0 # 5 分钟 _DISPLAY_NAME_CACHE_MAX = 256 async def _resolve_display_name( db_reader: Optional[DbReader], to_wxid: str, display_name: Optional[str], ) -> str: """解析用于微信搜索框定位会话的显示名。 优先级: 1. 调用方显式传入的 display_name(strip 后非空才用) 2. 缓存命中(5 分钟 TTL) 3. 从 contact.db 读取该 wxid 的备注(remark) 4. 从 contact.db 读取该 wxid 的昵称(nickname) 5. 回退到 wxid 本身 DB 不可用时静默回退到 wxid,避免阻塞发送流程。 """ # 显式传入优先 if display_name and display_name.strip(): return display_name.strip() # 缓存命中 cached = _display_name_cache.get(to_wxid) if cached and time.monotonic() - cached[1] < _DISPLAY_NAME_CACHE_TTL: return cached[0] if db_reader is None: return to_wxid try: contact = await asyncio.to_thread(db_reader.get_contact_detail, to_wxid) except Exception: return to_wxid if contact: resolved = contact.get("remark") or contact.get("nickname") or to_wxid else: resolved = to_wxid # 写入缓存(简单 LRU:超限时删最旧条目) if len(_display_name_cache) >= _DISPLAY_NAME_CACHE_MAX: oldest = min(_display_name_cache, key=lambda k: _display_name_cache[k][1]) del _display_name_cache[oldest] _display_name_cache[to_wxid] = (resolved, time.monotonic()) return resolved @app.post("/api/send/text", response_model=SendResponse) async def send_text(req: SendTextRequest) -> SendResponse: """发送文本消息(channels OutboundAdapter 核心接口)。 流程: 1. 校验 to_wxid / content 非空 2. 解析 display_name:显式传入 > 备注 > 昵称 > wxid 3. 检测登录态,非 logged_in 直接拒绝(避免 UI 操作引发不可预期行为) 4. 经 send_queue 串行执行:activate_window → Ctrl+F 搜索会话(按 display_name) → 选中并进入 → xclip 粘贴 content → 回车发送 5. 返回本地生成的 local_send_id(不等待微信发送回执) Args: req: SendTextRequest(to_wxid + content + 可选 display_name) Returns: SendResponse:success=true / local_send_id(格式 local__<随机>) / placeholder=false(文本发送为真实发送,非占位) Raises: BridgeError(INVALID_PARAMS): to_wxid 或 content 为空(HTTP 400) BridgeError(WECHAT_NOT_LOGGED_IN): 当前未登录(HTTP 401) BridgeError(SEND_FAILED): xdotool/xclip 操作失败(HTTP 500) Notes: - 发送队列串行化避免 Ctrl+F 搜索框竞态(前一条消息的搜索未关闭时, 后一条会粘到错误的会话) - local_send_id 由 bridge 本地生成,不对应微信真实 msg_id; 调用方应用它做幂等去重,**禁止用它到 /api/media/{msg_id} 查媒体** - 发送队列的发送间隔与限频由 .env 的 WOC_BRIDGE_SEND_DELAY_MS / WOC_BRIDGE_MAX_CALLS_PER_SEC 控制 """ xdotool = _require_xdotool() send_queue = _require_send_queue() # 校验请求体 if not req.to_wxid: raise BridgeError(code="INVALID_PARAMS", message="to_wxid 不能为空") if not req.content: raise BridgeError(code="INVALID_PARAMS", message="content 不能为空") # 检查登录态 login_state = await xdotool.detect_login_state() if login_state != "logged_in": raise BridgeError( code="WECHAT_NOT_LOGGED_IN", message=f"当前登录态为 {login_state},无法发送消息", ) # 解析显示名(优先备注/昵称,不再直接用 wxid 搜索) display_name = await _resolve_display_name( _state.db_reader, req.to_wxid, req.display_name ) # 经发送队列串行执行 try: local_send_id = await send_queue.enqueue( lambda: xdotool.send_text(req.to_wxid, req.content, display_name) ) except BridgeError: raise except Exception as e: raise BridgeError( code="SEND_FAILED", message=f"发送失败: {e}", ) logger.info( "send/text: to=%s display_name=%s content_len=%d → 成功 local_send_id=%s", req.to_wxid, display_name, len(req.content), local_send_id, ) return SendResponse(success=True, local_send_id=local_send_id, placeholder=False, error=None) # --------------------------------------------------------------------------- # 路由:POST /api/login/qr/start # --------------------------------------------------------------------------- @app.post("/api/login/qr/start", response_model=QrLoginStartResult) async def login_qr_start() -> QrLoginStartResult: """启动扫码登录:截取二维码区域并返回 data URL。 channels LoginAdapter 的扫码入口。调用本接口后,调用方应把 qr_data_url 渲染给用户扫描,然后立即调 GET /api/login/qr/wait 轮询登录结果。 Returns: QrLoginStartResult:含 qr_data_url(data:image/png;base64,...) / message / connected=false Raises: BridgeError(WINDOW_NOT_FOUND): 微信窗口未找到,无法截图(HTTP 503) 其他异常由全局兜底处理器返回 BRIDGE_INTERNAL_ERROR Notes: - 激活微信窗口后再截图,确保二维码可见(不被其他窗口遮挡) - 二维码有时效(微信约 60s 刷新),调用方应在 qr/wait 超时后 重新调本接口获取新二维码 - 若微信已登录,本接口仍会返回二维码截图(调用方应先调 /api/status 判断 login_state,避免重复登录) """ xdotool = _require_xdotool() qr_capture = _require_qr_capture() # 激活微信窗口(非阻塞,避免 VNC 无人操作时 --sync 死等) await xdotool._activate_window_fast() qr_data_url = await qr_capture.capture_qr_code() return QrLoginStartResult( qr_data_url=qr_data_url, message="请使用微信扫描二维码", connected=False, ) # --------------------------------------------------------------------------- # 路由:GET /api/login/qr/wait # --------------------------------------------------------------------------- @app.get("/api/login/qr/wait", response_model=QrLoginWaitResult) async def login_qr_wait(timeout: int = 30) -> QrLoginWaitResult: """轮询登录态,等待扫码登录完成。 每 2 秒调一次 detect_login_state,直到 logged_in / not_running / 超时。 长轮询模式(响应在登录成功或超时后才返回),调用方无需在 client 端做 轮询间隔控制,直接发请求阻塞等待即可。 Args: timeout: 最大等待秒数,默认 30,上限 120(防止长时间占用连接) Returns: QrLoginWaitResult:含 connected / message / qr_data_url(成功时为空) / credentials(成功时含 wxid + nickname,否则为 None) Raises: BridgeError(INVALID_PARAMS): timeout < 1(HTTP 400) Notes: - 微信进程未运行(not_running)时立即返回,不继续等待(继续等无意义) - 登录成功时尝试从 DB 读 wxid/nickname;DB 不可达时返回空字符串 (credentials 不为 None,但 wxid 为空) - 超时返回 connected=false,message="等待扫码超时",调用方应重新调 POST /api/login/qr/start 获取新二维码(旧二维码可能已过期) """ if timeout < 1: raise BridgeError(code="INVALID_PARAMS", message="timeout 必须 >= 1") if timeout > 120: timeout = 120 xdotool = _require_xdotool() db_reader = _require_db_reader() deadline = time.monotonic() + timeout while time.monotonic() < deadline: state = await xdotool.detect_login_state() if state == "logged_in": # 登录成功:从 DB 读取 wxid / nickname try: self_info = await asyncio.to_thread(db_reader.get_self_info) wxid = self_info.get("wxid", "") nickname = self_info.get("nickname", "") except Exception: wxid = "" nickname = "" logger.info("login/qr/wait: → 登录成功 wxid=%s nickname=%s", wxid, nickname) return QrLoginWaitResult( connected=True, message="登录成功", qr_data_url="", credentials={"wxid": wxid, "nickname": nickname}, ) if state == "not_running": # 微信进程未运行,继续等无意义,直接返回 logger.warning("login/qr/wait: → 微信进程未运行") return QrLoginWaitResult( connected=False, message="微信进程未运行,无法扫码登录", qr_data_url="", credentials=None, ) # not_logged_in / logging_in:继续等待 await asyncio.sleep(2) # 超时 logger.warning("login/qr/wait: → 等待扫码超时(%ds)", timeout) return QrLoginWaitResult( connected=False, message="等待扫码超时", qr_data_url="", credentials=None, ) # --------------------------------------------------------------------------- # 路由:POST /api/login/logout # --------------------------------------------------------------------------- @app.post("/api/login/logout", response_model=LogoutResponse) async def login_logout() -> LogoutResponse: """退出微信登录(channels LifecycleAdapter 调用)。 通过 UI 操作退出登录,避免直接 kill 进程导致登录态文件未刷盘。 幂等:未登录时直接返回成功(不抛错),方便调用方无需先查状态再调用。 流程(仅 logged_in 时执行): 1. detect_login_state 确认登录态 2. xdotool.logout() 执行 UI 操作:激活窗口 → 点击主菜单 → 方向键导航 到「退出登录」→ 回车 → 确认对话框(具体坐标/次数为估算值,需实测调优) Returns: LogoutResponse:success=true / message("已退出登录" 或 "当前未登录,无需退出") Raises: BridgeError(LOGOUT_FAILED): 窗口未找到或 UI 操作失败(HTTP 500) Notes: - 幂等:not_running / not_logged_in 状态下都返回 success=true - logout() 的 UI 坐标/方向键次数为估算值,spec 已记录需实测调优 - 退出后微信进程仍在运行(只是登录态变 not_logged_in),autostart 不会拉起新进程;如需重新登录走 /api/login/qr/start - 不删除任何文件:登录态由微信自身管理,bridge 不破坏数据 """ xdotool = _require_xdotool() # 记录调用前的登录态(用于返回 message) state_before = await xdotool.detect_login_state() # 若已登录,执行 UI 退出操作 if state_before == LoginState.LOGGED_IN.value: try: await xdotool.logout() except BridgeError: raise except Exception as e: raise BridgeError( code="LOGOUT_FAILED", message=f"退出登录失败: {e}", ) return LogoutResponse(success=True, message="已退出登录") # 未登录,幂等返回 return LogoutResponse(success=True, message="当前未登录,无需退出") # --------------------------------------------------------------------------- # 路由:POST /api/wechat/restart # --------------------------------------------------------------------------- @app.post("/api/wechat/restart", response_model=RestartResponse) async def wechat_restart() -> RestartResponse: """重启微信进程(不破坏登录态,数据卷保留)。 流程: 1. pgrep -x wechat 获取当前所有 PID 2. 对每个 PID 发 SIGTERM(不强制 SIGKILL,给微信优雅退出机会,避免 DB 写入未刷盘) 3. 轮询 pgrep -x wechat,等待新 PID 出现(autostart 2 秒后拉起) 4. 30 秒内未出现新进程抛 RESTART_TIMEOUT 与 docker stop/start 的区别: - 本接口只重启微信进程,不重启容器,X 会话/VNC 连接保持,速度更快(~5s) - docker stop 会杀整个容器,VNC 断连,需重新连接 Returns: RestartResponse:success=true / message("微信已重启" 或 "微信已启动") / pid(新进程 PID) Raises: BridgeError(RESTART_TIMEOUT): 30 秒内未检测到新进程(HTTP 408) BridgeError(BRIDGE_INTERNAL_ERROR): pgrep 等命令异常(HTTP 500) Notes: - 数据卷保留,登录态不丢(除非 SIGTERM 期间微信主动退出登录) - 不提供 stop/start 接口:autostart watchdog 会立即拉起被 stop 的微信进程,stop 没有意义;启动由 autostart 管理 - 若 was_running=false(重启前未运行),message="微信已启动" - 调用方应在收到 success=true 后调 /api/status 确认 login_state 恢复到 logged_in(autostart 拉起后微信会自动尝试恢复登录) """ xdotool = _require_xdotool() # 记录调用前是否在运行 was_running = await xdotool.is_wechat_running() try: new_pid = await xdotool.restart_wechat(timeout_sec=30) except BridgeError: raise except Exception as e: # 非 timeout 的意外错误(如 pgrep 不可用)归为内部错误, # 不误报为 RESTART_TIMEOUT(调用方会按 timeout 语义重试,无意义) raise BridgeError( code="BRIDGE_INTERNAL_ERROR", message=f"重启微信失败: {e}", ) message = "微信已重启" if was_running else "微信已启动" return RestartResponse(success=True, message=message, pid=new_pid) # --------------------------------------------------------------------------- # 路由:GET /api/contacts # --------------------------------------------------------------------------- @app.get("/api/contacts", response_model=ContactsResponse) async def get_contacts(keyword: str = "", limit: int = 50) -> ContactsResponse: """联系人列表查询(channels SessionAdapter / WizardAdapter 使用)。 Args: keyword: 模糊匹配 wxid / nickname / remark,空串返回全部 limit: 1~200,默认 50 Returns: ContactsResponse:含 contacts 列表 / total Raises: BridgeError(INVALID_PARAMS): limit 越界(HTTP 400) BridgeError(DB_NOT_FOUND): 未找到微信消息 DB(HTTP 500) BridgeError(DB_ENCRYPTED): DB 已加密(HTTP 503) Notes: - 查询走 cp 快照 + sqlite3 同步阻塞,asyncio.to_thread 包装 - keyword 为空时仍走索引,不会全表扫描 """ if limit < 1 or limit > 200: raise BridgeError( code="INVALID_PARAMS", message=f"limit 必须在 1~200 之间,收到 {limit}", ) db_reader = _require_db_reader() # DB 加密时返回 DB_ENCRYPTED,而非空数据 await _check_db_readable() result = await asyncio.to_thread(db_reader.get_contacts, keyword, limit) contacts_list = result.get("contacts", []) logger.info( "contacts: keyword='%s' limit=%d → 返回 %d 条, total=%d", keyword, limit, len(contacts_list), result.get("total", 0), ) return ContactsResponse( contacts=[Contact(**c) for c in contacts_list], total=result.get("total", 0), ) # --------------------------------------------------------------------------- # 路由:GET /api/contacts/{wxid} # --------------------------------------------------------------------------- @app.get("/api/contacts/{wxid}", response_model=Contact) async def get_contact_detail(wxid: str) -> Contact: """单条联系人详情查询。 Args: wxid: 路径参数,联系人 wxid(如 wxid_xxx 或 gh_xxx 公众号) Returns: Contact:单条联系人记录 Raises: BridgeError(DB_NOT_FOUND): 未找到微信消息 DB(HTTP 500) BridgeError(DB_ENCRYPTED): DB 已加密(HTTP 503) BridgeError(CONTACT_NOT_FOUND): 指定 wxid 不存在(HTTP 404) Notes: - 不存在的 wxid 返回 CONTACT_NOT_FOUND(404),与 DB 不可达错误区分 - 群聊 username(xxx@chatroom)也可用本接口查(DbReader 视为联系人) """ db_reader = _require_db_reader() # DB 加密时返回 DB_ENCRYPTED,而非空数据 await _check_db_readable() result = await asyncio.to_thread(db_reader.get_contact_detail, wxid) if result is None: raise BridgeError( code="CONTACT_NOT_FOUND", message=f"未找到联系人: {wxid}", ) return Contact(**result) # --------------------------------------------------------------------------- # 路由:GET /api/groups # --------------------------------------------------------------------------- @app.get("/api/groups", response_model=GroupsResponse) async def get_groups(limit: int = 50) -> GroupsResponse: """群聊列表查询。 Args: limit: 1~200,默认 50 Returns: GroupsResponse:含 groups 列表(每项为 Contact 结构)/ total Raises: BridgeError(INVALID_PARAMS): limit 越界(HTTP 400) BridgeError(DB_NOT_FOUND): 未找到微信消息 DB(HTTP 500) BridgeError(DB_ENCRYPTED): DB 已加密(HTTP 503) Notes: - 群聊判定条件:username LIKE '%@chatroom' - 返回结构复用 Contact(群也存于 contact 表,字段相同) """ if limit < 1 or limit > 200: raise BridgeError( code="INVALID_PARAMS", message=f"limit 必须在 1~200 之间,收到 {limit}", ) db_reader = _require_db_reader() # DB 加密时返回 DB_ENCRYPTED,而非空数据 await _check_db_readable() result = await asyncio.to_thread(db_reader.get_groups, limit) groups_list = result.get("groups", []) logger.info( "groups: limit=%d → 返回 %d 个群, total=%d", limit, len(groups_list), result.get("total", 0), ) return GroupsResponse( groups=[Contact(**g) for g in groups_list], total=result.get("total", 0), ) # --------------------------------------------------------------------------- # 路由:GET /api/groups/{wxid}/members # --------------------------------------------------------------------------- @app.get("/api/groups/{wxid}/members", response_model=GroupMembersResponse) async def get_group_members(wxid: str) -> GroupMembersResponse: """群成员列表查询。 Args: wxid: 路径参数,群聊 wxid(形如 xxxxx@chatroom) Returns: GroupMembersResponse:含 members 列表 / total / group_wxid Raises: BridgeError(DB_NOT_FOUND): 未找到微信消息 DB(HTTP 500) BridgeError(DB_ENCRYPTED): DB 已加密(HTTP 503) Notes: - 群成员信息存于 group_members 表,DbReader 联表查询 - 若群 wxid 不存在或无成员记录,返回空列表而非错误 """ db_reader = _require_db_reader() # DB 加密时返回 DB_ENCRYPTED,而非空数据 await _check_db_readable() result = await asyncio.to_thread(db_reader.get_group_members, wxid) return GroupMembersResponse(**result) # --------------------------------------------------------------------------- # 路由:POST /api/send/image # --------------------------------------------------------------------------- @app.post("/api/send/image", response_model=SendResponse) async def send_image(req: SendFileRequest) -> SendResponse: """发送图片消息(MVP 简化版)。 当前 MVP 实现仅校验文件存在 + 进入会话 + 发送"[图片] 文件名"提示文本, 真实图片传输(拖拽 / Ctrl+V 粘贴文件路径)留给后续完善(W-02)。 响应中 placeholder=true 标识当前为占位实现。 Args: req: SendFileRequest(to_wxid + file_path + 可选 display_name) Returns: SendResponse:success=true / local_send_id / placeholder=true Raises: 同 _send_file:INVALID_PARAMS / WECHAT_NOT_LOGGED_IN / SEND_FAILED """ return await _send_file(req, is_image=True) # --------------------------------------------------------------------------- # 路由:POST /api/send/file # --------------------------------------------------------------------------- @app.post("/api/send/file", response_model=SendResponse) async def send_file(req: SendFileRequest) -> SendResponse: """发送文件消息(MVP 简化版)。 当前 MVP 实现仅校验文件存在 + 进入会话 + 发送"[文件] 文件名"提示文本, 真实文件传输(拖拽 / 文件选择器)留给后续完善(W-02)。 响应中 placeholder=true 标识当前为占位实现。 Args: req: SendFileRequest(to_wxid + file_path + 可选 display_name) Returns: SendResponse:success=true / local_send_id / placeholder=true Raises: 同 _send_file:INVALID_PARAMS / WECHAT_NOT_LOGGED_IN / SEND_FAILED """ return await _send_file(req, is_image=False) async def _send_file(req: SendFileRequest, is_image: bool) -> SendResponse: """send_image / send_file 共用实现。 流程: 1. 校验 to_wxid / file_path 非空,文件存在 2. 解析 display_name:显式传入 > 备注 > 昵称 > wxid 3. 检测登录态,非 logged_in 直接拒绝 4. 经 send_queue 串行执行:activate → Ctrl+F 搜索会话(按 display_name) → 选中并进入 → xclip 粘贴"[图片/文件] 文件名" → 回车发送 Args: req: SendFileRequest(to_wxid + file_path + 可选 display_name) is_image: True=图片,False=普通文件(仅影响提示文本前缀) Returns: SendResponse:success=true / local_send_id / placeholder=true (W-02 完成前为占位实现,仅发提示文本;完成后改为 placeholder=false) Raises: BridgeError(INVALID_PARAMS): to_wxid/file_path 为空或文件不存在(HTTP 400) BridgeError(WECHAT_NOT_LOGGED_IN): 当前未登录(HTTP 401) BridgeError(SEND_FAILED): xdotool/xclip 操作失败(HTTP 500) Notes: - file_path 必须是容器内绝对路径(不是宿主机路径) - MVP 阶段不传输真实文件内容,仅发提示文本;后续完善时需考虑 拖拽到聊天窗口 / Ctrl+V 粘贴文件 / 文件选择器对话框等方案 - local_send_id 不对应微信原生 msg_id,禁止用于 /api/media/{msg_id} """ xdotool = _require_xdotool() send_queue = _require_send_queue() if not req.to_wxid: raise BridgeError(code="INVALID_PARAMS", message="to_wxid 不能为空") if not req.file_path: raise BridgeError(code="INVALID_PARAMS", message="file_path 不能为空") if not os.path.isfile(req.file_path) or not os.access(req.file_path, os.R_OK): raise BridgeError( code="INVALID_PARAMS", message=f"文件不存在或不可读: {req.file_path}", ) # 检查登录态 login_state = await xdotool.detect_login_state() if login_state != "logged_in": raise BridgeError( code="WECHAT_NOT_LOGGED_IN", message=f"当前登录态为 {login_state},无法发送消息", ) # 解析显示名(优先备注/昵称,不再直接用 wxid 搜索) display_name = await _resolve_display_name( _state.db_reader, req.to_wxid, req.display_name ) try: local_send_id = await send_queue.enqueue( lambda: xdotool.send_file( req.to_wxid, req.file_path, is_image=is_image, display_name=display_name ) ) except BridgeError: raise except Exception as e: raise BridgeError( code="SEND_FAILED", message=f"发送失败: {e}", ) kind = "image" if is_image else "file" logger.info( "send/%s: to=%s display_name=%s file=%s → 成功 local_send_id=%s (placeholder)", kind, req.to_wxid, display_name, req.file_path, local_send_id, ) # W-02 完成前,图片/文件发送为占位实现(仅发提示文本) return SendResponse(success=True, local_send_id=local_send_id, placeholder=True, error=None) # --------------------------------------------------------------------------- # 路由:GET /api/media/avatar/{wxid} # --------------------------------------------------------------------------- # 注意:此路由必须在 /api/media/{msg_id} 之前声明,否则会被 {msg_id} # 匹配到(FastAPI 按声明顺序匹配,先匹配先得)。 @app.get("/api/media/avatar/{wxid}") async def get_avatar(wxid: str) -> Response: """获取联系人头像。 最小实现:优先从 contact.db 读取 big_head_url / small_head_url / avatar_url, 若存在 HTTP(S) 链接则拉取并透传二进制;否则返回 MEDIA_NOT_FOUND。 本地 .dat 头像缓存解密仍留给后续 W-04 完善。 Args: wxid: 路径参数,联系人 wxid Returns: Response:头像二进制内容 + Content-Type Raises: BridgeError(MEDIA_NOT_FOUND): 未找到联系人或无有效头像 URL(HTTP 404) BridgeError(DB_ENCRYPTED): DB 已加密,无法读取联系人表(HTTP 503) Notes: - 路由声明顺序敏感:必须在 /api/media/{msg_id} 之前,否则 FastAPI 会把 "avatar" 当作 msg_id 匹配 """ db_reader = _require_db_reader() # DB 不可读时统一返回 503,行为与 /api/media/{msg_id} 一致 await _check_db_readable() contact = await asyncio.to_thread(db_reader.get_contact_detail, wxid) url: Optional[str] = None if contact: url = ( contact.get("big_head_url") or contact.get("small_head_url") or contact.get("avatar_url") ) if not url or not url.startswith(("http://", "https://")): raise BridgeError( code="MEDIA_NOT_FOUND", message=f"未找到联系人头像 URL: {wxid}", ) try: data = await asyncio.to_thread(_fetch_avatar_url, url) except Exception as e: logger.warning("avatar: wxid=%s url=%s 拉取失败: %s", wxid, url, e) raise BridgeError( code="MEDIA_NOT_FOUND", message=f"头像下载失败: {wxid}", ) mime = mimetypes.guess_type(url)[0] or "image/jpeg" logger.info("avatar: wxid=%s url=%s → %d bytes (%s)", wxid, url, len(data), mime) return Response(content=data, media_type=mime) def _fetch_avatar_url(url: str) -> bytes: """同步拉取头像 URL 二进制内容。""" with urllib.request.urlopen(url, timeout=15) as resp: return resp.read() # --------------------------------------------------------------------------- # 路由:GET /api/media/{msg_id} # --------------------------------------------------------------------------- @app.get("/api/media/{msg_id}") async def get_media(msg_id: str) -> Response: """下载消息媒体文件(图片/语音/视频/文件)。 Args: msg_id: 路径参数,消息 ID(来自 /api/messages/since 返回的 msg_id) Returns: Response:二进制内容 + Content-Type(image/jpeg / audio/amr / video/mp4 / application/octet-stream 等) Raises: BridgeError(MEDIA_NOT_FOUND): 媒体文件未找到或解析失败(HTTP 404) BridgeError(DB_ENCRYPTED): DB 已加密,无法查 media 表(HTTP 503) BridgeError(DB_NOT_FOUND): 未找到微信消息 DB(HTTP 500) Notes: - 前置调 _check_db_readable(),DB 加密时直接返回 503 DB_ENCRYPTED, 不再走 sqlite3.DatabaseError 被兜底为 500 的旧路径 - DbReader.get_media_bytes 内部:DB 查 msg_id → 找对应 .dat 文件 → XOR 解密 → 返回 (data, mime) - 大文件可能阻塞较久,建议调用方设置合理的 timeout """ db_reader = _require_db_reader() # 前置 DB 可达性检查:加密时直接返回 503,避免 sqlite3 异常被兜底 500 await _check_db_readable() result = await asyncio.to_thread(db_reader.get_media_bytes, msg_id) if result is None: logger.info("media: msg_id=%s → 未找到", msg_id) raise BridgeError( code="MEDIA_NOT_FOUND", message=f"未找到媒体文件: {msg_id}", ) data, mime = result logger.info("media: msg_id=%s → %d bytes (%s)", msg_id, len(data), mime) return Response(content=data, media_type=mime) # --------------------------------------------------------------------------- # 路由:GET /api/diagnostic/connectivity # --------------------------------------------------------------------------- @app.get("/api/diagnostic/connectivity", response_model=ConnectivityResponse) async def diagnostic_connectivity() -> ConnectivityResponse: """bridge 连通性检查(channels ProbeableAdapter 调用)。 MVP 阶段恒返回 reachable=true / latency_ms=0 / error=null。 真实实现可加:内部 health ping / 依赖项检查 / 平均响应时延。 Returns: ConnectivityResponse:reachable / latency_ms / error """ return ConnectivityResponse(reachable=True, latency_ms=0, error=None) # --------------------------------------------------------------------------- # 路由:GET /api/diagnostic/items # --------------------------------------------------------------------------- @app.get("/api/diagnostic/items") async def diagnostic_items() -> dict: """返回诊断项定义列表。 前端「诊断」页面用本接口渲染可执行的检查项卡片,每张卡片含: - check_id(POST /api/diagnostic/run/{check_id} 的路径参数) - name(卡片标题)/ description(卡片副标题) - severity(critical/error/warn/info,决定卡片颜色与是否阻断) - auto_repairable(true 时显示「自动修复」按钮,调 autofix 接口) Returns: {"items": [DiagnosticItem, ...]}(共 4 项:wechat_running / login_state / db_accessible / xdotool_available) """ return { "items": [DiagnosticItem(**item) for item in DIAGNOSTIC_ITEMS], } # --------------------------------------------------------------------------- # 路由:POST /api/diagnostic/run/{check_id} # --------------------------------------------------------------------------- @app.post("/api/diagnostic/run/{check_id}", response_model=DiagnosticRunResult) async def diagnostic_run(check_id: str) -> DiagnosticRunResult: """执行单个诊断项检查(channels DoctorAdapter 调用)。 支持的 check_id: - wechat_running:pgrep -x wechat 检测进程,auto_repairable=true - login_state:detect_login_state(),区分 not_running/not_logged_in/logged_in - db_accessible:check_db_status() 区分 ok/not_found/encrypted/unreadable, encrypted 时返回 repair_plan 提示独立专项解决 - xdotool_available:shutil.which('xdotool') 检测可执行文件 Args: check_id: 路径参数,诊断项 ID(见 /api/diagnostic/items 返回) Returns: DiagnosticRunResult:含 passed / severity / message / auto_repairable / repair_plan(仅 db_accessible=encrypted/unreadable 时非空) Raises: BridgeError(INVALID_PARAMS): 未知 check_id(HTTP 400) Notes: - 不抛业务错误的诊断项:即使 passed=false 也返回 200 + 详细 message, 让调用方按字段判定后续动作(如显示修复建议、调 autofix) - repair_plan 字段仅在 db_accessible 失败时给出建议,其他项为 None """ item = _DIAGNOSTIC_ITEM_BY_ID.get(check_id) if item is None: raise BridgeError( code="INVALID_PARAMS", message=f"未知诊断项: {check_id}", ) xdotool = _require_xdotool() db_reader = _require_db_reader() if check_id == "wechat_running": passed = await xdotool.is_wechat_running() message = "微信进程运行中" if passed else "微信进程未运行" elif check_id == "login_state": state = await xdotool.detect_login_state() passed = state == "logged_in" message = f"登录态: {state}" elif check_id == "db_accessible": # 统一走 _resolve_db_state(含 salt/key/pid/验证逻辑) db_state = await _resolve_db_state() if db_state.db_accessible: source = _state.key_cache.source or "unknown" msg = "微信 DB 可读" if db_state.status == "ok" \ else f"DB 加密但已具备解密能力(key 来源: {source})" return DiagnosticRunResult( check_id=check_id, passed=True, severity=item["severity"], message=msg, auto_repairable=item["auto_repairable"], repair_plan=None, ) if db_state.status == "not_found": return DiagnosticRunResult( check_id=check_id, passed=False, severity=item["severity"], message="未在 /config 下找到微信消息 DB", auto_repairable=item["auto_repairable"], repair_plan=None, ) if db_state.status == "unreadable": return DiagnosticRunResult( check_id=check_id, passed=False, severity=item["severity"], message="DB 文件存在但不可读(权限问题)", auto_repairable=item["auto_repairable"], repair_plan="检查 /config/xwechat_files 下 DB 文件权限", ) # need_init / key_invalid 且无有效 key return DiagnosticRunResult( check_id=check_id, passed=False, severity=item["severity"], message="DB 已加密(SQLCipher),需密钥才能读取", auto_repairable=item["auto_repairable"], repair_plan="通过 POST /api/db/decrypt 注入 64 位 hex 密钥,或设置 WOC_DB_KEY 环境变量,或启用 WOC_DB_KEY_AUTO_EXTRACT=true 自动提取", ) elif check_id == "xdotool_available": path = shutil.which("xdotool") passed = path is not None message = f"xdotool 路径: {path}" if passed else "xdotool 不可用" else: # 理论不可达 raise BridgeError( code="INVALID_PARAMS", message=f"未知诊断项: {check_id}", ) return DiagnosticRunResult( check_id=check_id, passed=passed, severity=item["severity"], message=message, auto_repairable=item["auto_repairable"], repair_plan=None, ) # --------------------------------------------------------------------------- # 路由:POST /api/diagnostic/autofix/{check_id} # --------------------------------------------------------------------------- @app.post("/api/diagnostic/autofix/{check_id}", response_model=DiagnosticRunResult) async def diagnostic_autofix(check_id: str) -> DiagnosticRunResult: """尝试自动修复诊断项(channels DoctorAdapter 调用)。 仅 auto_repairable=true 的项可调,否则抛 INVALID_PARAMS。 当前可修复项: - wechat_running:pkill -x wechat 杀进程 → 等 2s 检查 autostart 是否拉起 → 未拉起则 bridge 显式启动(start_wechat)作为兜底 - xdotool_available:不可修复,返回 passed=false + "请重建容器" (xdotool 缺失说明容器构建异常,bridge 内无法修复,只能重建) - db_accessible:加密 DB 时尝试自动提取 key(P1 能力),失败返回 repair_plan Args: check_id: 路径参数,诊断项 ID Returns: DiagnosticRunResult:含 passed / message(描述修复结果或失败原因) Raises: BridgeError(INVALID_PARAMS): 未知 check_id 或该项不可自动修复(HTTP 400) Notes: - wechat_running 修复流程: 1. pkill 杀旧进程 2. 等 2s 让 autostart watchdog 拉起 3. 若 autostart 已拉起 → 返回 passed=true + 新 PID 4. 若 autostart 未拉起 → bridge 显式启动(start_wechat) 5. 显式启动成功 → passed=true + 新 PID 6. 显式启动失败 → passed=false + repair_plan 提示 - 调用方收到 passed=true 后应间隔 3-5s 调 /api/diagnostic/run/wechat_running 确认实际状态(进程可能在初始化中,窗口尚未出现) """ item = _DIAGNOSTIC_ITEM_BY_ID.get(check_id) if item is None: raise BridgeError( code="INVALID_PARAMS", message=f"未知诊断项: {check_id}", ) if not item["auto_repairable"]: raise BridgeError( code="INVALID_PARAMS", message="该项不可自动修复", ) if check_id == "wechat_running": xdotool = _require_xdotool() # 1. pkill 旧进程(不强制 SIGKILL,给微信优雅退出机会) try: proc = await asyncio.create_subprocess_exec( "pkill", "-x", "wechat", stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) await proc.wait() except Exception: pass # 2. 等 2s 让 autostart watchdog 拉起 await asyncio.sleep(2) # 3. 检查 autostart 是否已拉起 new_pid = await xdotool.check_wechat_pid() # 4. autostart 未拉起,bridge 显式启动兜底 if new_pid is None: new_pid = await xdotool.start_wechat(timeout_sec=10) # 5. 返回结果 if new_pid is not None: return DiagnosticRunResult( check_id=check_id, passed=True, severity=item["severity"], message=f"微信进程已恢复,PID={new_pid}", auto_repairable=True, repair_plan=None, ) return DiagnosticRunResult( check_id=check_id, passed=False, severity=item["severity"], message="autostart 未拉起且 bridge 显式启动失败", auto_repairable=True, repair_plan="检查 X 会话是否正常(Xvnc/openbox),或通过 VNC 桌面手动启动微信", ) elif check_id == "db_accessible": db_reader = _require_db_reader() # 检查 DB 状态 status = await asyncio.to_thread(db_reader.check_db_status) if status == "ok": return DiagnosticRunResult( check_id=check_id, passed=True, severity=item["severity"], message="DB 可读,无需修复", auto_repairable=item["auto_repairable"], repair_plan=None, ) if status in ("need_init", "key_invalid"): # 尝试自动提取 key(阻塞等待结果,复用公共加锁路径) extracted = await _auto_extract_with_lock() if extracted: return DiagnosticRunResult( check_id=check_id, passed=True, severity=item["severity"], message="自动提取 DB 密钥成功,DB 已可解密读取", auto_repairable=item["auto_repairable"], repair_plan=None, ) else: return DiagnosticRunResult( check_id=check_id, passed=False, severity=item["severity"], message="自动提取 DB 密钥失败", auto_repairable=item["auto_repairable"], repair_plan="通过 POST /api/db/decrypt 手动注入 64 位 hex 密钥,或设置 WOC_DB_KEY 环境变量", ) # not_found / unreadable 不可自动修复 return DiagnosticRunResult( check_id=check_id, passed=False, severity=item["severity"], message=f"DB 状态: {status},无法自动修复", auto_repairable=item["auto_repairable"], repair_plan="检查 /config/xwechat_files 下 DB 文件是否存在且权限正确", ) elif check_id == "xdotool_available": return DiagnosticRunResult( check_id=check_id, passed=False, severity=item["severity"], message="xdotool 不可用,请重建容器", auto_repairable=True, repair_plan=None, ) # 理论不可达 raise BridgeError( code="INVALID_PARAMS", message=f"未知诊断项: {check_id}", ) # --------------------------------------------------------------------------- # 路由:POST /api/screenshot # --------------------------------------------------------------------------- @app.post("/api/screenshot") async def screenshot() -> Response: """截取完整屏幕并返回 PNG。 用于前端「截图」按钮 / 调试时观察微信实际界面状态(如确认 UI 操作是否 触发了正确的对话框)。截取整个 X 桌面(DISPLAY 由 _state.config.display 指定)。 Returns: Response:Content-Type=image/png,body 为 PNG 二进制 Raises: BridgeError(WINDOW_NOT_FOUND): X 会话未就绪,无法截图(HTTP 503) 其他异常由全局兜底处理器返回 BRIDGE_INTERNAL_ERROR Notes: - 截图由 qr_capture.capture_full_screenshot 完成,底层调 xdotool/getwindowgeometry + import(ImageMagick) - 与 /api/login/qr/start 区别:本接口截全屏,qr/start 截二维码区域 - 大屏幕截图可能 > 1MB,调用方注意带宽 """ qr_capture = _require_qr_capture() png_bytes = await qr_capture.capture_full_screenshot() logger.info("screenshot: → 截图成功 %d bytes", len(png_bytes)) return Response(content=png_bytes, media_type="image/png") # --------------------------------------------------------------------------- # 命令行入口 # --------------------------------------------------------------------------- def _parse_args() -> argparse.Namespace: """解析命令行参数。""" parser = argparse.ArgumentParser(description="woc-bridge FastAPI 服务") parser.add_argument( "--listen", default="0.0.0.0:8088", help="监听地址,格式 host:port,默认 0.0.0.0:8088", ) parser.add_argument( "--wechat-db", default="/config", help="微信数据根目录,默认 /config(_find_db_path 会递归查找 message_0.db)", ) parser.add_argument( "--display", default=":1", help="X server display,默认 :1", ) return parser.parse_args() 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 = SendQueue( send_delay_ms=cfg.send_delay_ms, max_calls_per_sec=cfg.max_calls_per_sec, ) # 读取 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 密钥(未验证,首次查询时验证)") if __name__ == "__main__": args = _parse_args() cfg = BridgeConfig.from_args_and_env(args) _init_state(cfg) host, _, port_str = cfg.listen.rpartition(":") if not host: host = "0.0.0.0" port = int(port_str) if port_str else 8088 uvicorn.run(app, host=host, port=port)