WechatOnCloud/bridge/woc_bridge/routes/login.py

244 lines
9.6 KiB
Python
Raw Normal View History

from __future__ import annotations
import asyncio
import logging
import time
from fastapi import APIRouter
from woc_bridge.config import (
_require_xdotool,
_require_qr_capture,
_require_db_reader,
)
from woc_bridge.db.coordinator import with_db_retry
from woc_bridge.models import (
QrLoginStartResult,
QrLoginWaitResult,
LogoutResponse,
RestartResponse,
BridgeError,
LoginState,
)
logger = logging.getLogger("woc-bridge")
router = APIRouter()
@router.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
# ---------------------------------------------------------------------------
@router.get("/api/login/qr/wait", response_model=QrLoginWaitResult)
@with_db_retry
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
# ---------------------------------------------------------------------------
@router.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
# ---------------------------------------------------------------------------
@router.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)