mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 00:00:26 +00:00
* feat: 自动登录失败后回退密码登录并补充独立配置 改动文件: - packages/napcat-webui-backend/src/helper/config.ts - packages/napcat-webui-backend/src/utils/auto_login.ts - packages/napcat-webui-backend/src/utils/auto_login_config.ts - packages/napcat-webui-backend/index.ts - packages/napcat-webui-backend/src/api/QQLogin.ts - packages/napcat-webui-backend/src/router/QQLogin.ts - packages/napcat-webui-frontend/src/controllers/qq_manager.ts - packages/napcat-webui-frontend/src/pages/dashboard/config/login.tsx - packages/napcat-test/autoPasswordFallback.test.ts 目的: - 在启动阶段将自动登录流程从“仅快速登录”扩展为“快速登录失败后自动回退密码登录”,并保持二维码兜底。 - 在 WebUI 登录配置页新增独立的自动回退账号/密码配置,密码仅提交与存储 MD5,不回显明文。 效果: - 后端配置新增 autoPasswordLoginAccount 与 autoPasswordLoginPasswordMd5 字段,并提供读取、更新(空密码不覆盖)和清空能力。 - 新增 QQLogin API:GetAutoPasswordLoginConfig / SetAutoPasswordLoginConfig / ClearAutoPasswordLoginConfig。 - WebUI 登录配置页新增自动回退密码登录区块,支持保存、刷新、清空及“留空不修改密码”交互。 - 新增自动登录回退逻辑单测与配置补丁构造单测,覆盖快速成功、回退成功、回退失败、无密码兜底等场景。 * feat: 精简为环境变量驱动的快速登录失败密码回退 改动目的: - 按维护者建议将方案收敛为后端环境变量驱动,不新增 WebUI 配置与路由 - 保留“快速登录失败 -> 密码回退 -> 二维码兜底”核心能力 - 兼容快速启动参数场景,降低评审复杂度 主要改动文件: - packages/napcat-webui-backend/index.ts - packages/napcat-shell/base.ts - packages/napcat-webui-backend/src/api/QQLogin.ts - packages/napcat-webui-backend/src/helper/config.ts - packages/napcat-webui-backend/src/router/QQLogin.ts - packages/napcat-webui-frontend/src/controllers/qq_manager.ts - packages/napcat-webui-frontend/src/pages/dashboard/config/login.tsx - 删除:packages/napcat-webui-backend/src/utils/auto_login.ts - 删除:packages/napcat-webui-backend/src/utils/auto_login_config.ts - 删除:packages/napcat-test/autoPasswordFallback.test.ts 实现细节: 1. WebUI 启动自动登录链路 - 保留 NAPCAT_QUICK_ACCOUNT 优先逻辑 - 快速登录失败后触发密码回退 - 回退密码来源优先级: a) NAPCAT_QUICK_PASSWORD_MD5(32 位 MD5) b) NAPCAT_QUICK_PASSWORD(运行时自动计算 MD5) - 未配置回退密码时保持二维码兜底,并输出带 QQ 号的引导日志 2. Shell 快速登录链路 - quickLoginWithUin 失败判定统一基于 result 码 + errMsg - 覆盖历史账号不存在、凭证失效、快速登录异常等场景 - 失败后统一进入同一密码回退逻辑,再兜底二维码 3. 文案与可运维性 - 日志明确推荐优先使用 ACCOUNT + NAPCAT_QUICK_PASSWORD - NAPCAT_QUICK_PASSWORD_MD5 作为备用方式 效果: - 满足自动回退登录需求,且改动面显著缩小 - 不修改 napcat-docker 仓库代码,直接兼容现有容器启动参数 - 便于上游快速审阅与合并 * fix: 修复 napcat-framework 未使用变量导致的 CI typecheck 失败 改动文件: - packages/napcat-framework/napcat.ts 问题背景: - 上游代码中声明了变量 bypassEnabled,但后续未使用 - 在 CI 的全量 TypeScript 检查中触发 TS6133(声明但未读取) - 导致 PR Build 机器人评论显示构建失败(Type check failed) 具体修复: - 将以下语句从“赋值后未使用”改为“直接调用” - 原:const bypassEnabled = napi2nativeLoader.nativeExports.enableAllBypasses?.(bypassOptions); - 现:napi2nativeLoader.nativeExports.enableAllBypasses?.(bypassOptions); 影响与效果: - 不改变运行时行为(仍会执行 enableAllBypasses) - 消除 TS6133 报错,恢复 typecheck 可通过 本地验证: - pnpm run typecheck:通过 - pnpm run build:framework:通过 - pnpm run build:shell:通过 --------- Co-authored-by: 手瓜一十雪 <nanaeonn@outlook.com>
753 lines
30 KiB
TypeScript
753 lines
30 KiB
TypeScript
import type { SelfInfo } from 'napcat-core/index';
|
||
|
||
import { NodeIKernelLoginListener, NodeIKernelSessionListener } from 'napcat-core/listeners';
|
||
import { NodeIDependsAdapter, NodeIDispatcherAdapter, NodeIGlobalAdapter } from 'napcat-core/adapters';
|
||
import { NapCatPathWrapper } from 'napcat-common/src/path';
|
||
import {
|
||
genSessionConfig,
|
||
InstanceContext,
|
||
loadQQWrapper,
|
||
NapCatCore,
|
||
NapCatCoreWorkingEnv,
|
||
NodeIQQNTStartupSessionWrapper,
|
||
NodeIQQNTWrapperEngine,
|
||
NodeIQQNTWrapperSession,
|
||
PlatformType,
|
||
WrapperNodeApi,
|
||
WrapperSessionInitConfig,
|
||
} from 'napcat-core';
|
||
import { hostname, systemVersion } from 'napcat-common/src/system';
|
||
import path from 'path';
|
||
import fs from 'fs';
|
||
import os from 'os';
|
||
import { createHash } from 'node:crypto';
|
||
import { LoginListItem, NodeIKernelLoginService } from 'napcat-core/services';
|
||
import qrcode from 'napcat-qrcode/lib/main';
|
||
import { NapCatAdapterManager } from 'napcat-adapter';
|
||
import { InitWebUi } from 'napcat-webui-backend/index';
|
||
import { WebUiDataRuntime } from 'napcat-webui-backend/src/helper/Data';
|
||
import { napCatVersion } from 'napcat-common/src/version';
|
||
import { NodeIO3MiscListener } from 'napcat-core/listeners/NodeIO3MiscListener';
|
||
import { sleep } from 'napcat-common/src/helper';
|
||
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
|
||
import { NativePacketHandler } from 'napcat-core/packet/handler/client';
|
||
import { Napi2NativeLoader } from 'napcat-core/packet/handler/napi2nativeLoader';
|
||
import { loadNapcatConfig } from '@/napcat-core/helper/config';
|
||
import { logSubscription, LogWrapper } from '@/napcat-core/helper/log';
|
||
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
|
||
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
|
||
import { statusHelperSubscription } from '@/napcat-core/helper/status';
|
||
import { applyPendingUpdates } from '@/napcat-webui-backend/src/api/UpdateNapCat';
|
||
import { connectToNamedPipe } from './pipe';
|
||
|
||
// NapCat Shell App ES 入口文件
|
||
async function handleUncaughtExceptions (logger: LogWrapper) {
|
||
process.on('uncaughtException', (err) => {
|
||
logger.logError('[NapCat] [Error] Unhandled Exception:', err.message);
|
||
});
|
||
process.on('unhandledRejection', (reason) => {
|
||
logger.logError('[NapCat] [Error] unhandledRejection:', reason);
|
||
});
|
||
}
|
||
|
||
function getDataPaths (wrapper: WrapperNodeApi): [string, string] {
|
||
if (os.platform() === 'darwin') {
|
||
const userPath = os.homedir();
|
||
const appDataPath = path.resolve(userPath, './Library/Application Support/QQ');
|
||
return [appDataPath, path.join(appDataPath, 'global')];
|
||
}
|
||
let dataPath = wrapper.NodeQQNTWrapperUtil.getNTUserDataInfoConfig();
|
||
if (!dataPath) {
|
||
dataPath = path.resolve(os.homedir(), './.config/QQ');
|
||
fs.mkdirSync(dataPath, { recursive: true });
|
||
}
|
||
const dataPathGlobal = path.resolve(dataPath, './nt_qq/global');
|
||
return [dataPath, dataPathGlobal];
|
||
}
|
||
|
||
function getPlatformType (): PlatformType {
|
||
const platformMapping: Partial<Record<NodeJS.Platform, PlatformType>> = {
|
||
win32: PlatformType.KWINDOWS,
|
||
darwin: PlatformType.KMAC,
|
||
linux: PlatformType.KLINUX,
|
||
};
|
||
return platformMapping[os.platform()] ?? PlatformType.KWINDOWS;
|
||
}
|
||
|
||
async function initializeEngine (
|
||
engine: NodeIQQNTWrapperEngine,
|
||
basicInfoWrapper: QQBasicInfoWrapper,
|
||
dataPathGlobal: string,
|
||
systemPlatform: PlatformType,
|
||
systemVersion: string
|
||
) {
|
||
engine.initWithDeskTopConfig(
|
||
{
|
||
base_path_prefix: '',
|
||
platform_type: systemPlatform,
|
||
app_type: 4,
|
||
app_version: basicInfoWrapper.getFullQQVersion(),
|
||
os_version: systemVersion,
|
||
use_xlog: false,
|
||
qua: basicInfoWrapper.QQVersionQua ?? '',
|
||
global_path_config: {
|
||
desktopGlobalPath: dataPathGlobal,
|
||
},
|
||
thumb_config: { maxSide: 324, minSide: 48, longLimit: 6, density: 2 },
|
||
},
|
||
new NodeIGlobalAdapter()
|
||
);
|
||
}
|
||
|
||
async function initializeLoginService (
|
||
loginService: NodeIKernelLoginService,
|
||
basicInfoWrapper: QQBasicInfoWrapper,
|
||
dataPathGlobal: string,
|
||
systemVersion: string,
|
||
hostname: string
|
||
) {
|
||
loginService.initConfig({
|
||
machineId: '',
|
||
appid: basicInfoWrapper.QQVersionAppid ?? '',
|
||
platVer: systemVersion,
|
||
commonPath: dataPathGlobal,
|
||
clientVer: basicInfoWrapper.getFullQQVersion(),
|
||
hostName: hostname,
|
||
externalVersion: false,
|
||
});
|
||
}
|
||
|
||
async function handleLogin (
|
||
loginService: NodeIKernelLoginService,
|
||
logger: LogWrapper,
|
||
pathWrapper: NapCatPathWrapper,
|
||
quickLoginUin: string | undefined,
|
||
historyLoginList: LoginListItem[]
|
||
): Promise<SelfInfo> {
|
||
const context = { isLogined: false };
|
||
let inner_resolve: (value: SelfInfo) => void;
|
||
const selfInfo: Promise<SelfInfo> = new Promise((resolve) => {
|
||
inner_resolve = resolve;
|
||
});
|
||
// 连接服务
|
||
|
||
const loginListener = new NodeIKernelLoginListener();
|
||
loginListener.onUserLoggedIn = (userid: string) => {
|
||
const tips = `当前账号(${userid})已登录,无法重复登录`;
|
||
logger.logError(tips);
|
||
WebUiDataRuntime.setQQLoginError(tips);
|
||
};
|
||
loginListener.onQRCodeLoginSucceed = async (loginResult) => {
|
||
context.isLogined = true;
|
||
WebUiDataRuntime.setQQLoginStatus(true);
|
||
inner_resolve({
|
||
uid: loginResult.uid,
|
||
uin: loginResult.uin,
|
||
nick: '',
|
||
online: true,
|
||
});
|
||
};
|
||
loginListener.onLoginConnected = () => {
|
||
waitForNetworkConnection(loginService, logger).then(() => {
|
||
handleLoginInner(context, logger, loginService, quickLoginUin, historyLoginList).then().catch(e => logger.logError(e));
|
||
loginListener.onLoginConnected = () => { };
|
||
});
|
||
};
|
||
loginListener.onQRCodeGetPicture = ({ pngBase64QrcodeData, qrcodeUrl }) => {
|
||
WebUiDataRuntime.setQQLoginQrcodeURL(qrcodeUrl);
|
||
|
||
const realBase64 = pngBase64QrcodeData.replace(/^data:image\/\w+;base64,/, '');
|
||
const buffer = Buffer.from(realBase64, 'base64');
|
||
logger.logWarn('请扫描下面的二维码,然后在手Q上授权登录:');
|
||
const qrcodePath = path.join(pathWrapper.cachePath, 'qrcode.png');
|
||
qrcode.generate(qrcodeUrl, { small: true }, (res) => {
|
||
logger.logWarn([
|
||
'\n',
|
||
res,
|
||
'二维码解码URL: ' + qrcodeUrl,
|
||
'如果控制台二维码无法扫码,可以复制解码url到二维码生成网站生成二维码再扫码,也可以打开下方的二维码路径图片进行扫码。',
|
||
].join('\n'));
|
||
fs.writeFile(qrcodePath, buffer, {}, () => {
|
||
logger.logWarn('二维码已保存到', qrcodePath);
|
||
});
|
||
});
|
||
};
|
||
|
||
loginListener.onQRCodeSessionFailed = (errType: number, errCode: number) => {
|
||
if (!context.isLogined) {
|
||
logger.logError('[Core] [Login] Login Error,ErrType: ', errType, ' ErrCode:', errCode);
|
||
if (errType === 1 && errCode === 3) {
|
||
// 二维码过期刷新
|
||
WebUiDataRuntime.setQQLoginError('二维码已过期,请刷新');
|
||
}
|
||
loginService.getQRCodePicture();
|
||
}
|
||
};
|
||
|
||
loginListener.onLoginFailed = (...args) => {
|
||
const errInfo = JSON.stringify(args);
|
||
logger.logError('[Core] [Login] Login Error , ErrInfo: ', errInfo);
|
||
WebUiDataRuntime.setQQLoginError(`登录失败: ${errInfo}`);
|
||
};
|
||
|
||
loginService.addKernelLoginListener(proxiedListenerOf(loginListener, logger));
|
||
loginService.connect();
|
||
return await selfInfo;
|
||
}
|
||
async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWrapper, loginService: NodeIKernelLoginService, quickLoginUin: string | undefined, historyLoginList: LoginListItem[]) {
|
||
const resolveQuickPasswordMd5 = (): string | undefined => {
|
||
const quickPasswordMd5 = process.env['NAPCAT_QUICK_PASSWORD_MD5']?.trim();
|
||
if (quickPasswordMd5) {
|
||
if (/^[a-fA-F0-9]{32}$/.test(quickPasswordMd5)) {
|
||
return quickPasswordMd5.toLowerCase();
|
||
}
|
||
logger.logError('NAPCAT_QUICK_PASSWORD_MD5 格式无效(需为 32 位 MD5)');
|
||
}
|
||
|
||
const quickPassword = process.env['NAPCAT_QUICK_PASSWORD'];
|
||
if (typeof quickPassword === 'string' && quickPassword.length > 0) {
|
||
logger.log('检测到 NAPCAT_QUICK_PASSWORD,已在内存中计算 MD5 用于回退登录');
|
||
return createHash('md5').update(quickPassword, 'utf8').digest('hex');
|
||
}
|
||
|
||
return undefined;
|
||
};
|
||
|
||
// 注册刷新二维码回调
|
||
WebUiDataRuntime.setRefreshQRCodeCallback(async () => {
|
||
loginService.getQRCodePicture();
|
||
});
|
||
|
||
WebUiDataRuntime.setQuickLoginCall(async (uin: string) => {
|
||
return await new Promise((resolve) => {
|
||
if (uin) {
|
||
logger.log('正在快速登录 ', uin);
|
||
loginService.quickLoginWithUin(uin).then(res => {
|
||
const quickLoginSuccess = res.result === '0' && !res.loginErrorInfo?.errMsg;
|
||
if (!quickLoginSuccess) {
|
||
const errMsg = res.loginErrorInfo?.errMsg || `快速登录失败,错误码: ${res.result}`;
|
||
WebUiDataRuntime.setQQLoginError(errMsg);
|
||
loginService.getQRCodePicture();
|
||
resolve({ result: false, message: errMsg });
|
||
} else {
|
||
WebUiDataRuntime.setQQLoginStatus(true);
|
||
WebUiDataRuntime.setQQLoginError('');
|
||
resolve({ result: true, message: '' });
|
||
}
|
||
}).catch((e) => {
|
||
logger.logError(e);
|
||
WebUiDataRuntime.setQQLoginError('快速登录发生错误');
|
||
loginService.getQRCodePicture();
|
||
resolve({ result: false, message: '快速登录发生错误' });
|
||
});
|
||
} else {
|
||
resolve({ result: false, message: '快速登录失败' });
|
||
}
|
||
});
|
||
});
|
||
|
||
// 注册密码登录回调
|
||
WebUiDataRuntime.setPasswordLoginCall(async (uin: string, passwordMd5: string) => {
|
||
return await new Promise((resolve) => {
|
||
if (uin && passwordMd5) {
|
||
logger.log('正在密码登录 ', uin);
|
||
loginService.passwordLogin({
|
||
uin,
|
||
passwordMd5,
|
||
step: 0,
|
||
newDeviceLoginSig: new Uint8Array(),
|
||
proofWaterSig: new Uint8Array(),
|
||
proofWaterRand: new Uint8Array(),
|
||
proofWaterSid: new Uint8Array(),
|
||
unusualDeviceCheckSig: new Uint8Array(),
|
||
}).then(res => {
|
||
if (res.result === '140022008') {
|
||
const proofWaterUrl = res.loginErrorInfo?.proofWaterUrl || '';
|
||
logger.log('需要验证码, proofWaterUrl: ', proofWaterUrl);
|
||
resolve({
|
||
result: false,
|
||
message: '需要验证码',
|
||
needCaptcha: true,
|
||
proofWaterUrl,
|
||
});
|
||
} else if (res.result === '140022010') {
|
||
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
|
||
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
|
||
logger.log('新设备需要扫码验证, jumpUrl: ', jumpUrl);
|
||
resolve({
|
||
result: false,
|
||
message: '新设备需要扫码验证',
|
||
needNewDevice: true,
|
||
jumpUrl,
|
||
newDevicePullQrCodeSig,
|
||
});
|
||
} else if (res.result === '140022011') {
|
||
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
|
||
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
|
||
logger.log('异常设备需要验证, jumpUrl: ', jumpUrl);
|
||
resolve({
|
||
result: false,
|
||
message: '异常设备需要验证',
|
||
needNewDevice: true,
|
||
jumpUrl,
|
||
newDevicePullQrCodeSig,
|
||
});
|
||
} else if (res.result !== '0') {
|
||
const errMsg = res.loginErrorInfo?.errMsg || '密码登录失败';
|
||
WebUiDataRuntime.setQQLoginError(errMsg);
|
||
loginService.getQRCodePicture();
|
||
resolve({ result: false, message: errMsg });
|
||
} else {
|
||
WebUiDataRuntime.setQQLoginStatus(true);
|
||
WebUiDataRuntime.setQQLoginError('');
|
||
resolve({ result: true, message: '' });
|
||
}
|
||
}).catch((e) => {
|
||
logger.logError(e);
|
||
WebUiDataRuntime.setQQLoginError('密码登录发生错误');
|
||
loginService.getQRCodePicture();
|
||
resolve({ result: false, message: '密码登录发生错误' });
|
||
});
|
||
} else {
|
||
resolve({ result: false, message: '密码登录失败:参数不完整' });
|
||
}
|
||
});
|
||
});
|
||
const tryPasswordFallbackLogin = async (uin: string): Promise<{ success: boolean, attempted: boolean; }> => {
|
||
const quickPasswordMd5 = resolveQuickPasswordMd5();
|
||
if (!quickPasswordMd5) {
|
||
logger.log(`QQ ${uin} 未配置回退密码环境变量,建议优先使用 ACCOUNT + NAPCAT_QUICK_PASSWORD(NAPCAT_QUICK_PASSWORD_MD5 作为备用),将使用二维码登录方式`);
|
||
return { success: false, attempted: false };
|
||
}
|
||
|
||
logger.log('正在尝试密码回退登录 ', uin);
|
||
const fallbackResult = await WebUiDataRuntime.requestPasswordLogin(uin, quickPasswordMd5);
|
||
if (fallbackResult.result) {
|
||
logger.log('密码回退登录成功 ', uin);
|
||
return { success: true, attempted: true };
|
||
}
|
||
if (fallbackResult.needCaptcha) {
|
||
const captchaTip = fallbackResult.proofWaterUrl
|
||
? `密码回退需要验证码,请在 WebUi 中继续完成验证:${fallbackResult.proofWaterUrl}`
|
||
: '密码回退需要验证码,请在 WebUi 中继续完成验证';
|
||
logger.logWarn(captchaTip);
|
||
WebUiDataRuntime.setQQLoginError('密码回退需要验证码,请在 WebUi 中继续完成验证');
|
||
return { success: false, attempted: true };
|
||
}
|
||
if (fallbackResult.needNewDevice) {
|
||
const newDeviceTip = fallbackResult.jumpUrl
|
||
? `密码回退需要新设备验证,请在 WebUi 中继续完成验证:${fallbackResult.jumpUrl}`
|
||
: '密码回退需要新设备验证,请在 WebUi 中继续完成验证';
|
||
logger.logWarn(newDeviceTip);
|
||
WebUiDataRuntime.setQQLoginError('密码回退需要新设备验证,请在 WebUi 中继续完成验证');
|
||
return { success: false, attempted: true };
|
||
}
|
||
logger.logError('密码回退登录失败:', fallbackResult.message);
|
||
return { success: false, attempted: true };
|
||
};
|
||
|
||
// 注册验证码登录回调(密码登录需要验证码时的第二步)
|
||
WebUiDataRuntime.setCaptchaLoginCall(async (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) => {
|
||
return await new Promise((resolve) => {
|
||
if (uin && passwordMd5 && ticket) {
|
||
logger.log('正在验证码登录 ', uin);
|
||
loginService.passwordLogin({
|
||
uin,
|
||
passwordMd5,
|
||
step: 1,
|
||
newDeviceLoginSig: new Uint8Array(),
|
||
proofWaterSig: new TextEncoder().encode(ticket),
|
||
proofWaterRand: new TextEncoder().encode(randstr),
|
||
proofWaterSid: new TextEncoder().encode(sid),
|
||
unusualDeviceCheckSig: new Uint8Array(),
|
||
}).then(res => {
|
||
console.log('验证码登录结果: ', res);
|
||
if (res.result === '140022010') {
|
||
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
|
||
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
|
||
logger.log('验证码登录后需要新设备验证, jumpUrl: ', jumpUrl);
|
||
resolve({
|
||
result: false,
|
||
message: '新设备需要扫码验证',
|
||
needNewDevice: true,
|
||
jumpUrl,
|
||
newDevicePullQrCodeSig,
|
||
});
|
||
} else if (res.result === '140022011') {
|
||
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
|
||
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
|
||
logger.log('验证码登录后需要异常设备验证, jumpUrl: ', jumpUrl);
|
||
resolve({
|
||
result: false,
|
||
message: '异常设备需要验证',
|
||
needNewDevice: true,
|
||
jumpUrl,
|
||
newDevicePullQrCodeSig,
|
||
});
|
||
} else if (res.result !== '0') {
|
||
const errMsg = res.loginErrorInfo?.errMsg || '验证码登录失败';
|
||
WebUiDataRuntime.setQQLoginError(errMsg);
|
||
loginService.getQRCodePicture();
|
||
resolve({ result: false, message: errMsg });
|
||
} else {
|
||
WebUiDataRuntime.setQQLoginStatus(true);
|
||
WebUiDataRuntime.setQQLoginError('');
|
||
resolve({ result: true, message: '' });
|
||
}
|
||
}).catch((e) => {
|
||
logger.logError(e);
|
||
WebUiDataRuntime.setQQLoginError('验证码登录发生错误');
|
||
loginService.getQRCodePicture();
|
||
resolve({ result: false, message: '验证码登录发生错误' });
|
||
});
|
||
} else {
|
||
resolve({ result: false, message: '验证码登录失败:参数不完整' });
|
||
}
|
||
});
|
||
});
|
||
|
||
// 注册新设备登录回调(密码登录需要新设备验证时的第二步)
|
||
WebUiDataRuntime.setNewDeviceLoginCall(async (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) => {
|
||
return await new Promise((resolve) => {
|
||
if (uin && passwordMd5 && newDevicePullQrCodeSig) {
|
||
logger.log('正在新设备验证登录 ', uin);
|
||
loginService.passwordLogin({
|
||
uin,
|
||
passwordMd5,
|
||
step: 2,
|
||
newDeviceLoginSig: new TextEncoder().encode(newDevicePullQrCodeSig),
|
||
proofWaterSig: new Uint8Array(),
|
||
proofWaterRand: new Uint8Array(),
|
||
proofWaterSid: new Uint8Array(),
|
||
unusualDeviceCheckSig: new Uint8Array(),
|
||
}).then(res => {
|
||
if (res.result === '140022011') {
|
||
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
|
||
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
|
||
logger.log('新设备验证后需要异常设备验证, jumpUrl: ', jumpUrl);
|
||
resolve({
|
||
result: false,
|
||
message: '异常设备需要验证',
|
||
needNewDevice: true,
|
||
jumpUrl,
|
||
newDevicePullQrCodeSig,
|
||
});
|
||
} else if (res.result !== '0') {
|
||
const errMsg = res.loginErrorInfo?.errMsg || '新设备验证登录失败';
|
||
WebUiDataRuntime.setQQLoginError(errMsg);
|
||
loginService.getQRCodePicture();
|
||
resolve({ result: false, message: errMsg });
|
||
} else {
|
||
WebUiDataRuntime.setQQLoginStatus(true);
|
||
WebUiDataRuntime.setQQLoginError('');
|
||
resolve({ result: true, message: '' });
|
||
}
|
||
}).catch((e) => {
|
||
logger.logError(e);
|
||
WebUiDataRuntime.setQQLoginError('新设备验证登录发生错误');
|
||
loginService.getQRCodePicture();
|
||
resolve({ result: false, message: '新设备验证登录发生错误' });
|
||
});
|
||
} else {
|
||
resolve({ result: false, message: '新设备验证登录失败:参数不完整' });
|
||
}
|
||
});
|
||
});
|
||
if (quickLoginUin) {
|
||
if (historyLoginList.some(u => u.uin === quickLoginUin)) {
|
||
logger.log('正在快速登录 ', quickLoginUin);
|
||
loginService.quickLoginWithUin(quickLoginUin)
|
||
.then(async result => {
|
||
const quickLoginSuccess = result.result === '0' && !result.loginErrorInfo?.errMsg;
|
||
if (!quickLoginSuccess) {
|
||
const errMsg = result.loginErrorInfo?.errMsg || `快速登录失败,错误码: ${result.result}`;
|
||
logger.logError('快速登录错误:', errMsg);
|
||
WebUiDataRuntime.setQQLoginError(errMsg);
|
||
const { success, attempted } = await tryPasswordFallbackLogin(quickLoginUin);
|
||
if (!success && !attempted && !context.isLogined) loginService.getQRCodePicture();
|
||
}
|
||
})
|
||
.catch(async (error) => {
|
||
logger.logError('快速登录异常:', error);
|
||
WebUiDataRuntime.setQQLoginError('快速登录发生错误');
|
||
const { success, attempted } = await tryPasswordFallbackLogin(quickLoginUin);
|
||
if (!success && !attempted && !context.isLogined) loginService.getQRCodePicture();
|
||
});
|
||
} else {
|
||
logger.logError('快速登录失败,未找到该 QQ 历史登录记录,将尝试密码回退登录');
|
||
const { success, attempted } = await tryPasswordFallbackLogin(quickLoginUin);
|
||
if (!success && !attempted && !context.isLogined) loginService.getQRCodePicture();
|
||
}
|
||
} else {
|
||
logger.log('没有 -q 指令指定快速登录,将使用二维码登录方式');
|
||
if (historyLoginList.length > 0) {
|
||
logger.log(`可用于快速登录的 QQ:\n${historyLoginList
|
||
.map((u, index) => `${index + 1}. ${u.uin} ${u.nickName}`)
|
||
.join('\n')
|
||
}`);
|
||
}
|
||
loginService.getQRCodePicture();
|
||
try {
|
||
await WebUiDataRuntime.runWebUiConfigQuickFunction();
|
||
} catch (error) {
|
||
logger.logError('WebUi 快速登录失败 执行失败', error);
|
||
}
|
||
}
|
||
|
||
loginService.getLoginList().then((res) => {
|
||
// 遍历 res.LocalLoginInfoList[x].isQuickLogin是否可以 res.LocalLoginInfoList[x].uin 转为string 加入string[] 最后遍历完成调用WebUiDataRuntime.setQQQuickLoginList
|
||
const list = res.LocalLoginInfoList.filter((item) => item.isQuickLogin);
|
||
WebUiDataRuntime.setQQQuickLoginList(list.map((item) => item.uin.toString()));
|
||
WebUiDataRuntime.setQQNewLoginList(list);
|
||
});
|
||
}
|
||
|
||
async function initializeSession (
|
||
session: NodeIQQNTWrapperSession,
|
||
sessionConfig: WrapperSessionInitConfig,
|
||
startupSession: NodeIQQNTStartupSessionWrapper | null
|
||
) {
|
||
return new Promise<void>((resolve, reject) => {
|
||
const sessionListener = new NodeIKernelSessionListener();
|
||
sessionListener.onOpentelemetryInit = (info) => {
|
||
if (info.is_init) {
|
||
resolve();
|
||
} else {
|
||
reject(new Error('opentelemetry init failed'));
|
||
}
|
||
};
|
||
session.init(
|
||
sessionConfig,
|
||
new NodeIDependsAdapter(),
|
||
new NodeIDispatcherAdapter(),
|
||
sessionListener
|
||
);
|
||
if (startupSession) {
|
||
startupSession.start();
|
||
} else {
|
||
try {
|
||
session.startNT(0);
|
||
} catch {
|
||
try {
|
||
session.startNT();
|
||
} catch (e: unknown) {
|
||
reject(new Error('init failed ' + (e as Error).message));
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
async function handleProxy (session: NodeIQQNTWrapperSession, logger: LogWrapper) {
|
||
if (process.env['NAPCAT_PROXY_PORT']) {
|
||
session.getMSFService().setNetworkProxy({
|
||
userName: '',
|
||
userPwd: '',
|
||
address: process.env['NAPCAT_PROXY_ADDRESS'] || '127.0.0.1',
|
||
port: +process.env['NAPCAT_PROXY_PORT'],
|
||
proxyType: 2,
|
||
domain: '',
|
||
isSocket: true,
|
||
});
|
||
logger.logWarn('已设置代理', process.env['NAPCAT_PROXY_ADDRESS'], process.env['NAPCAT_PROXY_PORT']);
|
||
} else if (process.env['NAPCAT_PROXY_CLOSE']) {
|
||
session.getMSFService().setNetworkProxy({
|
||
userName: '',
|
||
userPwd: '',
|
||
address: '',
|
||
port: 0,
|
||
proxyType: 0,
|
||
domain: '',
|
||
isSocket: false,
|
||
});
|
||
}
|
||
}
|
||
|
||
async function waitForNetworkConnection (loginService: NodeIKernelLoginService, logger: LogWrapper) {
|
||
let network_ok = false;
|
||
let _tryCount = 0;
|
||
while (!network_ok) {
|
||
network_ok = loginService.getMsfStatus() !== 3;// win 11 0连接 1未连接
|
||
logger.log('等待网络连接...');
|
||
await sleep(500);
|
||
_tryCount++;
|
||
}
|
||
logger.log('网络已连接');
|
||
return network_ok;
|
||
}
|
||
|
||
export async function NCoreInitShell () {
|
||
console.log('NapCat Shell App Loading...');
|
||
const pathWrapper = new NapCatPathWrapper();
|
||
const logger = new LogWrapper(pathWrapper.logsPath);
|
||
handleUncaughtExceptions(logger);
|
||
await applyPendingUpdates(pathWrapper, logger);
|
||
|
||
// 提前初始化 Native 模块(在登录前加载)
|
||
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
|
||
const nativePacketHandler = new NativePacketHandler({ logger });
|
||
const napi2nativeLoader = new Napi2NativeLoader({ logger });
|
||
|
||
// 初始化 FFmpeg 服务
|
||
await FFmpegService.init(pathWrapper.binaryPath, logger);
|
||
|
||
if (!(process.env['NAPCAT_DISABLE_PIPE'] === '1' || process.env['NAPCAT_WORKER_PROCESS'] === '1')) {
|
||
await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e));
|
||
}
|
||
const wrapper = loadQQWrapper(basicInfoWrapper.QQMainPath, basicInfoWrapper.getFullQQVersion());
|
||
// wrapper.node 加载后再初始化 hook,按 schema 读取配置
|
||
const napcatConfig = loadNapcatConfig(pathWrapper.configPath);
|
||
await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion(), napcatConfig.o3HookMode === 1 ? true : false);
|
||
if (process.env['NAPCAT_ENABLE_VERBOSE_LOG'] === '1') {
|
||
napi2nativeLoader.nativeExports.setVerbose?.(true);
|
||
}
|
||
// wrapper.node 加载后立刻启用 Bypass(可通过环境变量禁用)
|
||
if (process.env['NAPCAT_DISABLE_BYPASS'] !== '1') {
|
||
const bypassOptions = napcatConfig.bypass ?? {};
|
||
napi2nativeLoader.nativeExports.enableAllBypasses?.(bypassOptions);
|
||
} else {
|
||
logger.log('[NapCat] Napi2NativeLoader: Bypass已通过环境变量禁用');
|
||
}
|
||
|
||
const o3Service = wrapper.NodeIO3MiscService.get();
|
||
o3Service.addO3MiscListener(new NodeIO3MiscListener());
|
||
|
||
logger.log('[NapCat] [Core] NapCat.Core Version: ' + napCatVersion);
|
||
WebUiDataRuntime.setWorkingEnv(NapCatCoreWorkingEnv.Shell);
|
||
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
|
||
const engine = wrapper.NodeIQQNTWrapperEngine.get();
|
||
const loginService = wrapper.NodeIKernelLoginService.get();
|
||
let session: NodeIQQNTWrapperSession;
|
||
let startupSession: NodeIQQNTStartupSessionWrapper | null = null;
|
||
try {
|
||
startupSession = wrapper.NodeIQQNTStartupSessionWrapper.create();
|
||
session = wrapper.NodeIQQNTWrapperSession.getNTWrapperSession('nt_1');
|
||
} catch (e: unknown) {
|
||
try {
|
||
session = wrapper.NodeIQQNTWrapperSession.create();
|
||
} catch (error) {
|
||
logger.logError('创建 StartupSession 失败', e);
|
||
logger.logError('创建 Session 失败', error);
|
||
throw error;
|
||
}
|
||
}
|
||
const [dataPath, dataPathGlobal] = getDataPaths(wrapper);
|
||
WebUiDataRuntime.setQQDataPath(dataPath);
|
||
const systemPlatform = getPlatformType();
|
||
|
||
if (!basicInfoWrapper.QQVersionAppid || !basicInfoWrapper.QQVersionQua) throw new Error('QQVersionAppid or QQVersionQua is not defined');
|
||
|
||
await initializeEngine(engine, basicInfoWrapper, dataPathGlobal, systemPlatform, systemVersion);
|
||
await initializeLoginService(loginService, basicInfoWrapper, dataPathGlobal, systemVersion, hostname);
|
||
handleProxy(session, logger);
|
||
|
||
let quickLoginUin: string | undefined;
|
||
try {
|
||
const args = process.argv;
|
||
const qIndex = args.findIndex(arg => arg === '-q' || arg === '--qq');
|
||
if (qIndex !== -1 && qIndex + 1 < args.length) {
|
||
quickLoginUin = args[qIndex + 1];
|
||
}
|
||
} catch (error) {
|
||
logger.logWarn('解析命令行参数失败,无法使用快速登录功能', error);
|
||
}
|
||
|
||
const historyLoginList = (await loginService.getLoginList()).LocalLoginInfoList;
|
||
|
||
const dataTimestape = new Date().getTime().toString();
|
||
o3Service.reportAmgomWeather('login', 'a1', [dataTimestape, '0', '0']);
|
||
|
||
const selfInfo = await handleLogin(loginService, logger, pathWrapper, quickLoginUin, historyLoginList);
|
||
|
||
// 登录成功后通知 Master 进程(用于切换崩溃重试策略)
|
||
if (typeof process.send === 'function') {
|
||
process.send({ type: 'login-success' });
|
||
logger.log('[NapCat] 已通知主进程登录成功');
|
||
}
|
||
|
||
const amgomDataPiece = 'eb1fd6ac257461580dc7438eb099f23aae04ca679f4d88f53072dc56e3bb1129';
|
||
o3Service.setAmgomDataPiece(basicInfoWrapper.QQVersionAppid, new Uint8Array(Buffer.from(amgomDataPiece, 'hex')));
|
||
|
||
let guid = loginService.getMachineGuid();
|
||
guid = guid.slice(0, 8) + '-' + guid.slice(8, 12) + '-' + guid.slice(12, 16) + '-' + guid.slice(16, 20) + '-' + guid.slice(20);
|
||
o3Service.reportAmgomWeather('login', 'a6', [dataTimestape, '184', '329']);
|
||
|
||
const sessionConfig = await genSessionConfig(
|
||
guid,
|
||
basicInfoWrapper.QQVersionAppid,
|
||
basicInfoWrapper.getFullQQVersion(),
|
||
selfInfo.uin,
|
||
selfInfo.uid,
|
||
dataPath
|
||
);
|
||
|
||
await initializeSession(session, sessionConfig, startupSession);
|
||
|
||
const accountDataPath = path.resolve(dataPath, './NapCat/data');
|
||
// 判断dataPath是否为根目录 或者 D:/ 之类的盘目录
|
||
if (dataPath !== '/' && /^[a-zA-Z]:\\$/.test(dataPath) === false) {
|
||
try {
|
||
fs.mkdirSync(accountDataPath, { recursive: true });
|
||
} catch (error) {
|
||
logger.logError('创建accountDataPath失败', error);
|
||
}
|
||
}
|
||
|
||
logger.logDebug('本账号数据/缓存目录:', accountDataPath);
|
||
|
||
await new NapCatShell(
|
||
wrapper,
|
||
session,
|
||
logger,
|
||
selfInfo,
|
||
basicInfoWrapper,
|
||
pathWrapper,
|
||
nativePacketHandler,
|
||
napi2nativeLoader
|
||
).InitNapCat();
|
||
}
|
||
|
||
export class NapCatShell {
|
||
readonly core: NapCatCore;
|
||
readonly context: InstanceContext;
|
||
|
||
constructor (
|
||
wrapper: WrapperNodeApi,
|
||
session: NodeIQQNTWrapperSession,
|
||
logger: LogWrapper,
|
||
selfInfo: SelfInfo,
|
||
basicInfoWrapper: QQBasicInfoWrapper,
|
||
pathWrapper: NapCatPathWrapper,
|
||
packetHandler: NativePacketHandler,
|
||
napi2nativeLoader: Napi2NativeLoader
|
||
) {
|
||
this.context = {
|
||
packetHandler,
|
||
napi2nativeLoader,
|
||
workingEnv: NapCatCoreWorkingEnv.Shell,
|
||
wrapper,
|
||
session,
|
||
logger,
|
||
basicInfoWrapper,
|
||
pathWrapper,
|
||
};
|
||
this.core = new NapCatCore(this.context, selfInfo);
|
||
}
|
||
|
||
async InitNapCat () {
|
||
await this.core.initCore();
|
||
// 监听下线通知并同步到 WebUI
|
||
this.core.event.on('KickedOffLine', (tips: string) => {
|
||
WebUiDataRuntime.setQQLoginError(tips);
|
||
});
|
||
// 使用 NapCatAdapterManager 统一管理协议适配器
|
||
const adapterManager = new NapCatAdapterManager(this.core, this.context, this.context.pathWrapper);
|
||
await adapterManager.initAdapters()
|
||
.catch(e => this.context.logger.logError('初始化协议适配器失败', e));
|
||
// 注册 OneBot 适配器到 WebUiDataRuntime,供调试功能使用
|
||
const oneBotAdapter = adapterManager.getOneBotAdapter();
|
||
if (oneBotAdapter) {
|
||
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
|
||
}
|
||
}
|
||
}
|