WechatOnCloud/bridge/server.py
Kris a2201d8be3 feat: 新增woc-bridge服务及配套能力
新增完整的woc-bridge业务服务,实现微信API代理、密钥自动提取、消息队列限流等功能:
1. 新增bridge核心代码与s6服务配置
2. 新增ptrace权限初始化脚本与docker配置
3. 新增M2M鉴权API代理与环境变量配置
4. 新增SQLCipher解密、密钥缓存、二维码截图等工具模块
5. 完善docker构建与实例部署配置
2026-07-07 19:04:13 +08:00

2394 lines
93 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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.0P0 接口闭环(消息/联系人/群/发送/登录/重启/诊断基础能力)
- 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.0KeyCache 统一密钥来源_resolve_db_state 统一 DB 状态解析;
修复 env/api key 验证失败误清持久化 key修复 _init_task start_time 硬编码
- 1.4.0
- _check_db_readable 改为非阻塞:触发后台提取后立即返回 503不再卡住请求
- 提取公共 _auto_extract_with_lockdiagnostic_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
# 模块级 loggers6/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.0KeyCache 统一密钥来源_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_decryptSQLCipher DB 解密)。
# 待落地能力(占位声明,未真正实现):
# - image_sendW-02 完成后追加
# - file_sendW-02 完成后追加
# - long_pollW-03 完成后追加
# - avatarW-04 完成后追加
# - group_adminW-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_statelifespan 直接用
2. `uvicorn server:app`__main__ 未执行lifespan 内兜底初始化
启动 send_queueyield停止 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 若未验证,快速验证 page14KB避免无效 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
# 有有效 keyDB 可读
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_moderaw_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/statuskey 缓存状态查询)
# ---------------------------------------------------------------------------
@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): 未找到微信消息 DBHTTP 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_namestrip 后非空才用)
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: SendTextRequestto_wxid + content + 可选 display_name
Returns:
SendResponsesuccess=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_urldata: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 < 1HTTP 400
Notes:
- 微信进程未运行not_running时立即返回不继续等待继续等无意义
- 登录成功时尝试从 DB 读 wxid/nicknameDB 不可达时返回空字符串
credentials 不为 None但 wxid 为空)
- 超时返回 connected=falsemessage="等待扫码超时",调用方应重新调
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:
LogoutResponsesuccess=true / message"已退出登录""当前未登录,无需退出"
Raises:
BridgeError(LOGOUT_FAILED): 窗口未找到或 UI 操作失败HTTP 500
Notes:
- 幂等not_running / not_logged_in 状态下都返回 success=true
- logout() 的 UI 坐标/方向键次数为估算值spec 已记录需实测调优
- 退出后微信进程仍在运行(只是登录态变 not_logged_inautostart
不会拉起新进程;如需重新登录走 /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:
RestartResponsesuccess=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_inautostart 拉起后微信会自动尝试恢复登录)
"""
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): 未找到微信消息 DBHTTP 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): 未找到微信消息 DBHTTP 500
BridgeError(DB_ENCRYPTED): DB 已加密HTTP 503
BridgeError(CONTACT_NOT_FOUND): 指定 wxid 不存在HTTP 404
Notes:
- 不存在的 wxid 返回 CONTACT_NOT_FOUND404与 DB 不可达错误区分
- 群聊 usernamexxx@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): 未找到微信消息 DBHTTP 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): 未找到微信消息 DBHTTP 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: SendFileRequestto_wxid + file_path + 可选 display_name
Returns:
SendResponsesuccess=true / local_send_id / placeholder=true
Raises:
同 _send_fileINVALID_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: SendFileRequestto_wxid + file_path + 可选 display_name
Returns:
SendResponsesuccess=true / local_send_id / placeholder=true
Raises:
同 _send_fileINVALID_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: SendFileRequestto_wxid + file_path + 可选 display_name
is_image: True=图片False=普通文件(仅影响提示文本前缀)
Returns:
SendResponsesuccess=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): 未找到联系人或无有效头像 URLHTTP 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-Typeimage/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): 未找到微信消息 DBHTTP 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:
ConnectivityResponsereachable / 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_idPOST /api/diagnostic/run/{check_id} 的路径参数)
- name卡片标题/ description卡片副标题
- severitycritical/error/warn/info决定卡片颜色与是否阻断
- auto_repairabletrue 时显示「自动修复」按钮,调 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_runningpgrep -x wechat 检测进程auto_repairable=true
- login_statedetect_login_state(),区分 not_running/not_logged_in/logged_in
- db_accessiblecheck_db_status() 区分 ok/not_found/encrypted/unreadable
encrypted 时返回 repair_plan 提示独立专项解决
- xdotool_availableshutil.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_idHTTP 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_runningpkill -x wechat 杀进程 → 等 2s 检查 autostart 是否拉起
→ 未拉起则 bridge 显式启动start_wechat作为兜底
- xdotool_available不可修复返回 passed=false + "请重建容器"
xdotool 缺失说明容器构建异常bridge 内无法修复,只能重建)
- db_accessible加密 DB 时尝试自动提取 keyP1 能力),失败返回 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:
ResponseContent-Type=image/pngbody 为 PNG 二进制
Raises:
BridgeError(WINDOW_NOT_FOUND): X 会话未就绪无法截图HTTP 503
其他异常由全局兜底处理器返回 BRIDGE_INTERNAL_ERROR
Notes:
- 截图由 qr_capture.capture_full_screenshot 完成,底层调
xdotool/getwindowgeometry + importImageMagick
- 与 /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)