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_PATH=/qqbot/onebot/reverse
|
||||||
QQBOT_REVERSE_WS_TOKEN=
|
QQBOT_REVERSE_WS_TOKEN=
|
||||||
QQBOT_AUTO_REGISTER_ACCOUNT=true
|
QQBOT_AUTO_REGISTER_ACCOUNT=true
|
||||||
QQBOT_REQUIRE_ALLOWLIST=false
|
|
||||||
QQBOT_API_TIMEOUT_MS=10000
|
QQBOT_API_TIMEOUT_MS=10000
|
||||||
QQBOT_SEND_RATE_PER_SECOND=1
|
QQBOT_SEND_RATE_PER_SECOND=1
|
||||||
NAPCAT_WEBUI_BASE_URL=http://127.0.0.1:6099
|
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_TARGET=nas
|
||||||
QQBOT_NAPCAT_SSH_PORT=
|
QQBOT_NAPCAT_SSH_PORT=
|
||||||
QQBOT_NAPCAT_SSH_KEY_PATH=
|
QQBOT_NAPCAT_SSH_KEY_PATH=
|
||||||
|
QQBOT_NAPCAT_SSH_TIMEOUT_MS=120000
|
||||||
QQBOT_NAPCAT_ROOT=/vol1/docker/kt-qqbot/napcat-instances
|
QQBOT_NAPCAT_ROOT=/vol1/docker/kt-qqbot/napcat-instances
|
||||||
QQBOT_NAPCAT_IMAGE=mlikiowa/napcat-docker:latest
|
QQBOT_NAPCAT_IMAGE=mlikiowa/napcat-docker:latest
|
||||||
QQBOT_NAPCAT_CONTAINER_PREFIX=kt-qqbot-napcat
|
QQBOT_NAPCAT_CONTAINER_PREFIX=kt-qqbot-napcat
|
||||||
|
|||||||
@ -91,7 +91,7 @@ export class QqbotAccountController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: '取消 QQBot 扫码登录会话' })
|
@ApiOperation({ summary: '取消 QQBot 扫码登录会话' })
|
||||||
async cancelScan(@Query() query: QqbotAccountScanStatusDto) {
|
async cancelScan(@Query() query: QqbotAccountScanStatusDto) {
|
||||||
return vbenSuccess(this.napcatLoginService.cancel(query.sessionId));
|
return vbenSuccess(await this.napcatLoginService.cancel(query.sessionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('delete')
|
@Post('delete')
|
||||||
|
|||||||
@ -184,8 +184,12 @@ export class QqbotAccountService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async save(body: QqbotAccountBodyDto) {
|
async save(body: QqbotAccountBodyDto) {
|
||||||
await this.assertSelfIdAvailable(body.selfId);
|
const payload = this.normalizeBody(body);
|
||||||
const account = this.accountRepository.create(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);
|
const saved = await this.accountRepository.save(account);
|
||||||
return saved.id;
|
return saved.id;
|
||||||
}
|
}
|
||||||
@ -268,15 +272,42 @@ export class QqbotAccountService {
|
|||||||
private async assertSelfIdAvailable(selfId: string, id?: string) {
|
private async assertSelfIdAvailable(selfId: string, id?: string) {
|
||||||
const exists = await this.accountRepository.findOne({
|
const exists = await this.accountRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
isDeleted: false,
|
|
||||||
selfId,
|
selfId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (exists && exists.id !== id) {
|
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>) {
|
private normalizeBody(body: Partial<QqbotAccountBodyDto>) {
|
||||||
return {
|
return {
|
||||||
accessToken: normalizeNullableString(body.accessToken),
|
accessToken: normalizeNullableString(body.accessToken),
|
||||||
@ -284,7 +315,8 @@ export class QqbotAccountService {
|
|||||||
enabled: body.enabled ?? true,
|
enabled: body.enabled ?? true,
|
||||||
name: body.name || '',
|
name: body.name || '',
|
||||||
remark: body.remark || '',
|
remark: body.remark || '',
|
||||||
selfId: body.selfId,
|
selfId:
|
||||||
|
typeof body.selfId === 'string' ? body.selfId.trim() : body.selfId,
|
||||||
} as Partial<QqbotAccount>;
|
} as Partial<QqbotAccount>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -124,9 +124,7 @@ export class QqbotNapcatLoginService {
|
|||||||
async status(sessionId: string) {
|
async status(sessionId: string) {
|
||||||
const session = this.getSession(sessionId);
|
const session = this.getSession(sessionId);
|
||||||
if (Date.now() > session.expiresAt) {
|
if (Date.now() > session.expiresAt) {
|
||||||
session.status = 'expired';
|
return this.expireSession(session);
|
||||||
this.sessions.set(session.id, session);
|
|
||||||
return this.toResult(session);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = await this.getSessionContainer(session);
|
const container = await this.getSessionContainer(session);
|
||||||
@ -141,8 +139,12 @@ export class QqbotNapcatLoginService {
|
|||||||
return this.completeLogin(session, container);
|
return this.completeLogin(session, container);
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel(sessionId: string) {
|
async cancel(sessionId: string) {
|
||||||
this.sessions.delete(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (session) {
|
||||||
|
this.sessions.delete(sessionId);
|
||||||
|
await this.cleanupSessionContainer(session);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,34 +156,44 @@ export class QqbotNapcatLoginService {
|
|||||||
},
|
},
|
||||||
container: QqbotNapcatRuntime,
|
container: QqbotNapcatRuntime,
|
||||||
): Promise<QqbotLoginScanResult> {
|
): Promise<QqbotLoginScanResult> {
|
||||||
this.cleanupSessions();
|
await this.cleanupSessions();
|
||||||
|
|
||||||
const loginStatus = await this.getLoginStatus(container, true);
|
try {
|
||||||
if (loginStatus.isLogin) {
|
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({
|
const session = this.createSession({
|
||||||
...options,
|
...options,
|
||||||
container,
|
container,
|
||||||
qrcode: loginStatus.qrcodeurl,
|
qrcode,
|
||||||
status: 'success',
|
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(
|
private async completeLogin(
|
||||||
@ -191,16 +203,13 @@ export class QqbotNapcatLoginService {
|
|||||||
const loginInfo = await this.getLoginInfo(container);
|
const loginInfo = await this.getLoginInfo(container);
|
||||||
const selfId = this.getSelfId(loginInfo);
|
const selfId = this.getSelfId(loginInfo);
|
||||||
if (!selfId) {
|
if (!selfId) {
|
||||||
session.status = 'error';
|
return this.failSession(session, 'NapCat 已登录但未返回 QQ 号');
|
||||||
session.errorMessage = 'NapCat 已登录但未返回 QQ 号';
|
|
||||||
this.sessions.set(session.id, session);
|
|
||||||
return this.toResult(session);
|
|
||||||
}
|
}
|
||||||
if (session.expectedSelfId && session.expectedSelfId !== selfId) {
|
if (session.expectedSelfId && session.expectedSelfId !== selfId) {
|
||||||
session.status = 'error';
|
return this.failSession(
|
||||||
session.errorMessage = `当前扫码账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`;
|
session,
|
||||||
this.sessions.set(session.id, session);
|
`当前扫码账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`,
|
||||||
return this.toResult(session);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const accountId = await this.accountService.ensureScannedAccount({
|
const accountId = await this.accountService.ensureScannedAccount({
|
||||||
@ -271,13 +280,60 @@ export class QqbotNapcatLoginService {
|
|||||||
return this.containerService.findRuntimeById(session.containerId);
|
return this.containerService.findRuntimeById(session.containerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private cleanupSessions() {
|
private async cleanupSessions() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const expiredSessions: QqbotLoginScanSession[] = [];
|
||||||
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);
|
||||||
|
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) {
|
private async getLoginStatus(container: QqbotNapcatRuntime, retry = false) {
|
||||||
|
|||||||
@ -137,6 +137,20 @@ export class QqbotNapcatContainerService {
|
|||||||
return { deletedContainers };
|
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) {
|
private async removeContainer(containerId: string) {
|
||||||
const container = await this.containerRepository.findOne({
|
const container = await this.containerRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
@ -507,6 +521,13 @@ docker run -d \\
|
|||||||
return `${this.configService.get<string>(key) || defaultValue}`.trim();
|
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) {
|
private sh(value: string) {
|
||||||
return `'${`${value}`.replace(/'/g, `'\\''`)}'`;
|
return `'${`${value}`.replace(/'/g, `'\\''`)}'`;
|
||||||
}
|
}
|
||||||
@ -516,21 +537,39 @@ docker run -d \\
|
|||||||
const child = spawn(command, args, {
|
const child = spawn(command, args, {
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
|
let settled = false;
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
let stderr = '';
|
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) => {
|
child.stdout.on('data', (chunk) => {
|
||||||
stdout += Buffer.from(chunk).toString('utf8');
|
stdout += Buffer.from(chunk).toString('utf8');
|
||||||
});
|
});
|
||||||
child.stderr.on('data', (chunk) => {
|
child.stderr.on('data', (chunk) => {
|
||||||
stderr += Buffer.from(chunk).toString('utf8');
|
stderr += Buffer.from(chunk).toString('utf8');
|
||||||
});
|
});
|
||||||
child.on('error', reject);
|
child.on('error', (err) => {
|
||||||
|
finish(() => reject(err));
|
||||||
|
});
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
if (code === 0) {
|
finish(() => {
|
||||||
resolve();
|
if (code === 0) {
|
||||||
return;
|
resolve();
|
||||||
}
|
return;
|
||||||
reject(new Error((stderr || stdout || `${command} failed`).trim()));
|
}
|
||||||
|
reject(new Error((stderr || stdout || `${command} failed`).trim()));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
child.stdin.write(input);
|
child.stdin.write(input);
|
||||||
child.stdin.end();
|
child.stdin.end();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user