feat: 增加 QQBot 更新登录 SSE 进度流

This commit is contained in:
sunlei 2026-06-04 11:44:37 +08:00
parent fbaaee8bd7
commit 054c6ce23e
5 changed files with 390 additions and 34 deletions

View File

@ -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 扫码二维码' })

View File

@ -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) {
await this.resetNapcatForLogin(container); const session = this.createSession({
...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.resetNapcatForLogin(container, { waitForReady: false });
} else {
await this.restartNapcatForLogin(container, { waitForReady: false }); 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);
} }

View File

@ -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;

View File

@ -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;

View File

@ -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 () => {