fix(qqbot): 完善账号容器链路
This commit is contained in:
parent
aa4b46950b
commit
c51535eed8
@ -28,7 +28,6 @@ QQBOT_EVENT_BUS=mqtt
|
||||
QQBOT_REVERSE_WS_PATH=/qqbot/onebot/reverse
|
||||
QQBOT_REVERSE_WS_TOKEN=
|
||||
QQBOT_AUTO_REGISTER_ACCOUNT=true
|
||||
QQBOT_REQUIRE_ALLOWLIST=false
|
||||
QQBOT_API_TIMEOUT_MS=10000
|
||||
QQBOT_SEND_RATE_PER_SECOND=1
|
||||
NAPCAT_WEBUI_BASE_URL=http://127.0.0.1:6099
|
||||
@ -40,6 +39,7 @@ QQBOT_NAPCAT_CONTAINER_MODE=
|
||||
QQBOT_NAPCAT_SSH_TARGET=nas
|
||||
QQBOT_NAPCAT_SSH_PORT=
|
||||
QQBOT_NAPCAT_SSH_KEY_PATH=
|
||||
QQBOT_NAPCAT_SSH_TIMEOUT_MS=120000
|
||||
QQBOT_NAPCAT_ROOT=/vol1/docker/kt-qqbot/napcat-instances
|
||||
QQBOT_NAPCAT_IMAGE=mlikiowa/napcat-docker:latest
|
||||
QQBOT_NAPCAT_CONTAINER_PREFIX=kt-qqbot-napcat
|
||||
|
||||
@ -91,7 +91,7 @@ export class QqbotAccountController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '取消 QQBot 扫码登录会话' })
|
||||
async cancelScan(@Query() query: QqbotAccountScanStatusDto) {
|
||||
return vbenSuccess(this.napcatLoginService.cancel(query.sessionId));
|
||||
return vbenSuccess(await this.napcatLoginService.cancel(query.sessionId));
|
||||
}
|
||||
|
||||
@Post('delete')
|
||||
|
||||
@ -184,8 +184,12 @@ export class QqbotAccountService {
|
||||
}
|
||||
|
||||
async save(body: QqbotAccountBodyDto) {
|
||||
await this.assertSelfIdAvailable(body.selfId);
|
||||
const account = this.accountRepository.create(this.normalizeBody(body));
|
||||
const payload = this.normalizeBody(body);
|
||||
const restored = await this.restoreDeletedAccount(payload);
|
||||
if (restored) return restored.id;
|
||||
|
||||
await this.assertSelfIdAvailable(payload.selfId || '');
|
||||
const account = this.accountRepository.create(payload);
|
||||
const saved = await this.accountRepository.save(account);
|
||||
return saved.id;
|
||||
}
|
||||
@ -268,15 +272,42 @@ export class QqbotAccountService {
|
||||
private async assertSelfIdAvailable(selfId: string, id?: string) {
|
||||
const exists = await this.accountRepository.findOne({
|
||||
where: {
|
||||
isDeleted: false,
|
||||
selfId,
|
||||
},
|
||||
});
|
||||
if (exists && exists.id !== id) {
|
||||
throwVbenError('QQBot 账号 selfId 已存在');
|
||||
throwVbenError(
|
||||
exists.isDeleted
|
||||
? 'QQBot 账号 selfId 已存在于已删除账号,请通过新增恢复该账号'
|
||||
: 'QQBot 账号 selfId 已存在',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async restoreDeletedAccount(payload: Partial<QqbotAccount>) {
|
||||
if (!payload.selfId) return null;
|
||||
|
||||
const existing = await this.accountRepository.findOne({
|
||||
where: {
|
||||
selfId: payload.selfId,
|
||||
},
|
||||
});
|
||||
if (!existing || !existing.isDeleted) return null;
|
||||
|
||||
await this.accountRepository.update(
|
||||
{ id: existing.id },
|
||||
{
|
||||
...payload,
|
||||
clientRole: null,
|
||||
connectStatus: 'offline',
|
||||
isDeleted: false,
|
||||
lastError: null,
|
||||
lastHeartbeatAt: null,
|
||||
},
|
||||
);
|
||||
return existing;
|
||||
}
|
||||
|
||||
private normalizeBody(body: Partial<QqbotAccountBodyDto>) {
|
||||
return {
|
||||
accessToken: normalizeNullableString(body.accessToken),
|
||||
@ -284,7 +315,8 @@ export class QqbotAccountService {
|
||||
enabled: body.enabled ?? true,
|
||||
name: body.name || '',
|
||||
remark: body.remark || '',
|
||||
selfId: body.selfId,
|
||||
selfId:
|
||||
typeof body.selfId === 'string' ? body.selfId.trim() : body.selfId,
|
||||
} as Partial<QqbotAccount>;
|
||||
}
|
||||
}
|
||||
|
||||
@ -124,9 +124,7 @@ export class QqbotNapcatLoginService {
|
||||
async status(sessionId: string) {
|
||||
const session = this.getSession(sessionId);
|
||||
if (Date.now() > session.expiresAt) {
|
||||
session.status = 'expired';
|
||||
this.sessions.set(session.id, session);
|
||||
return this.toResult(session);
|
||||
return this.expireSession(session);
|
||||
}
|
||||
|
||||
const container = await this.getSessionContainer(session);
|
||||
@ -141,8 +139,12 @@ export class QqbotNapcatLoginService {
|
||||
return this.completeLogin(session, container);
|
||||
}
|
||||
|
||||
cancel(sessionId: string) {
|
||||
this.sessions.delete(sessionId);
|
||||
async cancel(sessionId: string) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
this.sessions.delete(sessionId);
|
||||
await this.cleanupSessionContainer(session);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -154,34 +156,44 @@ export class QqbotNapcatLoginService {
|
||||
},
|
||||
container: QqbotNapcatRuntime,
|
||||
): Promise<QqbotLoginScanResult> {
|
||||
this.cleanupSessions();
|
||||
await this.cleanupSessions();
|
||||
|
||||
const loginStatus = await this.getLoginStatus(container, true);
|
||||
if (loginStatus.isLogin) {
|
||||
try {
|
||||
const loginStatus = await this.getLoginStatus(container, true);
|
||||
if (loginStatus.isLogin) {
|
||||
const session = this.createSession({
|
||||
...options,
|
||||
container,
|
||||
qrcode: loginStatus.qrcodeurl,
|
||||
status: 'success',
|
||||
});
|
||||
return this.completeLogin(session, container);
|
||||
}
|
||||
|
||||
let qrcode = this.isExpiredQrcodeStatus(loginStatus)
|
||||
? ''
|
||||
: loginStatus.qrcodeurl || '';
|
||||
if (!qrcode) {
|
||||
await this.callRefreshQrcode(container, true);
|
||||
qrcode = await this.getQrcode(container, true);
|
||||
}
|
||||
const session = this.createSession({
|
||||
...options,
|
||||
container,
|
||||
qrcode: loginStatus.qrcodeurl,
|
||||
status: 'success',
|
||||
qrcode,
|
||||
status: 'pending',
|
||||
});
|
||||
return this.completeLogin(session, container);
|
||||
this.sessions.set(session.id, session);
|
||||
return this.toResult(session);
|
||||
} catch (err) {
|
||||
const cleanupError = await this.cleanupRuntimeContainer(container);
|
||||
if (cleanupError) {
|
||||
throwVbenError(
|
||||
`${this.getErrorMessage(err)};清理未绑定容器失败:${cleanupError}`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
let qrcode = this.isExpiredQrcodeStatus(loginStatus)
|
||||
? ''
|
||||
: loginStatus.qrcodeurl || '';
|
||||
if (!qrcode) {
|
||||
await this.callRefreshQrcode(container, true);
|
||||
qrcode = await this.getQrcode(container, true);
|
||||
}
|
||||
const session = this.createSession({
|
||||
...options,
|
||||
container,
|
||||
qrcode,
|
||||
status: 'pending',
|
||||
});
|
||||
this.sessions.set(session.id, session);
|
||||
return this.toResult(session);
|
||||
}
|
||||
|
||||
private async completeLogin(
|
||||
@ -191,16 +203,13 @@ export class QqbotNapcatLoginService {
|
||||
const loginInfo = await this.getLoginInfo(container);
|
||||
const selfId = this.getSelfId(loginInfo);
|
||||
if (!selfId) {
|
||||
session.status = 'error';
|
||||
session.errorMessage = 'NapCat 已登录但未返回 QQ 号';
|
||||
this.sessions.set(session.id, session);
|
||||
return this.toResult(session);
|
||||
return this.failSession(session, 'NapCat 已登录但未返回 QQ 号');
|
||||
}
|
||||
if (session.expectedSelfId && session.expectedSelfId !== selfId) {
|
||||
session.status = 'error';
|
||||
session.errorMessage = `当前扫码账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`;
|
||||
this.sessions.set(session.id, session);
|
||||
return this.toResult(session);
|
||||
return this.failSession(
|
||||
session,
|
||||
`当前扫码账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`,
|
||||
);
|
||||
}
|
||||
|
||||
const accountId = await this.accountService.ensureScannedAccount({
|
||||
@ -271,13 +280,60 @@ export class QqbotNapcatLoginService {
|
||||
return this.containerService.findRuntimeById(session.containerId);
|
||||
}
|
||||
|
||||
private cleanupSessions() {
|
||||
private async cleanupSessions() {
|
||||
const now = Date.now();
|
||||
const expiredSessions: QqbotLoginScanSession[] = [];
|
||||
this.sessions.forEach((session, sessionId) => {
|
||||
if (session.status !== 'pending' || now > session.expiresAt) {
|
||||
this.sessions.delete(sessionId);
|
||||
expiredSessions.push(session);
|
||||
}
|
||||
});
|
||||
await Promise.all(
|
||||
expiredSessions.map((session) => this.cleanupSessionContainer(session)),
|
||||
);
|
||||
}
|
||||
|
||||
private async expireSession(session: QqbotLoginScanSession) {
|
||||
session.status = 'expired';
|
||||
session.errorMessage = session.errorMessage || '扫码会话已过期';
|
||||
this.sessions.delete(session.id);
|
||||
await this.cleanupSessionContainer(session);
|
||||
return this.toResult(session);
|
||||
}
|
||||
|
||||
private async failSession(
|
||||
session: QqbotLoginScanSession,
|
||||
errorMessage: string,
|
||||
) {
|
||||
session.status = 'error';
|
||||
session.errorMessage = errorMessage;
|
||||
this.sessions.delete(session.id);
|
||||
await this.cleanupSessionContainer(session);
|
||||
return this.toResult(session);
|
||||
}
|
||||
|
||||
private async cleanupSessionContainer(session: QqbotLoginScanSession) {
|
||||
const cleanupError = await this.cleanupRuntimeContainer({
|
||||
baseUrl: '',
|
||||
id: session.containerId,
|
||||
name: session.containerName || '',
|
||||
webuiPort: session.webuiPort,
|
||||
});
|
||||
if (cleanupError) {
|
||||
session.errorMessage = session.errorMessage
|
||||
? `${session.errorMessage};清理未绑定容器失败:${cleanupError}`
|
||||
: `清理未绑定容器失败:${cleanupError}`;
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupRuntimeContainer(container: QqbotNapcatRuntime) {
|
||||
try {
|
||||
await this.containerService.removeUnboundContainer(container.id);
|
||||
return null;
|
||||
} catch (err) {
|
||||
return this.getErrorMessage(err);
|
||||
}
|
||||
}
|
||||
|
||||
private async getLoginStatus(container: QqbotNapcatRuntime, retry = false) {
|
||||
|
||||
@ -137,6 +137,20 @@ export class QqbotNapcatContainerService {
|
||||
return { deletedContainers };
|
||||
}
|
||||
|
||||
async removeUnboundContainer(containerId?: string) {
|
||||
if (!containerId) return false;
|
||||
|
||||
const bindingCount = await this.bindingRepository.count({
|
||||
where: {
|
||||
containerId,
|
||||
isDeleted: false,
|
||||
},
|
||||
});
|
||||
if (bindingCount > 0) return false;
|
||||
|
||||
return this.removeContainer(containerId);
|
||||
}
|
||||
|
||||
private async removeContainer(containerId: string) {
|
||||
const container = await this.containerRepository.findOne({
|
||||
where: {
|
||||
@ -507,6 +521,13 @@ docker run -d \\
|
||||
return `${this.configService.get<string>(key) || defaultValue}`.trim();
|
||||
}
|
||||
|
||||
private getProcessTimeoutMs() {
|
||||
const timeoutMs = Number(
|
||||
this.getConfig('QQBOT_NAPCAT_SSH_TIMEOUT_MS', '120000'),
|
||||
);
|
||||
return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 120000;
|
||||
}
|
||||
|
||||
private sh(value: string) {
|
||||
return `'${`${value}`.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
@ -516,21 +537,39 @@ docker run -d \\
|
||||
const child = spawn(command, args, {
|
||||
windowsHide: true,
|
||||
});
|
||||
let settled = false;
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const timeoutMs = this.getProcessTimeoutMs();
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill('SIGTERM');
|
||||
reject(new Error(`${command} timeout after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
const finish = (callback: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
callback();
|
||||
};
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += Buffer.from(chunk).toString('utf8');
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += Buffer.from(chunk).toString('utf8');
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('error', (err) => {
|
||||
finish(() => reject(err));
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error((stderr || stdout || `${command} failed`).trim()));
|
||||
finish(() => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error((stderr || stdout || `${command} failed`).trim()));
|
||||
});
|
||||
});
|
||||
child.stdin.write(input);
|
||||
child.stdin.end();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user