新增完整的woc-bridge业务服务,实现微信API代理、密钥自动提取、消息队列限流等功能: 1. 新增bridge核心代码与s6服务配置 2. 新增ptrace权限初始化脚本与docker配置 3. 新增M2M鉴权API代理与环境变量配置 4. 新增SQLCipher解密、密钥缓存、二维码截图等工具模块 5. 完善docker构建与实例部署配置
2394 lines
93 KiB
Python
2394 lines
93 KiB
Python
"""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/<pid>/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/<pid>/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_<unix秒>_<随机>)
|
||
/ 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)
|