feat: add WeChat QR code login and AGP WebSocket channel plugin
- Auth module: WeChat OAuth2 scan-to-login flow with terminal QR code - Token persistence to ~/.openclaw/wechat-access-auth.json (chmod 600) - Token resolution: config > saved state > interactive login - Invite code verification (configurable bypass) - Production/test environment support - AGP WebSocket client with heartbeat, reconnect, wake detection - Message handler: Agent dispatch with streaming text and tool calls - Random device GUID generation (persisted, no real machine ID)
This commit is contained in:
43
auth/device-guid.ts
Normal file
43
auth/device-guid.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @file device-guid.ts
|
||||
* @description 设备唯一标识生成
|
||||
*
|
||||
* 不使用真实机器码,而是首次运行时随机生成一个 GUID 并持久化到本地文件。
|
||||
* 后续启动自动加载,保证同一台机器上 GUID 稳定不变。
|
||||
*/
|
||||
|
||||
import { randomUUID, createHash } from "node:crypto";
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
const GUID_FILE = join(homedir(), ".openclaw", "wechat-access-guid");
|
||||
|
||||
/**
|
||||
* 获取设备唯一标识
|
||||
*
|
||||
* 首次运行时随机生成一个 MD5 格式的 GUID 并保存到 ~/.openclaw/wechat-access-guid,
|
||||
* 后续启动直接从文件读取,确保稳定。
|
||||
*/
|
||||
export const getDeviceGuid = (): string => {
|
||||
// 尝试从文件加载已有 GUID
|
||||
try {
|
||||
const existing = readFileSync(GUID_FILE, "utf-8").trim();
|
||||
if (existing) return existing;
|
||||
} catch {
|
||||
// 文件不存在或读取失败,继续生成
|
||||
}
|
||||
|
||||
// 首次运行:生成随机 GUID(MD5 hex 格式,32 字符)
|
||||
const guid = createHash("md5").update(randomUUID()).digest("hex");
|
||||
|
||||
// 持久化
|
||||
try {
|
||||
mkdirSync(dirname(GUID_FILE), { recursive: true });
|
||||
writeFileSync(GUID_FILE, guid, "utf-8");
|
||||
} catch {
|
||||
// 写入失败不致命,本次仍返回生成的 GUID
|
||||
}
|
||||
|
||||
return guid;
|
||||
};
|
||||
29
auth/environments.ts
Normal file
29
auth/environments.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @file environments.ts
|
||||
* @description QClaw 环境配置(生产/测试)
|
||||
*/
|
||||
|
||||
import type { QClawEnvironment } from "./types.js";
|
||||
|
||||
const ENVIRONMENTS: Record<string, QClawEnvironment> = {
|
||||
production: {
|
||||
jprxGateway: "https://jprx.m.qq.com/",
|
||||
qclawBaseUrl: "https://mmgrcalltoken.3g.qq.com/aizone/v1",
|
||||
wxLoginRedirectUri: "https://security.guanjia.qq.com/login",
|
||||
wechatWsUrl: "wss://mmgrcalltoken.3g.qq.com/agentwss",
|
||||
wxAppId: "wx9d11056dd75b7240",
|
||||
},
|
||||
test: {
|
||||
jprxGateway: "https://jprx.sparta.html5.qq.com/",
|
||||
qclawBaseUrl: "https://jprx.sparta.html5.qq.com/aizone/v1",
|
||||
wxLoginRedirectUri: "https://security-test.guanjia.qq.com/login",
|
||||
wechatWsUrl: "wss://jprx.sparta.html5.qq.com/agentwss",
|
||||
wxAppId: "wx3dd49afb7e2cf957",
|
||||
},
|
||||
};
|
||||
|
||||
export const getEnvironment = (name: string): QClawEnvironment => {
|
||||
const env = ENVIRONMENTS[name];
|
||||
if (!env) throw new Error(`未知环境: ${name},可选: ${Object.keys(ENVIRONMENTS).join(", ")}`);
|
||||
return env;
|
||||
};
|
||||
19
auth/index.ts
Normal file
19
auth/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @file auth/index.ts
|
||||
* @description 认证模块导出
|
||||
*/
|
||||
|
||||
export type {
|
||||
QClawEnvironment,
|
||||
LoginCredentials,
|
||||
PersistedAuthState,
|
||||
QClawApiResponse,
|
||||
} from "./types.js";
|
||||
export { TokenExpiredError } from "./types.js";
|
||||
|
||||
export { getEnvironment } from "./environments.js";
|
||||
export { getDeviceGuid } from "./device-guid.js";
|
||||
export { QClawAPI } from "./qclaw-api.js";
|
||||
export { loadState, saveState, clearState } from "./state-store.js";
|
||||
export { performLogin } from "./wechat-login.js";
|
||||
export type { PerformLoginOptions } from "./wechat-login.js";
|
||||
129
auth/qclaw-api.ts
Normal file
129
auth/qclaw-api.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @file qclaw-api.ts
|
||||
* @description QClaw JPRX 网关 API 客户端
|
||||
*
|
||||
* 对应 Python demo 的 QClawAPI 类,所有业务接口走 POST {jprxGateway}data/{cmdId}/forward。
|
||||
*/
|
||||
|
||||
import type { QClawEnvironment, QClawApiResponse } from "./types.js";
|
||||
import { TokenExpiredError } from "./types.js";
|
||||
import { nested } from "./utils.js";
|
||||
|
||||
export class QClawAPI {
|
||||
private env: QClawEnvironment;
|
||||
private guid: string;
|
||||
|
||||
/** 鉴权 key,登录后可由服务端返回新值 */
|
||||
loginKey = "m83qdao0AmE5";
|
||||
|
||||
jwtToken: string;
|
||||
userId = "";
|
||||
|
||||
constructor(env: QClawEnvironment, guid: string, jwtToken = "") {
|
||||
this.env = env;
|
||||
this.guid = guid;
|
||||
this.jwtToken = jwtToken;
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
const h: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Version": "1",
|
||||
"X-Token": this.loginKey,
|
||||
"X-Guid": this.guid,
|
||||
"X-Account": this.userId || "1",
|
||||
"X-Session": "",
|
||||
};
|
||||
if (this.jwtToken) {
|
||||
h["X-OpenClaw-Token"] = this.jwtToken;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
private async post(path: string, body: Record<string, unknown> = {}): Promise<QClawApiResponse> {
|
||||
const url = `${this.env.jprxGateway}${path}`;
|
||||
const payload = { ...body, web_version: "1.4.0", web_env: "release" };
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
|
||||
// Token 续期
|
||||
const newToken = res.headers.get("X-New-Token");
|
||||
if (newToken) this.jwtToken = newToken;
|
||||
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
|
||||
const ret = data.ret;
|
||||
const commonCode =
|
||||
nested(data, "data", "resp", "common", "code") ??
|
||||
nested(data, "data", "common", "code") ??
|
||||
nested(data, "resp", "common", "code") ??
|
||||
nested(data, "common", "code");
|
||||
|
||||
// Token 过期
|
||||
if (commonCode === 21004) {
|
||||
throw new TokenExpiredError();
|
||||
}
|
||||
|
||||
if (ret === 0 || commonCode === 0) {
|
||||
const respData =
|
||||
nested(data, "data", "resp", "data") ??
|
||||
nested(data, "data", "data") ??
|
||||
data.data ??
|
||||
data;
|
||||
return { success: true, data: respData as Record<string, unknown> };
|
||||
}
|
||||
|
||||
const message =
|
||||
(nested(data, "data", "common", "message") as string) ??
|
||||
(nested(data, "resp", "common", "message") as string) ??
|
||||
(nested(data, "common", "message") as string) ??
|
||||
"请求失败";
|
||||
return { success: false, message, data: data as Record<string, unknown> };
|
||||
}
|
||||
|
||||
// ---------- 业务 API ----------
|
||||
|
||||
/** 获取微信登录 state(OAuth2 安全校验) */
|
||||
async getWxLoginState(): Promise<QClawApiResponse> {
|
||||
return this.post("data/4050/forward", { guid: this.guid });
|
||||
}
|
||||
|
||||
/** 用微信授权 code 换取 token */
|
||||
async wxLogin(code: string, state: string): Promise<QClawApiResponse> {
|
||||
return this.post("data/4026/forward", { guid: this.guid, code, state });
|
||||
}
|
||||
|
||||
/** 创建模型 API Key */
|
||||
async createApiKey(): Promise<QClawApiResponse> {
|
||||
return this.post("data/4055/forward", {});
|
||||
}
|
||||
|
||||
/** 获取用户信息 */
|
||||
async getUserInfo(): Promise<QClawApiResponse> {
|
||||
return this.post("data/4027/forward", {});
|
||||
}
|
||||
|
||||
/** 检查邀请码验证状态 */
|
||||
async checkInviteCode(userId: string): Promise<QClawApiResponse> {
|
||||
return this.post("data/4056/forward", { user_id: userId });
|
||||
}
|
||||
|
||||
/** 提交邀请码 */
|
||||
async submitInviteCode(userId: string, code: string): Promise<QClawApiResponse> {
|
||||
return this.post("data/4057/forward", { user_id: userId, code });
|
||||
}
|
||||
|
||||
/** 刷新渠道 token */
|
||||
async refreshChannelToken(): Promise<string | null> {
|
||||
const result = await this.post("data/4058/forward", {});
|
||||
if (result.success) {
|
||||
return (result.data as Record<string, unknown>)?.openclaw_channel_token as string ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
48
auth/state-store.ts
Normal file
48
auth/state-store.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @file state-store.ts
|
||||
* @description 登录态持久化存储
|
||||
*
|
||||
* 将 token 保存到本地文件,下次启动时自动加载,避免重复扫码。
|
||||
* 文件权限设为 0o600,仅当前用户可读写。
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, chmodSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import type { PersistedAuthState } from "./types.js";
|
||||
|
||||
const DEFAULT_STATE_PATH = join(homedir(), ".openclaw", "wechat-access-auth.json");
|
||||
|
||||
export const getStatePath = (customPath?: string): string =>
|
||||
customPath || DEFAULT_STATE_PATH;
|
||||
|
||||
export const loadState = (customPath?: string): PersistedAuthState | null => {
|
||||
const filePath = getStatePath(customPath);
|
||||
try {
|
||||
const raw = readFileSync(filePath, "utf-8");
|
||||
return JSON.parse(raw) as PersistedAuthState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const saveState = (state: PersistedAuthState, customPath?: string): void => {
|
||||
const filePath = getStatePath(customPath);
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, JSON.stringify(state, null, 2), { encoding: "utf-8", mode: 0o600 });
|
||||
// 确保已有文件也收紧权限
|
||||
try {
|
||||
chmodSync(filePath, 0o600);
|
||||
} catch {
|
||||
// Windows 等平台可能不支持 chmod,忽略
|
||||
}
|
||||
};
|
||||
|
||||
export const clearState = (customPath?: string): void => {
|
||||
const filePath = getStatePath(customPath);
|
||||
try {
|
||||
unlinkSync(filePath);
|
||||
} catch {
|
||||
// file not found — ignore
|
||||
}
|
||||
};
|
||||
57
auth/types.ts
Normal file
57
auth/types.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @file types.ts
|
||||
* @description 微信扫码登录相关类型定义
|
||||
*/
|
||||
|
||||
/** QClaw 环境配置 */
|
||||
export interface QClawEnvironment {
|
||||
/** JPRX 网关地址 */
|
||||
jprxGateway: string;
|
||||
/** QClaw 基础 URL (未直接使用,走 JPRX 网关) */
|
||||
qclawBaseUrl: string;
|
||||
/** 微信登录回调地址 */
|
||||
wxLoginRedirectUri: string;
|
||||
/** WebSocket 网关地址 */
|
||||
wechatWsUrl: string;
|
||||
/** 微信开放平台 AppID */
|
||||
wxAppId: string;
|
||||
}
|
||||
|
||||
/** 登录凭证 */
|
||||
export interface LoginCredentials {
|
||||
/** JWT Token (用于 API 鉴权) */
|
||||
jwtToken: string;
|
||||
/** Channel Token (用于 WebSocket 连接) */
|
||||
channelToken: string;
|
||||
/** 用户信息 */
|
||||
userInfo: Record<string, unknown>;
|
||||
/** API Key (用于调用模型) */
|
||||
apiKey: string;
|
||||
/** 设备 GUID */
|
||||
guid: string;
|
||||
}
|
||||
|
||||
/** 持久化的登录态 */
|
||||
export interface PersistedAuthState {
|
||||
jwtToken: string;
|
||||
channelToken: string;
|
||||
apiKey: string;
|
||||
guid: string;
|
||||
userInfo: Record<string, unknown>;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
/** QClaw API 通用响应 */
|
||||
export interface QClawApiResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** Token 过期错误 */
|
||||
export class TokenExpiredError extends Error {
|
||||
constructor(message = "登录已过期,请重新登录") {
|
||||
super(message);
|
||||
this.name = "TokenExpiredError";
|
||||
}
|
||||
}
|
||||
14
auth/utils.ts
Normal file
14
auth/utils.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* @file utils.ts
|
||||
* @description 认证模块共享工具函数
|
||||
*/
|
||||
|
||||
/** 安全嵌套取值 */
|
||||
export const nested = (obj: unknown, ...keys: string[]): unknown => {
|
||||
let current = obj;
|
||||
for (const key of keys) {
|
||||
if (current == null || typeof current !== "object") return undefined;
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return current;
|
||||
};
|
||||
238
auth/wechat-login.ts
Normal file
238
auth/wechat-login.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* @file wechat-login.ts
|
||||
* @description 微信扫码登录流程编排
|
||||
*
|
||||
* 对应 Python demo 的 WeChatLogin 类和 do_login 函数。
|
||||
* 流程:获取 state → 生成二维码 → 等待 code → 换 token → (邀请码) → 保存
|
||||
*/
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import type { QClawEnvironment, LoginCredentials, PersistedAuthState } from "./types.js";
|
||||
import { QClawAPI } from "./qclaw-api.js";
|
||||
import { saveState } from "./state-store.js";
|
||||
import { nested } from "./utils.js";
|
||||
|
||||
/** 构造微信 OAuth2 授权 URL */
|
||||
const buildAuthUrl = (state: string, env: QClawEnvironment): string => {
|
||||
const params = new URLSearchParams({
|
||||
appid: env.wxAppId,
|
||||
redirect_uri: env.wxLoginRedirectUri,
|
||||
response_type: "code",
|
||||
scope: "snsapi_login",
|
||||
state,
|
||||
});
|
||||
return `https://open.weixin.qq.com/connect/qrconnect?${params.toString()}#wechat_redirect`;
|
||||
};
|
||||
|
||||
/** 在终端显示二维码 */
|
||||
const displayQrCode = async (url: string): Promise<void> => {
|
||||
console.log("\n" + "=".repeat(60));
|
||||
console.log(" 请用微信扫描下方二维码登录");
|
||||
console.log("=".repeat(60));
|
||||
|
||||
try {
|
||||
// qrcode-terminal 是 CJS 模块,动态 import
|
||||
const qrterm = await import("qrcode-terminal");
|
||||
const generate = qrterm.default?.generate ?? qrterm.generate;
|
||||
generate(url, { small: true }, (qrcode: string) => {
|
||||
console.log(qrcode);
|
||||
});
|
||||
} catch {
|
||||
console.log("\n(未安装 qrcode-terminal,无法在终端显示二维码)");
|
||||
console.log("请安装: npm install qrcode-terminal");
|
||||
}
|
||||
|
||||
console.log("\n或者在浏览器中打开以下链接:");
|
||||
console.log(` ${url}`);
|
||||
console.log("=".repeat(60));
|
||||
};
|
||||
|
||||
/** 从 stdin 读取一行 */
|
||||
const readLine = (prompt: string): Promise<string> => {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 等待用户输入微信授权后重定向 URL 中的 code
|
||||
*
|
||||
* 接受两种输入:
|
||||
* 1. 完整 URL(自动从 query string 或 fragment 提取 code)
|
||||
* 2. 裸 code 字符串
|
||||
*/
|
||||
const waitForAuthCode = async (): Promise<string> => {
|
||||
console.log();
|
||||
console.log("微信扫码授权后,浏览器会跳转到一个新页面。");
|
||||
console.log("请从浏览器地址栏复制完整 URL,或只复制 code 参数值。");
|
||||
console.log();
|
||||
|
||||
const raw = await readLine("请粘贴 URL 或 code: ");
|
||||
if (!raw) return "";
|
||||
|
||||
// 尝试从 URL 中提取 code
|
||||
if (raw.includes("code=")) {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
// 先查 query string
|
||||
const code = url.searchParams.get("code");
|
||||
if (code) return code;
|
||||
// 再查 fragment(微信可能将 code 放在 hash 后面)
|
||||
if (url.hash) {
|
||||
const fragmentParams = new URLSearchParams(url.hash.replace(/^#/, ""));
|
||||
const fCode = fragmentParams.get("code");
|
||||
if (fCode) return fCode;
|
||||
}
|
||||
} catch {
|
||||
// URL 解析失败,尝试正则
|
||||
}
|
||||
const match = raw.match(/[?&#]code=([^&#]+)/);
|
||||
if (match?.[1]) return match[1];
|
||||
}
|
||||
|
||||
// 直接就是 code
|
||||
return raw;
|
||||
};
|
||||
|
||||
export interface PerformLoginOptions {
|
||||
guid: string;
|
||||
env: QClawEnvironment;
|
||||
bypassInvite?: boolean;
|
||||
/** 自定义 state 文件路径 */
|
||||
authStatePath?: string;
|
||||
/** 日志函数 */
|
||||
log?: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行完整的微信扫码登录流程
|
||||
*
|
||||
* 步骤:
|
||||
* 1. 获取 OAuth state
|
||||
* 2. 生成二维码并展示
|
||||
* 3. 等待用户输入 code
|
||||
* 4. 用 code 换 token
|
||||
* 5. 创建 API Key(非致命)
|
||||
* 6. 邀请码检查(可绕过)
|
||||
* 7. 保存登录态
|
||||
*/
|
||||
export const performLogin = async (options: PerformLoginOptions): Promise<LoginCredentials> => {
|
||||
const { guid, env, bypassInvite = false, authStatePath, log } = options;
|
||||
const info = (...args: unknown[]) => log?.info?.(...args) ?? console.log(...args);
|
||||
const warn = (...args: unknown[]) => log?.warn?.(...args) ?? console.warn(...args);
|
||||
|
||||
const api = new QClawAPI(env, guid);
|
||||
|
||||
// 1. 获取 OAuth state
|
||||
info("[Login] 步骤 1/5: 获取登录 state...");
|
||||
let state = String(Math.floor(Math.random() * 10000)); // 随机兜底
|
||||
const stateResult = await api.getWxLoginState();
|
||||
if (stateResult.success) {
|
||||
const s = nested(stateResult.data, "state") as string | undefined;
|
||||
if (s) state = s;
|
||||
}
|
||||
info(`[Login] state=${state}`);
|
||||
|
||||
// 2. 生成二维码
|
||||
info("[Login] 步骤 2/5: 生成微信登录二维码...");
|
||||
const authUrl = buildAuthUrl(state, env);
|
||||
await displayQrCode(authUrl);
|
||||
|
||||
// 3. 等待 code
|
||||
info("[Login] 步骤 3/5: 等待微信扫码授权...");
|
||||
const code = await waitForAuthCode();
|
||||
if (!code) {
|
||||
throw new Error("未获取到授权 code");
|
||||
}
|
||||
|
||||
// 4. 用 code 换 token
|
||||
info(`[Login] 步骤 4/5: 用授权码登录 (code=${code.substring(0, 10)}...)`);
|
||||
const loginResult = await api.wxLogin(code, state);
|
||||
if (!loginResult.success) {
|
||||
throw new Error(`登录失败: ${loginResult.message ?? "未知错误"}`);
|
||||
}
|
||||
|
||||
const loginData = loginResult.data as Record<string, unknown>;
|
||||
const jwtToken = (loginData.token as string) || "";
|
||||
const channelToken = (loginData.openclaw_channel_token as string) || "";
|
||||
const userInfo = (loginData.user_info as Record<string, unknown>) || {};
|
||||
|
||||
api.jwtToken = jwtToken;
|
||||
api.userId = String(userInfo.user_id ?? "");
|
||||
// 更新 loginKey(服务端可能返回新值,后续 API 调用需要使用)
|
||||
const loginKey = userInfo.loginKey as string | undefined;
|
||||
if (loginKey) {
|
||||
api.loginKey = loginKey;
|
||||
}
|
||||
|
||||
info(`[Login] 登录成功! 用户: ${(userInfo.nickname as string) ?? "unknown"}`);
|
||||
|
||||
// 5. 创建 API Key(非致命)
|
||||
info("[Login] 步骤 5/5: 创建 API Key...");
|
||||
let apiKey = "";
|
||||
try {
|
||||
const keyResult = await api.createApiKey();
|
||||
if (keyResult.success) {
|
||||
apiKey =
|
||||
(nested(keyResult.data, "key") as string) ??
|
||||
(nested(keyResult.data, "resp", "data", "key") as string) ??
|
||||
"";
|
||||
if (apiKey) info(`[Login] API Key: ${apiKey.substring(0, 8)}...`);
|
||||
}
|
||||
} catch (e) {
|
||||
warn(`[Login] 创建 API Key 失败(非致命): ${e}`);
|
||||
}
|
||||
|
||||
// 邀请码检查
|
||||
const userId = String(userInfo.user_id ?? "");
|
||||
if (userId && !bypassInvite) {
|
||||
try {
|
||||
const check = await api.checkInviteCode(userId);
|
||||
if (check.success) {
|
||||
const verified = nested(check.data, "already_verified");
|
||||
if (!verified) {
|
||||
info("\n[Login] 需要邀请码验证。");
|
||||
const inviteCode = await readLine("请输入邀请码: ");
|
||||
if (inviteCode) {
|
||||
const submitResult = await api.submitInviteCode(userId, inviteCode);
|
||||
if (!submitResult.success) {
|
||||
throw new Error(`邀请码验证失败: ${submitResult.message}`);
|
||||
}
|
||||
info("[Login] 邀请码验证通过!");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes("邀请码验证失败")) throw e;
|
||||
warn(`[Login] 邀请码检查失败(非致命): ${e}`);
|
||||
}
|
||||
} else if (bypassInvite) {
|
||||
info("[Login] 已跳过邀请码验证 (bypassInvite=true)");
|
||||
}
|
||||
|
||||
// 保存登录态
|
||||
const credentials: LoginCredentials = {
|
||||
jwtToken,
|
||||
channelToken,
|
||||
userInfo,
|
||||
apiKey,
|
||||
guid,
|
||||
};
|
||||
|
||||
const persistedState: PersistedAuthState = {
|
||||
jwtToken,
|
||||
channelToken,
|
||||
apiKey,
|
||||
guid,
|
||||
userInfo,
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
saveState(persistedState, authStatePath);
|
||||
info("[Login] 登录态已保存");
|
||||
|
||||
return credentials;
|
||||
};
|
||||
Reference in New Issue
Block a user