feat: 增加 QQBot 更新登录 SSE 进度流
This commit is contained in:
parent
fbaaee8bd7
commit
054c6ce23e
@ -6,6 +6,7 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
|
Sse,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||||
@ -78,6 +79,12 @@ export class QqbotAccountController {
|
|||||||
return vbenSuccess(await this.napcatLoginService.status(query.sessionId));
|
return vbenSuccess(await this.napcatLoginService.status(query.sessionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Sse('scan/events')
|
||||||
|
@ApiOperation({ summary: '订阅 QQBot 扫码登录进度' })
|
||||||
|
scanEvents(@Query() query: QqbotAccountScanStatusDto) {
|
||||||
|
return this.napcatLoginService.events(query.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('scan/qrcode/refresh')
|
@Post('scan/qrcode/refresh')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: '刷新 QQBot 扫码二维码' })
|
@ApiOperation({ summary: '刷新 QQBot 扫码二维码' })
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import * as https from 'https';
|
|||||||
import { createHash, randomUUID } from 'crypto';
|
import { createHash, randomUUID } from 'crypto';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
import { throwVbenError, ToolsService } from '@/common';
|
import { throwVbenError, ToolsService } from '@/common';
|
||||||
import type {
|
import type {
|
||||||
NapcatApiResponse,
|
NapcatApiResponse,
|
||||||
@ -12,6 +13,7 @@ import type {
|
|||||||
NapcatQrcode,
|
NapcatQrcode,
|
||||||
NapcatRestartOptions,
|
NapcatRestartOptions,
|
||||||
QqbotLoginScanMode,
|
QqbotLoginScanMode,
|
||||||
|
QqbotLoginScanEvent,
|
||||||
QqbotLoginScanResult,
|
QqbotLoginScanResult,
|
||||||
QqbotLoginScanSession,
|
QqbotLoginScanSession,
|
||||||
QqbotLoginScanStatus,
|
QqbotLoginScanStatus,
|
||||||
@ -25,6 +27,11 @@ import { QqbotAccountService } from './qqbot-account.service';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class QqbotNapcatLoginService {
|
export class QqbotNapcatLoginService {
|
||||||
private readonly sessions = new Map<string, QqbotLoginScanSession>();
|
private readonly sessions = new Map<string, QqbotLoginScanSession>();
|
||||||
|
private readonly sessionEventLogs = new Map<string, QqbotLoginScanEvent[]>();
|
||||||
|
private readonly sessionEventListeners = new Map<
|
||||||
|
string,
|
||||||
|
Set<(event: QqbotLoginScanEvent) => void>
|
||||||
|
>();
|
||||||
private readonly credentials = new Map<
|
private readonly credentials = new Map<
|
||||||
string,
|
string,
|
||||||
{ credential: string; expiresAt: number }
|
{ credential: string; expiresAt: number }
|
||||||
@ -67,6 +74,12 @@ export class QqbotNapcatLoginService {
|
|||||||
if (session.status !== 'pending') {
|
if (session.status !== 'pending') {
|
||||||
return this.toResult(session);
|
return this.toResult(session);
|
||||||
}
|
}
|
||||||
|
if (session.preparingRelogin) {
|
||||||
|
return this.keepSessionPending(
|
||||||
|
session,
|
||||||
|
session.errorMessage || 'NapCat 正在重置登录态并生成二维码,请稍后',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const container = await this.getSessionContainer(session);
|
const container = await this.getSessionContainer(session);
|
||||||
let loginStatus: NapcatLoginStatus;
|
let loginStatus: NapcatLoginStatus;
|
||||||
@ -85,7 +98,19 @@ export class QqbotNapcatLoginService {
|
|||||||
|
|
||||||
if (loginStatus.isOffline) {
|
if (loginStatus.isOffline) {
|
||||||
if (session.mode === 'refresh') {
|
if (session.mode === 'refresh') {
|
||||||
await this.resetNapcatForLogin(container, { waitForReady: false });
|
this.publishScanResultEvent(
|
||||||
|
session,
|
||||||
|
'relogin-reset-start',
|
||||||
|
'processing',
|
||||||
|
'开始重置 NapCat 登录态',
|
||||||
|
);
|
||||||
|
await this.resetNapcatForLogin(
|
||||||
|
container,
|
||||||
|
{ waitForReady: false },
|
||||||
|
(step, message) => {
|
||||||
|
this.publishScanResultEvent(session, step, 'processing', message);
|
||||||
|
},
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
await this.restartNapcatForLogin(container, { waitForReady: false });
|
await this.restartNapcatForLogin(container, { waitForReady: false });
|
||||||
}
|
}
|
||||||
@ -107,6 +132,12 @@ export class QqbotNapcatLoginService {
|
|||||||
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
||||||
session.errorMessage = undefined;
|
session.errorMessage = undefined;
|
||||||
this.sessions.set(session.id, session);
|
this.sessions.set(session.id, session);
|
||||||
|
this.publishScanResultEvent(
|
||||||
|
session,
|
||||||
|
'qrcode-ready',
|
||||||
|
'success',
|
||||||
|
'登录二维码已刷新',
|
||||||
|
);
|
||||||
return this.toResult(session);
|
return this.toResult(session);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!this.toolsService.isNapcatTemporaryError(err)) throw err;
|
if (!this.toolsService.isNapcatTemporaryError(err)) throw err;
|
||||||
@ -123,6 +154,9 @@ export class QqbotNapcatLoginService {
|
|||||||
if (Date.now() > session.expiresAt) {
|
if (Date.now() > session.expiresAt) {
|
||||||
return this.expireSession(session);
|
return this.expireSession(session);
|
||||||
}
|
}
|
||||||
|
if (session.preparingRelogin) {
|
||||||
|
return this.toResult(session);
|
||||||
|
}
|
||||||
|
|
||||||
const container = await this.getSessionContainer(session);
|
const container = await this.getSessionContainer(session);
|
||||||
let status: NapcatLoginStatus;
|
let status: NapcatLoginStatus;
|
||||||
@ -141,7 +175,16 @@ export class QqbotNapcatLoginService {
|
|||||||
status.qrcodeurl &&
|
status.qrcodeurl &&
|
||||||
!this.toolsService.isNapcatExpiredQrcodeStatus(status)
|
!this.toolsService.isNapcatExpiredQrcodeStatus(status)
|
||||||
) {
|
) {
|
||||||
|
const qrcodeChanged = session.qrcode !== status.qrcodeurl;
|
||||||
session.qrcode = status.qrcodeurl;
|
session.qrcode = status.qrcodeurl;
|
||||||
|
if (qrcodeChanged) {
|
||||||
|
this.publishScanResultEvent(
|
||||||
|
session,
|
||||||
|
'qrcode-ready',
|
||||||
|
'success',
|
||||||
|
'登录二维码已生成',
|
||||||
|
);
|
||||||
|
}
|
||||||
} else if (status.isOffline) {
|
} else if (status.isOffline) {
|
||||||
session.qrcode = undefined;
|
session.qrcode = undefined;
|
||||||
} else if (!this.toolsService.isNapcatExpiredQrcodeStatus(status)) {
|
} else if (!this.toolsService.isNapcatExpiredQrcodeStatus(status)) {
|
||||||
@ -154,11 +197,40 @@ export class QqbotNapcatLoginService {
|
|||||||
return this.completeLogin(session, container);
|
return this.completeLogin(session, container);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
events(sessionId: string) {
|
||||||
|
this.getSession(sessionId);
|
||||||
|
return new Observable<{ data: QqbotLoginScanEvent }>((subscriber) => {
|
||||||
|
const listener = (event: QqbotLoginScanEvent) => {
|
||||||
|
subscriber.next({ data: event });
|
||||||
|
};
|
||||||
|
(this.sessionEventLogs.get(sessionId) || []).forEach(listener);
|
||||||
|
const listeners =
|
||||||
|
this.sessionEventListeners.get(sessionId) ||
|
||||||
|
new Set<(event: QqbotLoginScanEvent) => void>();
|
||||||
|
listeners.add(listener);
|
||||||
|
this.sessionEventListeners.set(sessionId, listeners);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
if (listeners.size <= 0) {
|
||||||
|
this.sessionEventListeners.delete(sessionId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async cancel(sessionId: string) {
|
async cancel(sessionId: string) {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
|
this.publishScanEvent(session, {
|
||||||
|
message: '扫码会话已取消',
|
||||||
|
result: this.toResult(session),
|
||||||
|
status: 'info',
|
||||||
|
step: 'session-cancelled',
|
||||||
|
});
|
||||||
this.sessions.delete(sessionId);
|
this.sessions.delete(sessionId);
|
||||||
await this.cleanupSessionContainer(session);
|
await this.cleanupSessionContainer(session);
|
||||||
|
this.cleanupSessionEvents(sessionId);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -174,18 +246,30 @@ export class QqbotNapcatLoginService {
|
|||||||
): Promise<QqbotLoginScanResult> {
|
): Promise<QqbotLoginScanResult> {
|
||||||
await this.cleanupSessions();
|
await this.cleanupSessions();
|
||||||
|
|
||||||
try {
|
if (options.forceRelogin) {
|
||||||
if (options.forceRelogin) {
|
const session = this.createSession({
|
||||||
await this.resetNapcatForLogin(container);
|
...options,
|
||||||
}
|
container,
|
||||||
|
preparingRelogin: true,
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
session.lastRestartedAt = Date.now();
|
||||||
|
session.errorMessage = 'NapCat 正在重置登录态并生成二维码,请稍后';
|
||||||
|
this.sessions.set(session.id, session);
|
||||||
|
this.publishScanResultEvent(
|
||||||
|
session,
|
||||||
|
'session-created',
|
||||||
|
'processing',
|
||||||
|
'已创建更新登录会话',
|
||||||
|
);
|
||||||
|
void this.prepareReloginQrcode(session, container);
|
||||||
|
return this.toResult(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const loginStatus = await this.getLoginStatus(container, true);
|
const loginStatus = await this.getLoginStatus(container, true);
|
||||||
if (loginStatus.isOffline) {
|
if (loginStatus.isOffline) {
|
||||||
if (options.forceRelogin) {
|
await this.restartNapcatForLogin(container, { waitForReady: false });
|
||||||
await this.resetNapcatForLogin(container, { waitForReady: false });
|
|
||||||
} else {
|
|
||||||
await this.restartNapcatForLogin(container, { waitForReady: false });
|
|
||||||
}
|
|
||||||
const session = this.createSession({
|
const session = this.createSession({
|
||||||
...options,
|
...options,
|
||||||
container,
|
container,
|
||||||
@ -196,17 +280,12 @@ export class QqbotNapcatLoginService {
|
|||||||
loginStatus.loginError ||
|
loginStatus.loginError ||
|
||||||
'NapCat 账号已离线,已重新生成二维码';
|
'NapCat 账号已离线,已重新生成二维码';
|
||||||
this.sessions.set(session.id, session);
|
this.sessions.set(session.id, session);
|
||||||
return this.toResult(session);
|
this.publishScanResultEvent(
|
||||||
}
|
session,
|
||||||
|
'container-restarted',
|
||||||
if (options.forceRelogin && loginStatus.isLogin) {
|
'processing',
|
||||||
const session = this.createSession({
|
session.errorMessage,
|
||||||
...options,
|
);
|
||||||
container,
|
|
||||||
status: 'pending',
|
|
||||||
});
|
|
||||||
session.errorMessage = 'NapCat 正在重新生成二维码,请稍后刷新';
|
|
||||||
this.sessions.set(session.id, session);
|
|
||||||
return this.toResult(session);
|
return this.toResult(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -233,6 +312,18 @@ export class QqbotNapcatLoginService {
|
|||||||
status: 'pending',
|
status: 'pending',
|
||||||
});
|
});
|
||||||
this.sessions.set(session.id, session);
|
this.sessions.set(session.id, session);
|
||||||
|
this.publishScanResultEvent(
|
||||||
|
session,
|
||||||
|
'qrcode-ready',
|
||||||
|
'success',
|
||||||
|
'登录二维码已生成',
|
||||||
|
);
|
||||||
|
this.publishScanResultEvent(
|
||||||
|
session,
|
||||||
|
'waiting-scan',
|
||||||
|
'processing',
|
||||||
|
'等待扫码确认',
|
||||||
|
);
|
||||||
return this.toResult(session);
|
return this.toResult(session);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const cleanupError = await this.cleanupRuntimeContainer(container);
|
const cleanupError = await this.cleanupRuntimeContainer(container);
|
||||||
@ -277,11 +368,18 @@ export class QqbotNapcatLoginService {
|
|||||||
session.status = 'success';
|
session.status = 'success';
|
||||||
session.errorMessage = undefined;
|
session.errorMessage = undefined;
|
||||||
this.sessions.set(session.id, session);
|
this.sessions.set(session.id, session);
|
||||||
return {
|
const result = {
|
||||||
...this.toResult(session),
|
...this.toResult(session),
|
||||||
accountId,
|
accountId,
|
||||||
selfId,
|
selfId,
|
||||||
};
|
};
|
||||||
|
this.publishScanEvent(session, {
|
||||||
|
message: '扫码登录成功',
|
||||||
|
result,
|
||||||
|
status: 'success',
|
||||||
|
step: 'login-success',
|
||||||
|
});
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private createSession(input: {
|
private createSession(input: {
|
||||||
@ -289,6 +387,7 @@ export class QqbotNapcatLoginService {
|
|||||||
container: QqbotNapcatRuntime;
|
container: QqbotNapcatRuntime;
|
||||||
expectedSelfId?: string;
|
expectedSelfId?: string;
|
||||||
mode: QqbotLoginScanMode;
|
mode: QqbotLoginScanMode;
|
||||||
|
preparingRelogin?: boolean;
|
||||||
qrcode?: string;
|
qrcode?: string;
|
||||||
status: QqbotLoginScanStatus;
|
status: QqbotLoginScanStatus;
|
||||||
}): QqbotLoginScanSession {
|
}): QqbotLoginScanSession {
|
||||||
@ -302,6 +401,7 @@ export class QqbotNapcatLoginService {
|
|||||||
expiresAt: now + this.getSessionTtlMs(),
|
expiresAt: now + this.getSessionTtlMs(),
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
mode: input.mode,
|
mode: input.mode,
|
||||||
|
preparingRelogin: input.preparingRelogin,
|
||||||
qrcode: input.qrcode,
|
qrcode: input.qrcode,
|
||||||
status: input.status,
|
status: input.status,
|
||||||
webuiPort: input.container.webuiPort,
|
webuiPort: input.container.webuiPort,
|
||||||
@ -323,6 +423,41 @@ export class QqbotNapcatLoginService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private publishScanEvent(
|
||||||
|
session: QqbotLoginScanSession,
|
||||||
|
input: Omit<QqbotLoginScanEvent, 'createdAt'>,
|
||||||
|
) {
|
||||||
|
const event: QqbotLoginScanEvent = {
|
||||||
|
...input,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
const logs = this.sessionEventLogs.get(session.id) || [];
|
||||||
|
logs.push(event);
|
||||||
|
this.sessionEventLogs.set(session.id, logs.slice(-50));
|
||||||
|
this.sessionEventListeners
|
||||||
|
.get(session.id)
|
||||||
|
?.forEach((listener) => listener(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
private publishScanResultEvent(
|
||||||
|
session: QqbotLoginScanSession,
|
||||||
|
step: string,
|
||||||
|
status: QqbotLoginScanEvent['status'],
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
this.publishScanEvent(session, {
|
||||||
|
message,
|
||||||
|
result: this.toResult(session),
|
||||||
|
status,
|
||||||
|
step,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupSessionEvents(sessionId: string) {
|
||||||
|
this.sessionEventLogs.delete(sessionId);
|
||||||
|
this.sessionEventListeners.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
private keepSessionPending(
|
private keepSessionPending(
|
||||||
session: QqbotLoginScanSession,
|
session: QqbotLoginScanSession,
|
||||||
errorMessage: string,
|
errorMessage: string,
|
||||||
@ -354,6 +489,7 @@ export class QqbotNapcatLoginService {
|
|||||||
this.sessions.forEach((session, sessionId) => {
|
this.sessions.forEach((session, sessionId) => {
|
||||||
if (session.status !== 'pending' || now > session.expiresAt) {
|
if (session.status !== 'pending' || now > session.expiresAt) {
|
||||||
this.sessions.delete(sessionId);
|
this.sessions.delete(sessionId);
|
||||||
|
this.cleanupSessionEvents(sessionId);
|
||||||
expiredSessions.push(session);
|
expiredSessions.push(session);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -365,8 +501,15 @@ export class QqbotNapcatLoginService {
|
|||||||
private async expireSession(session: QqbotLoginScanSession) {
|
private async expireSession(session: QqbotLoginScanSession) {
|
||||||
session.status = 'expired';
|
session.status = 'expired';
|
||||||
session.errorMessage = session.errorMessage || '扫码会话已过期';
|
session.errorMessage = session.errorMessage || '扫码会话已过期';
|
||||||
|
this.publishScanResultEvent(
|
||||||
|
session,
|
||||||
|
'session-expired',
|
||||||
|
'error',
|
||||||
|
session.errorMessage,
|
||||||
|
);
|
||||||
this.sessions.delete(session.id);
|
this.sessions.delete(session.id);
|
||||||
await this.cleanupSessionContainer(session);
|
await this.cleanupSessionContainer(session);
|
||||||
|
this.cleanupSessionEvents(session.id);
|
||||||
return this.toResult(session);
|
return this.toResult(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -376,8 +519,10 @@ export class QqbotNapcatLoginService {
|
|||||||
) {
|
) {
|
||||||
session.status = 'error';
|
session.status = 'error';
|
||||||
session.errorMessage = errorMessage;
|
session.errorMessage = errorMessage;
|
||||||
|
this.publishScanResultEvent(session, 'login-error', 'error', errorMessage);
|
||||||
this.sessions.delete(session.id);
|
this.sessions.delete(session.id);
|
||||||
await this.cleanupSessionContainer(session);
|
await this.cleanupSessionContainer(session);
|
||||||
|
this.cleanupSessionEvents(session.id);
|
||||||
return this.toResult(session);
|
return this.toResult(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -406,8 +551,17 @@ export class QqbotNapcatLoginService {
|
|||||||
staleQrcode: session.qrcode || status.qrcodeurl,
|
staleQrcode: session.qrcode || status.qrcodeurl,
|
||||||
});
|
});
|
||||||
if (qrcode) {
|
if (qrcode) {
|
||||||
|
const qrcodeChanged = session.qrcode !== qrcode;
|
||||||
session.qrcode = qrcode;
|
session.qrcode = qrcode;
|
||||||
session.errorMessage = status.loginError || undefined;
|
session.errorMessage = status.loginError || undefined;
|
||||||
|
if (qrcodeChanged) {
|
||||||
|
this.publishScanResultEvent(
|
||||||
|
session,
|
||||||
|
'qrcode-ready',
|
||||||
|
'success',
|
||||||
|
'登录二维码已生成',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!this.toolsService.isNapcatTemporaryError(err)) throw err;
|
if (!this.toolsService.isNapcatTemporaryError(err)) throw err;
|
||||||
@ -613,13 +767,94 @@ export class QqbotNapcatLoginService {
|
|||||||
return this.requestNapcat<T>(container, path, body, credential);
|
return this.requestNapcat<T>(container, path, body, credential);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async prepareReloginQrcode(
|
||||||
|
session: QqbotLoginScanSession,
|
||||||
|
container: QqbotNapcatRuntime,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
this.publishScanResultEvent(
|
||||||
|
session,
|
||||||
|
'relogin-reset-start',
|
||||||
|
'processing',
|
||||||
|
'开始重置 NapCat 登录态',
|
||||||
|
);
|
||||||
|
await this.resetNapcatForLogin(
|
||||||
|
container,
|
||||||
|
{ waitForReady: false },
|
||||||
|
(step, message) => {
|
||||||
|
this.publishScanResultEvent(session, step, 'processing', message);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
session.lastRestartedAt = Date.now();
|
||||||
|
this.publishScanResultEvent(
|
||||||
|
session,
|
||||||
|
'napcat-ready-wait',
|
||||||
|
'processing',
|
||||||
|
'等待 NapCat WebUI 启动',
|
||||||
|
);
|
||||||
|
await this.toolsService.sleep(this.getRestartDelayMs());
|
||||||
|
this.publishScanResultEvent(
|
||||||
|
session,
|
||||||
|
'qrcode-fetch',
|
||||||
|
'processing',
|
||||||
|
'正在获取登录二维码',
|
||||||
|
);
|
||||||
|
session.qrcode = await this.refreshOrGetQrcode(container, true, {
|
||||||
|
requireFresh: true,
|
||||||
|
});
|
||||||
|
session.errorMessage = undefined;
|
||||||
|
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
||||||
|
this.publishScanResultEvent(
|
||||||
|
session,
|
||||||
|
'qrcode-ready',
|
||||||
|
'success',
|
||||||
|
'登录二维码已生成',
|
||||||
|
);
|
||||||
|
this.publishScanResultEvent(
|
||||||
|
session,
|
||||||
|
'waiting-scan',
|
||||||
|
'processing',
|
||||||
|
'等待扫码确认',
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const message = this.toolsService.getErrorMessage(err);
|
||||||
|
if (this.toolsService.isNapcatTemporaryError(err)) {
|
||||||
|
session.errorMessage =
|
||||||
|
'NapCat 正在重新生成二维码,请稍后刷新或等待自动更新';
|
||||||
|
this.publishScanResultEvent(
|
||||||
|
session,
|
||||||
|
'qrcode-pending',
|
||||||
|
'processing',
|
||||||
|
session.errorMessage,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
session.status = 'error';
|
||||||
|
session.errorMessage = message || 'NapCat 重置登录态失败';
|
||||||
|
this.publishScanResultEvent(
|
||||||
|
session,
|
||||||
|
'relogin-error',
|
||||||
|
'error',
|
||||||
|
session.errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
const current = this.sessions.get(session.id);
|
||||||
|
if (current === session && current.status === 'pending') {
|
||||||
|
current.preparingRelogin = false;
|
||||||
|
this.sessions.set(current.id, current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async resetNapcatForLogin(
|
private async resetNapcatForLogin(
|
||||||
container: QqbotNapcatRuntime,
|
container: QqbotNapcatRuntime,
|
||||||
options: NapcatRestartOptions = {},
|
options: NapcatRestartOptions = {},
|
||||||
|
onProgress?: (step: string, message: string) => void,
|
||||||
) {
|
) {
|
||||||
const resetByContainer =
|
const resetByContainer =
|
||||||
await this.containerService.resetRuntimeLoginState(container);
|
await this.containerService.resetRuntimeLoginState(container, onProgress);
|
||||||
if (!resetByContainer) {
|
if (!resetByContainer) {
|
||||||
|
onProgress?.('napcat-restart-webui', '正在调用 NapCat 重启接口');
|
||||||
await this.restartNapcatForLogin(container, options);
|
await this.restartNapcatForLogin(container, options);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -627,6 +862,7 @@ export class QqbotNapcatLoginService {
|
|||||||
this.credentials.delete(this.getCredentialCacheKey(container));
|
this.credentials.delete(this.getCredentialCacheKey(container));
|
||||||
if (options.waitForReady === false) return;
|
if (options.waitForReady === false) return;
|
||||||
|
|
||||||
|
onProgress?.('napcat-ready-wait', '等待 NapCat WebUI 启动');
|
||||||
await this.toolsService.sleep(this.getRestartDelayMs());
|
await this.toolsService.sleep(this.getRestartDelayMs());
|
||||||
await this.getLoginStatus(container, true);
|
await this.getLoginStatus(container, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -166,7 +166,10 @@ export class QqbotNapcatContainerService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async resetRuntimeLoginState(runtime: QqbotNapcatRuntime) {
|
async resetRuntimeLoginState(
|
||||||
|
runtime: QqbotNapcatRuntime,
|
||||||
|
onProgress?: (step: string, message: string) => void,
|
||||||
|
) {
|
||||||
if (this.getManagedMode() !== 'ssh' || !runtime.id || !runtime.name) {
|
if (this.getManagedMode() !== 'ssh' || !runtime.id || !runtime.name) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -182,7 +185,15 @@ export class QqbotNapcatContainerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const script = this.buildRemoteResetLoginStateScript(container);
|
const script = this.buildRemoteResetLoginStateScript(container);
|
||||||
await this.runProcess('ssh', [...this.getSshArgs(), 'sh -s'], script);
|
await this.runProcess(
|
||||||
|
'ssh',
|
||||||
|
[...this.getSshArgs(), 'sh -s'],
|
||||||
|
script,
|
||||||
|
(line) => {
|
||||||
|
const matched = line.match(/^__KT_PROGRESS__:([^:]+):(.+)$/);
|
||||||
|
if (matched) onProgress?.(matched[1], matched[2]);
|
||||||
|
},
|
||||||
|
);
|
||||||
await this.containerRepository.update(
|
await this.containerRepository.update(
|
||||||
{ id: runtime.id },
|
{ id: runtime.id },
|
||||||
{
|
{
|
||||||
@ -275,10 +286,14 @@ case "$DATA_DIR" in
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
docker exec "$NAME" rm -f /app/napcat/cache/qrcode.png >/dev/null 2>&1 || true
|
docker exec "$NAME" rm -f /app/napcat/cache/qrcode.png >/dev/null 2>&1 || true
|
||||||
|
echo "__KT_PROGRESS__:container-stop:正在停止 NapCat 容器"
|
||||||
docker stop "$NAME" >/dev/null 2>&1 || true
|
docker stop "$NAME" >/dev/null 2>&1 || true
|
||||||
|
echo "__KT_PROGRESS__:login-data-clean:正在清理旧 QQ 登录态"
|
||||||
mkdir -p "$DATA_DIR/QQ"
|
mkdir -p "$DATA_DIR/QQ"
|
||||||
find "$DATA_DIR/QQ" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
|
find "$DATA_DIR/QQ" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
|
||||||
|
echo "__KT_PROGRESS__:container-start:正在启动 NapCat 容器"
|
||||||
docker start "$NAME" >/dev/null
|
docker start "$NAME" >/dev/null
|
||||||
|
echo "__KT_PROGRESS__:container-started:NapCat 容器已启动"
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -608,7 +623,12 @@ docker run -d \\
|
|||||||
return `'${`${value}`.replace(/'/g, `'\\''`)}'`;
|
return `'${`${value}`.replace(/'/g, `'\\''`)}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private runProcess(command: string, args: string[], input: string) {
|
private runProcess(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
input: string,
|
||||||
|
onStdoutLine?: (line: string) => void,
|
||||||
|
) {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const child = spawn(command, args, {
|
const child = spawn(command, args, {
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
@ -616,6 +636,7 @@ docker run -d \\
|
|||||||
let settled = false;
|
let settled = false;
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
|
let stdoutLineBuffer = '';
|
||||||
const timeoutMs = this.getProcessTimeoutMs();
|
const timeoutMs = this.getProcessTimeoutMs();
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
@ -630,7 +651,16 @@ docker run -d \\
|
|||||||
callback();
|
callback();
|
||||||
};
|
};
|
||||||
child.stdout.on('data', (chunk) => {
|
child.stdout.on('data', (chunk) => {
|
||||||
stdout += Buffer.from(chunk).toString('utf8');
|
const text = Buffer.from(chunk).toString('utf8');
|
||||||
|
stdout += text;
|
||||||
|
if (onStdoutLine) {
|
||||||
|
const lines = `${stdoutLineBuffer}${text}`.split(/\r?\n/);
|
||||||
|
stdoutLineBuffer = lines.pop() || '';
|
||||||
|
lines
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.forEach((line) => onStdoutLine(line));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
child.stderr.on('data', (chunk) => {
|
child.stderr.on('data', (chunk) => {
|
||||||
stderr += Buffer.from(chunk).toString('utf8');
|
stderr += Buffer.from(chunk).toString('utf8');
|
||||||
@ -640,6 +670,9 @@ docker run -d \\
|
|||||||
});
|
});
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
finish(() => {
|
finish(() => {
|
||||||
|
if (onStdoutLine && stdoutLineBuffer.trim()) {
|
||||||
|
onStdoutLine(stdoutLineBuffer.trim());
|
||||||
|
}
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -246,6 +246,20 @@ export type QqbotLoginScanResult = {
|
|||||||
webuiPort?: null | number;
|
webuiPort?: null | number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QqbotLoginScanEventStatus =
|
||||||
|
| 'error'
|
||||||
|
| 'info'
|
||||||
|
| 'processing'
|
||||||
|
| 'success';
|
||||||
|
|
||||||
|
export type QqbotLoginScanEvent = {
|
||||||
|
createdAt: number;
|
||||||
|
message: string;
|
||||||
|
result?: QqbotLoginScanResult;
|
||||||
|
status: QqbotLoginScanEventStatus;
|
||||||
|
step: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type QqbotLoginScanSession = {
|
export type QqbotLoginScanSession = {
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
containerId?: string;
|
containerId?: string;
|
||||||
@ -257,6 +271,7 @@ export type QqbotLoginScanSession = {
|
|||||||
id: string;
|
id: string;
|
||||||
lastRestartedAt?: number;
|
lastRestartedAt?: number;
|
||||||
mode: QqbotLoginScanMode;
|
mode: QqbotLoginScanMode;
|
||||||
|
preparingRelogin?: boolean;
|
||||||
qrcode?: string;
|
qrcode?: string;
|
||||||
status: QqbotLoginScanStatus;
|
status: QqbotLoginScanStatus;
|
||||||
webuiPort?: null | number;
|
webuiPort?: null | number;
|
||||||
|
|||||||
@ -24,6 +24,8 @@ describe('QqbotNapcatLoginService', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
(service as any).sessions.clear();
|
(service as any).sessions.clear();
|
||||||
|
(service as any).sessionEventLogs.clear();
|
||||||
|
(service as any).sessionEventListeners.clear();
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -97,14 +99,20 @@ describe('QqbotNapcatLoginService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resets the existing container login state before refresh login scan', async () => {
|
it('returns refresh login scan immediately while resetting in background', async () => {
|
||||||
const container = {
|
const container = {
|
||||||
baseUrl: 'http://127.0.0.1:6103/',
|
baseUrl: 'http://127.0.0.1:6103/',
|
||||||
id: 'container-current',
|
id: 'container-current',
|
||||||
name: 'napcat-10001',
|
name: 'napcat-10001',
|
||||||
};
|
};
|
||||||
|
let resolveReset!: () => void;
|
||||||
const containerService = {
|
const containerService = {
|
||||||
resetRuntimeLoginState: jest.fn().mockResolvedValue(true),
|
resetRuntimeLoginState: jest.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
resolveReset = () => resolve(true);
|
||||||
|
}),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
const refreshService = new QqbotNapcatLoginService(
|
const refreshService = new QqbotNapcatLoginService(
|
||||||
{ get: jest.fn() } as unknown as ConfigService,
|
{ get: jest.fn() } as unknown as ConfigService,
|
||||||
@ -118,10 +126,6 @@ describe('QqbotNapcatLoginService', () => {
|
|||||||
jest
|
jest
|
||||||
.spyOn(refreshService as any, 'cleanupSessions')
|
.spyOn(refreshService as any, 'cleanupSessions')
|
||||||
.mockResolvedValue(undefined);
|
.mockResolvedValue(undefined);
|
||||||
jest
|
|
||||||
.spyOn(refreshService as any, 'getLoginStatus')
|
|
||||||
.mockResolvedValueOnce({ isLogin: false })
|
|
||||||
.mockResolvedValueOnce({ isLogin: false });
|
|
||||||
jest
|
jest
|
||||||
.spyOn(refreshService as any, 'refreshOrGetQrcode')
|
.spyOn(refreshService as any, 'refreshOrGetQrcode')
|
||||||
.mockResolvedValue('fresh-qrcode');
|
.mockResolvedValue('fresh-qrcode');
|
||||||
@ -137,10 +141,71 @@ describe('QqbotNapcatLoginService', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result.status).toBe('pending');
|
expect(result.status).toBe('pending');
|
||||||
expect(result.qrcode).toBe('fresh-qrcode');
|
expect(result.qrcode).toBeUndefined();
|
||||||
|
expect(result.errorMessage).toBe('NapCat 正在重置登录态并生成二维码,请稍后');
|
||||||
expect(containerService.resetRuntimeLoginState).toHaveBeenCalledWith(
|
expect(containerService.resetRuntimeLoginState).toHaveBeenCalledWith(
|
||||||
container,
|
container,
|
||||||
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
|
resolveReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not complete refresh login from stale status while relogin is preparing', async () => {
|
||||||
|
(service as any).sessions.set('session-preparing', {
|
||||||
|
containerId: 'container-preparing',
|
||||||
|
containerName: 'napcat-preparing',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
errorMessage: 'NapCat 正在重置登录态并生成二维码,请稍后',
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
id: 'session-preparing',
|
||||||
|
mode: 'refresh',
|
||||||
|
preparingRelogin: true,
|
||||||
|
status: 'pending',
|
||||||
|
webuiPort: 6106,
|
||||||
|
});
|
||||||
|
const getLoginStatus = jest.spyOn(service as any, 'getLoginStatus');
|
||||||
|
|
||||||
|
const result = await service.status('session-preparing');
|
||||||
|
|
||||||
|
expect(result.status).toBe('pending');
|
||||||
|
expect(result.errorMessage).toBe(
|
||||||
|
'NapCat 正在重置登录态并生成二维码,请稍后',
|
||||||
|
);
|
||||||
|
expect(getLoginStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replays scan progress events to late SSE subscribers', () => {
|
||||||
|
const session = {
|
||||||
|
containerId: 'container-events',
|
||||||
|
containerName: 'napcat-events',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
id: 'session-events',
|
||||||
|
mode: 'refresh',
|
||||||
|
status: 'pending',
|
||||||
|
webuiPort: 6107,
|
||||||
|
};
|
||||||
|
(service as any).sessions.set('session-events', session);
|
||||||
|
(service as any).publishScanResultEvent(
|
||||||
|
session,
|
||||||
|
'container-stop',
|
||||||
|
'processing',
|
||||||
|
'正在停止 NapCat 容器',
|
||||||
|
);
|
||||||
|
const events: any[] = [];
|
||||||
|
|
||||||
|
const subscription = service
|
||||||
|
.events('session-events')
|
||||||
|
.subscribe((event) => events.push(event.data));
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
message: '正在停止 NapCat 容器',
|
||||||
|
status: 'processing',
|
||||||
|
step: 'container-stop',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
subscription.unsubscribe();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requires a qrcode different from the current one when refreshing qrcode', async () => {
|
it('requires a qrcode different from the current one when refreshing qrcode', async () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user