feat: 支持 QQBot NapCat 多容器扫码链路

This commit is contained in:
sunlei 2026-06-01 16:12:24 +08:00
parent 391499928e
commit 6ed1eecb9a
14 changed files with 1369 additions and 6 deletions

View File

@ -27,9 +27,27 @@ QQBOT_ENABLED=false
QQBOT_EVENT_BUS=mqtt
QQBOT_REVERSE_WS_PATH=/qqbot/onebot/reverse
QQBOT_REVERSE_WS_TOKEN=
QQBOT_AUTO_REGISTER_ACCOUNT=true
QQBOT_REQUIRE_ALLOWLIST=true
QQBOT_API_TIMEOUT_MS=10000
QQBOT_SEND_RATE_PER_SECOND=1
NAPCAT_WEBUI_BASE_URL=http://127.0.0.1:6099
NAPCAT_WEBUI_TOKEN=
NAPCAT_WEBUI_TIMEOUT_MS=8000
NAPCAT_LOGIN_QR_EXPIRE_MS=120000
NAPCAT_WEBUI_READY_RETRIES=10
QQBOT_NAPCAT_CONTAINER_MODE=
QQBOT_NAPCAT_SSH_TARGET=nas
QQBOT_NAPCAT_SSH_PORT=
QQBOT_NAPCAT_SSH_KEY_PATH=
QQBOT_NAPCAT_ROOT=/vol1/docker/kt-qqbot/napcat-instances
QQBOT_NAPCAT_IMAGE=mlikiowa/napcat-docker:latest
QQBOT_NAPCAT_CONTAINER_PREFIX=kt-qqbot-napcat
QQBOT_NAPCAT_HOST=192.168.31.224
QQBOT_NAPCAT_PORT_START=6100
QQBOT_NAPCAT_PORT_END=6199
QQBOT_NAPCAT_BASE_URL_TEMPLATE=http://192.168.31.224:{port}
QQBOT_NAPCAT_REVERSE_WS_BASE=ws://192.168.31.224:48085/qqbot/onebot/reverse
MQTT_URL=mqtt://127.0.0.1:1883
MQTT_USERNAME=

View File

@ -6,6 +6,10 @@ ENV NODE_ENV=production
COPY package.json pnpm-lock.yaml ./
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssh-client \
&& rm -rf /var/lib/apt/lists/*
# 生产镜像只安装运行依赖dist 由 Jenkins Build stage 提前产出。
# 跳过安装阶段脚本,避免 NODE_ENV=production 时 devDependency 中的 husky 不存在导致 prepare 失败。
RUN corepack enable \

View File

@ -31,10 +31,16 @@ spec:
env:
- name: NODE_ENV
value: production
- name: QQBOT_NAPCAT_SSH_KEY_PATH
value: /app/secrets/napcat-ssh/id_rsa
# Jenkins 每次发布会从 Agent 私有 .env.production 重建这个 Secret。
envFrom:
- secretRef:
name: kt-template-online-api-env
volumeMounts:
- name: napcat-ssh-key
mountPath: /app/secrets/napcat-ssh
readOnly: true
readinessProbe:
tcpSocket:
port: 48085
@ -56,6 +62,12 @@ spec:
limits:
cpu: 1000m
memory: 768Mi
volumes:
- name: napcat-ssh-key
secret:
secretName: kt-qqbot-napcat-ssh-key
optional: true
defaultMode: 0400
---
apiVersion: v1
kind: Service

View File

@ -25,6 +25,44 @@ CREATE TABLE IF NOT EXISTS `qqbot_account` (
UNIQUE KEY `uk_qqbot_account_self_id` (`self_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `qqbot_napcat_container` (
`id` bigint NOT NULL,
`name` varchar(120) NOT NULL,
`base_url` varchar(255) NOT NULL,
`webui_port` int DEFAULT NULL,
`webui_token` varchar(255) DEFAULT NULL,
`image` varchar(255) NOT NULL DEFAULT '',
`data_dir` varchar(500) NOT NULL DEFAULT '',
`reverse_ws_url` varchar(500) NOT NULL DEFAULT '',
`status` varchar(32) NOT NULL DEFAULT 'creating',
`last_started_at` datetime DEFAULT NULL,
`last_checked_at` datetime DEFAULT NULL,
`last_error` varchar(500) DEFAULT NULL,
`remark` varchar(255) NOT NULL DEFAULT '',
`is_deleted` tinyint(1) NOT NULL DEFAULT 0,
`create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_qqbot_napcat_container_name` (`name`),
KEY `idx_qqbot_napcat_container_status` (`status`, `is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `qqbot_account_napcat` (
`id` bigint NOT NULL,
`account_id` bigint NOT NULL,
`container_id` bigint NOT NULL,
`bind_status` varchar(32) NOT NULL DEFAULT 'pending',
`is_primary` tinyint(1) NOT NULL DEFAULT 1,
`last_login_at` datetime DEFAULT NULL,
`remark` varchar(255) NOT NULL DEFAULT '',
`is_deleted` tinyint(1) NOT NULL DEFAULT 0,
`create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (`id`),
KEY `idx_qqbot_account_napcat_account` (`account_id`, `is_deleted`),
KEY `idx_qqbot_account_napcat_container` (`container_id`, `is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `qqbot_rule` (
`id` bigint NOT NULL,
`name` varchar(120) NOT NULL DEFAULT '',
@ -152,6 +190,7 @@ VALUES
(2041700000000120402, 2041700000000100402, 'QqBotAccountEdit', NULL, NULL, NULL, 'QqBot:Account:Edit', 'button', '{"title":"common.edit"}', 1, 0),
(2041700000000120403, 2041700000000100402, 'QqBotAccountDelete', NULL, NULL, NULL, 'QqBot:Account:Delete', 'button', '{"title":"common.delete"}', 1, 0),
(2041700000000120404, 2041700000000100402, 'QqBotAccountKick', NULL, NULL, NULL, 'QqBot:Account:Kick', 'button', '{"title":"断开连接"}', 1, 0),
(2041700000000120405, 2041700000000100402, 'QqBotAccountRefreshLogin', NULL, NULL, NULL, 'QqBot:Account:RefreshLogin', 'button', '{"title":"更新登录"}', 1, 0),
(2041700000000100403, 2041700000000100400, 'QqBotRule', '/qqbot/rule', '/qqbot/rule/list', NULL, 'QqBot:Rule:List', 'menu', '{"icon":"lucide:workflow","title":"自动回复规则"}', 1, 2),
(2041700000000120411, 2041700000000100403, 'QqBotRuleCreate', NULL, NULL, NULL, 'QqBot:Rule:Create', 'button', '{"title":"common.create"}', 1, 0),
(2041700000000120412, 2041700000000100403, 'QqBotRuleEdit', NULL, NULL, NULL, 'QqBot:Rule:Edit', 'button', '{"title":"common.edit"}', 1, 0),

View File

@ -14,9 +14,11 @@ import { vbenSuccess } from '@/common';
import {
QqbotAccountBodyDto,
QqbotAccountQueryDto,
QqbotAccountScanStatusDto,
QqbotAccountUpdateDto,
} from './qqbot-account.dto';
import { QqbotAccountService } from './qqbot-account.service';
import { QqbotNapcatLoginService } from './qqbot-napcat-login.service';
import { QqbotReverseWsService } from '../connection/qqbot-reverse-ws.service';
@ApiTags('qqbot-account')
@ -25,6 +27,7 @@ import { QqbotReverseWsService } from '../connection/qqbot-reverse-ws.service';
export class QqbotAccountController {
constructor(
private readonly accountService: QqbotAccountService,
private readonly napcatLoginService: QqbotNapcatLoginService,
private readonly reverseWsService: QqbotReverseWsService,
) {}
@ -54,6 +57,43 @@ export class QqbotAccountController {
return vbenSuccess(await this.accountService.update(body));
}
@Post('scan/create')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '扫码新增 QQBot 账号' })
async scanCreate() {
return vbenSuccess(await this.napcatLoginService.startCreate());
}
@Post('scan/refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '扫码刷新 QQBot 账号登录态' })
@ApiQuery({ name: 'id', type: String })
async scanRefresh(@Query('id') id: string) {
return vbenSuccess(await this.napcatLoginService.startRefresh(id));
}
@Get('scan/status')
@ApiOperation({ summary: '查询 QQBot 扫码登录状态' })
async scanStatus(@Query() query: QqbotAccountScanStatusDto) {
return vbenSuccess(await this.napcatLoginService.status(query.sessionId));
}
@Post('scan/qrcode/refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '刷新 QQBot 扫码二维码' })
async refreshScanQrcode(@Query() query: QqbotAccountScanStatusDto) {
return vbenSuccess(
await this.napcatLoginService.refreshQrcode(query.sessionId),
);
}
@Post('scan/cancel')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '取消 QQBot 扫码登录会话' })
async cancelScan(@Query() query: QqbotAccountScanStatusDto) {
return vbenSuccess(this.napcatLoginService.cancel(query.sessionId));
}
@Post('delete')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '删除 QQBot 账号' })

View File

@ -42,3 +42,8 @@ export class QqbotAccountQueryDto {
@ApiPropertyOptional()
connectStatus?: string;
}
export class QqbotAccountScanStatusDto {
@ApiProperty()
sessionId: string;
}

View File

@ -87,6 +87,100 @@ export class QqbotAccountService {
.getOne();
}
async findById(id: string) {
return this.accountRepository.findOne({
where: {
id,
isDeleted: false,
},
});
}
async findBySelfId(selfId: string) {
return this.accountRepository.findOne({
where: {
isDeleted: false,
selfId,
},
});
}
async ensureScannedAccount(input: {
accountId?: string;
name?: string;
selfId: string;
}) {
const selfId = `${input.selfId || ''}`.trim();
if (!selfId) {
throwVbenError('NapCat 未返回 QQ 账号');
}
const existing = input.accountId
? await this.accountRepository.findOne({ where: { id: input.accountId } })
: await this.accountRepository.findOne({ where: { selfId } });
const payload: Partial<QqbotAccount> = {
accessToken: null,
clientRole: null,
connectStatus: 'offline',
connectionMode: 'reverse-ws',
enabled: true,
isDeleted: false,
lastError: null,
name: input.name || existing?.name || `QQ ${selfId}`,
selfId,
};
if (existing) {
await this.accountRepository.update({ id: existing.id }, payload);
return existing.id;
}
const saved = await this.accountRepository.save(
this.accountRepository.create({
...payload,
remark: '',
}),
);
return saved.id;
}
async ensureRuntimeAccount(selfId: string) {
const normalizedSelfId = `${selfId || ''}`.trim();
if (!normalizedSelfId) return;
const existing = await this.accountRepository.findOne({
where: {
selfId: normalizedSelfId,
},
});
if (existing && !existing.isDeleted) return;
if (existing) {
await this.accountRepository.update(
{ id: existing.id },
{
connectStatus: 'offline',
enabled: true,
isDeleted: false,
lastError: null,
name: existing.name || `QQ ${normalizedSelfId}`,
},
);
return;
}
await this.accountRepository.save(
this.accountRepository.create({
connectionMode: 'reverse-ws',
connectStatus: 'offline',
enabled: true,
name: `QQ ${normalizedSelfId}`,
remark: '',
selfId: normalizedSelfId,
}),
);
}
async save(body: QqbotAccountBodyDto) {
await this.assertSelfIdAvailable(body.selfId);
const account = this.accountRepository.create(this.normalizeBody(body));

View File

@ -0,0 +1,521 @@
import * as http from 'http';
import * as https from 'https';
import { createHash, randomUUID } from 'crypto';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { throwVbenError } from '@/common';
import type { QqbotLoginScanMode, QqbotLoginScanStatus } from '../qqbot.types';
import {
QqbotNapcatContainerService,
type QqbotNapcatRuntime,
} from '../napcat/qqbot-napcat-container.service';
import { QqbotAccountService } from './qqbot-account.service';
type NapcatApiResponse<T> = {
code: number;
data?: T;
message?: string;
};
type NapcatCredential = {
Credential?: string;
};
type NapcatLoginInfo = Record<string, any> & {
avatarUrl?: string;
nick?: string;
nickname?: string;
online?: boolean;
uin?: number | string;
};
type NapcatLoginStatus = {
isLogin?: boolean;
isOffline?: boolean;
loginError?: string;
qrcodeurl?: string;
};
type NapcatQrcode = {
qrcode?: string;
};
type QqbotLoginScanSession = {
accountId?: string;
containerId?: string;
containerName?: string;
createdAt: number;
errorMessage?: string;
expiresAt: number;
expectedSelfId?: string;
id: string;
mode: QqbotLoginScanMode;
qrcode?: string;
status: QqbotLoginScanStatus;
webuiPort?: null | number;
};
export type QqbotLoginScanResult = {
accountId?: string;
containerId?: string;
containerName?: string;
errorMessage?: string;
expiresAt?: number;
mode: QqbotLoginScanMode;
qrcode?: string;
selfId?: string;
sessionId?: string;
status: QqbotLoginScanStatus;
webuiPort?: null | number;
};
@Injectable()
export class QqbotNapcatLoginService {
private readonly sessions = new Map<string, QqbotLoginScanSession>();
private readonly credentials = new Map<
string,
{ credential: string; expiresAt: number }
>();
constructor(
private readonly configService: ConfigService,
private readonly accountService: QqbotAccountService,
private readonly containerService: QqbotNapcatContainerService,
) {}
async startCreate() {
const container = await this.containerService.prepareCreateContainer();
return this.startScan({ mode: 'create' }, container);
}
async startRefresh(accountId: string) {
const account = await this.accountService.findById(accountId);
if (!account) {
throwVbenError('QQBot 账号不存在');
}
const container = await this.containerService.prepareAccountContainer(
account,
);
return this.startScan(
{
accountId: account.id,
expectedSelfId: account.selfId,
mode: 'refresh',
},
container,
);
}
async refreshQrcode(sessionId: string) {
const session = this.getSession(sessionId);
if (session.status !== 'pending') {
return this.toResult(session);
}
const container = await this.getSessionContainer(session);
await this.callRefreshQrcode(container);
session.qrcode = await this.getQrcode(container);
session.expiresAt = Date.now() + this.getSessionTtlMs();
this.sessions.set(session.id, session);
return this.toResult(session);
}
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);
}
const container = await this.getSessionContainer(session);
const status = await this.getLoginStatus(container);
if (!status.isLogin) {
session.errorMessage = status.loginError || undefined;
session.qrcode = status.qrcodeurl || session.qrcode;
this.sessions.set(session.id, session);
return this.toResult(session);
}
return this.completeLogin(session, container);
}
cancel(sessionId: string) {
this.sessions.delete(sessionId);
return true;
}
private async startScan(
options: {
accountId?: string;
expectedSelfId?: string;
mode: QqbotLoginScanMode;
},
container: QqbotNapcatRuntime,
): Promise<QqbotLoginScanResult> {
this.cleanupSessions();
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 = 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(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
): Promise<QqbotLoginScanResult> {
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);
}
if (session.expectedSelfId && session.expectedSelfId !== selfId) {
session.status = 'error';
session.errorMessage = `当前扫码账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`;
this.sessions.set(session.id, session);
return this.toResult(session);
}
const accountId = await this.accountService.ensureScannedAccount({
accountId: session.accountId,
name: this.getNickname(loginInfo),
selfId,
});
await this.containerService.bindAccount(accountId, session.containerId);
session.accountId = accountId;
session.status = 'success';
session.errorMessage = undefined;
this.sessions.set(session.id, session);
return {
...this.toResult(session),
accountId,
selfId,
};
}
private createSession(input: {
accountId?: string;
container: QqbotNapcatRuntime;
expectedSelfId?: string;
mode: QqbotLoginScanMode;
qrcode?: string;
status: QqbotLoginScanStatus;
}): QqbotLoginScanSession {
const now = Date.now();
return {
accountId: input.accountId,
containerId: input.container.id,
containerName: input.container.name,
createdAt: now,
expectedSelfId: input.expectedSelfId,
expiresAt: now + this.getSessionTtlMs(),
id: randomUUID(),
mode: input.mode,
qrcode: input.qrcode,
status: input.status,
webuiPort: input.container.webuiPort,
};
}
private toResult(session: QqbotLoginScanSession): QqbotLoginScanResult {
return {
accountId: session.accountId,
containerId: session.containerId,
containerName: session.containerName,
errorMessage: session.errorMessage,
expiresAt: session.expiresAt,
mode: session.mode,
qrcode: session.qrcode,
sessionId: session.id,
status: session.status,
webuiPort: session.webuiPort,
};
}
private getSession(sessionId: string) {
const session = this.sessions.get(sessionId);
if (!session) {
throwVbenError('扫码会话不存在或已过期');
}
return session;
}
private async getSessionContainer(session: QqbotLoginScanSession) {
return this.containerService.findRuntimeById(session.containerId);
}
private cleanupSessions() {
const now = Date.now();
this.sessions.forEach((session, sessionId) => {
if (session.status !== 'pending' || now > session.expiresAt) {
this.sessions.delete(sessionId);
}
});
}
private async getLoginStatus(container: QqbotNapcatRuntime, retry = false) {
if (!retry) {
return this.postNapcat<NapcatLoginStatus>(
container,
'/api/QQLogin/CheckLoginStatus',
);
}
let lastError: unknown;
const attempts = Number(
this.configService.get('NAPCAT_WEBUI_READY_RETRIES') || 10,
);
for (let index = 0; index < attempts; index += 1) {
try {
return await this.postNapcat<NapcatLoginStatus>(
container,
'/api/QQLogin/CheckLoginStatus',
);
} catch (err) {
lastError = err;
if (!this.isTemporaryNapcatError(err)) break;
await this.sleep(1500);
}
}
throw lastError;
}
private async getLoginInfo(container: QqbotNapcatRuntime) {
return this.postNapcat<NapcatLoginInfo>(
container,
'/api/QQLogin/GetQQLoginInfo',
);
}
private async callRefreshQrcode(
container: QqbotNapcatRuntime,
retry = false,
) {
await this.executeNapcatRequest(retry, async () => {
try {
await this.postNapcat<null>(container, '/api/QQLogin/RefreshQRcode');
} catch (err) {
if (this.isAlreadyLoggedIn(err)) return;
throw err;
}
});
}
private async getQrcode(container: QqbotNapcatRuntime, retry = false) {
return this.executeNapcatRequest(retry, async () => {
try {
const data = await this.postNapcat<NapcatQrcode>(
container,
'/api/QQLogin/GetQQLoginQrcode',
);
if (!data.qrcode) {
throwVbenError('NapCat 未返回登录二维码');
}
return data.qrcode;
} catch (err) {
if (this.isAlreadyLoggedIn(err)) {
const status = await this.getLoginStatus(container);
return status.qrcodeurl || '';
}
throw err;
}
});
}
private async postNapcat<T>(
container: QqbotNapcatRuntime,
path: string,
body: Record<string, any> = {},
) {
const credential = await this.getCredential(container);
return this.requestNapcat<T>(container, path, body, credential);
}
private async getCredential(container: QqbotNapcatRuntime) {
const cacheKey = container.id || container.baseUrl;
const cached = this.credentials.get(cacheKey);
if (cached && Date.now() < cached.expiresAt) {
return cached.credential;
}
const token = this.getWebuiToken(container);
const hash = createHash('sha256').update(`${token}.napcat`).digest('hex');
const data = await this.requestNapcat<NapcatCredential>(
container,
'/api/auth/login',
{ hash },
);
if (!data.Credential) {
throwVbenError('NapCat WebUI 登录失败');
}
this.credentials.set(cacheKey, {
credential: data.Credential,
expiresAt: Date.now() + 50 * 60 * 1000,
});
return data.Credential;
}
private requestNapcat<T>(
container: QqbotNapcatRuntime,
path: string,
body: Record<string, any> = {},
credential?: string,
): Promise<T> {
const baseUrl = container.baseUrl;
const target = new URL(path, baseUrl);
const payload = JSON.stringify(body);
const client = target.protocol === 'https:' ? https : http;
return new Promise<T>((resolve, reject) => {
const req = client.request(
{
headers: {
...(credential
? {
Authorization: `Bearer ${credential}`,
}
: {}),
'Content-Length': Buffer.byteLength(payload),
'Content-Type': 'application/json',
},
hostname: target.hostname,
method: 'POST',
path: `${target.pathname}${target.search}`,
port: target.port,
protocol: target.protocol,
timeout: this.getTimeout(),
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
let result: NapcatApiResponse<T>;
try {
result = raw ? JSON.parse(raw) : ({ code: -1 } as any);
} catch {
reject(new Error('NapCat 返回非 JSON 响应'));
return;
}
if (result.code !== 0) {
reject(new Error(result.message || 'NapCat 请求失败'));
return;
}
resolve(result.data as T);
});
},
);
req.on('error', reject);
req.on('timeout', () => {
req.destroy(new Error('NapCat 请求超时'));
});
req.write(payload);
req.end();
}).catch((err): never => {
const message = this.getErrorMessage(err);
return throwVbenError(message || 'NapCat 请求失败');
});
}
private getWebuiToken(container: QqbotNapcatRuntime) {
const value = container.webuiToken || '';
const token = `${value}`.trim();
if (!token) {
throwVbenError('NapCat WebUI token 未配置');
}
return token;
}
private getSessionTtlMs() {
return Number(
this.configService.get('NAPCAT_LOGIN_QR_EXPIRE_MS') || 2 * 60 * 1000,
);
}
private getTimeout() {
return Number(this.configService.get('NAPCAT_WEBUI_TIMEOUT_MS') || 8000);
}
private getSelfId(info: NapcatLoginInfo) {
return `${info.uin || info.self_id || info.selfId || ''}`.trim();
}
private getNickname(info: NapcatLoginInfo) {
return `${info.nick || info.nickname || info.name || ''}`.trim();
}
private isAlreadyLoggedIn(err: unknown) {
return this.getErrorMessage(err).includes('QQ Is Logined');
}
private isTemporaryNapcatError(err: unknown) {
const message = this.getErrorMessage(err);
return [
'ECONNREFUSED',
'ECONNRESET',
'ETIMEDOUT',
'NapCat 请求超时',
'socket hang up',
].some((keyword) => message.includes(keyword));
}
private async executeNapcatRequest<T>(
retry: boolean,
action: () => Promise<T>,
) {
if (!retry) return action();
let lastError: unknown;
const attempts = Number(
this.configService.get('NAPCAT_WEBUI_READY_RETRIES') || 10,
);
for (let index = 0; index < attempts; index += 1) {
try {
return await action();
} catch (err) {
lastError = err;
if (!this.isTemporaryNapcatError(err)) break;
await this.sleep(1500);
}
}
throw lastError;
}
private getErrorMessage(err: unknown) {
const response = (err as any)?.getResponse?.();
if (typeof response?.msg === 'string') return response.msg;
if (typeof response?.message === 'string') return response.message;
return err instanceof Error ? err.message : `${err}`;
}
private sleep(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
}

View File

@ -253,17 +253,23 @@ export class QqbotReverseWsService
const account = await this.accountService.findEnabledBySelfIdWithToken(
selfId,
);
if (!account) {
return { ok: false as const, message: 'unknown account' };
}
const expectedToken =
account.accessToken ||
account?.accessToken ||
this.configService.get<string>('QQBOT_REVERSE_WS_TOKEN') ||
'';
if (expectedToken && token !== expectedToken) {
return { ok: false as const, message: 'invalid token' };
}
if (!account) {
const disabledAccount = await this.accountService.findBySelfId(selfId);
if (disabledAccount) {
return { ok: false as const, message: 'account disabled' };
}
if (!this.isAutoRegisterEnabled()) {
return { ok: false as const, message: 'unknown account' };
}
await this.accountService.ensureRuntimeAccount(selfId);
}
return { ok: true as const, role, selfId };
}
@ -299,6 +305,13 @@ export class QqbotReverseWsService
return `${this.configService.get('QQBOT_ENABLED') || 'false'}` === 'true';
}
private isAutoRegisterEnabled() {
return (
`${this.configService.get('QQBOT_AUTO_REGISTER_ACCOUNT') || 'true'}` ===
'true'
);
}
private isReversePath(request: IncomingMessage) {
const url = new URL(request.url || '', `http://${request.headers.host}`);
return url.pathname === this.getReversePath();

View File

@ -0,0 +1,56 @@
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
Index,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ensureSnowflakeId } from '@/common';
import type { QqbotAccountNapcatBindStatus } from '../qqbot.types';
@Entity('qqbot_account_napcat')
@Index('idx_qqbot_account_napcat_account', ['accountId', 'isDeleted'])
@Index('idx_qqbot_account_napcat_container', ['containerId', 'isDeleted'])
export class QqbotAccountNapcat {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ name: 'account_id', type: 'bigint' })
accountId: string;
@Column({ name: 'container_id', type: 'bigint' })
containerId: string;
@Column({ default: 'pending', length: 32, name: 'bind_status' })
bindStatus: QqbotAccountNapcatBindStatus;
@Column({ default: true, name: 'is_primary' })
isPrimary: boolean;
@Column({
default: null,
name: 'last_login_at',
nullable: true,
type: 'datetime',
})
lastLoginAt: Date | null;
@Column({ default: '', length: 255 })
remark: string;
@Column({ default: false, name: 'is_deleted' })
isDeleted: boolean;
@CreateDateColumn({ name: 'create_time' })
createTime: Date;
@UpdateDateColumn({ name: 'update_time' })
updateTime: Date;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}

View File

@ -0,0 +1,85 @@
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
Index,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ensureSnowflakeId } from '@/common';
import type { QqbotNapcatContainerStatus } from '../qqbot.types';
@Entity('qqbot_napcat_container')
@Index('uk_qqbot_napcat_container_name', ['name'], { unique: true })
@Index('idx_qqbot_napcat_container_status', ['status', 'isDeleted'])
export class QqbotNapcatContainer {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ length: 120 })
name: string;
@Column({ length: 255, name: 'base_url' })
baseUrl: string;
@Column({ name: 'webui_port', nullable: true, type: 'int' })
webuiPort: null | number;
@Column({
default: null,
length: 255,
name: 'webui_token',
nullable: true,
select: false,
})
webuiToken: null | string;
@Column({ default: '', length: 255 })
image: string;
@Column({ default: '', length: 500, name: 'data_dir' })
dataDir: string;
@Column({ default: '', length: 500, name: 'reverse_ws_url' })
reverseWsUrl: string;
@Column({ default: 'creating', length: 32 })
status: QqbotNapcatContainerStatus;
@Column({
default: null,
name: 'last_started_at',
nullable: true,
type: 'datetime',
})
lastStartedAt: Date | null;
@Column({
default: null,
name: 'last_checked_at',
nullable: true,
type: 'datetime',
})
lastCheckedAt: Date | null;
@Column({ default: null, length: 500, name: 'last_error', nullable: true })
lastError: null | string;
@Column({ default: '', length: 255 })
remark: string;
@Column({ default: false, name: 'is_deleted' })
isDeleted: boolean;
@CreateDateColumn({ name: 'create_time' })
createTime: Date;
@UpdateDateColumn({ name: 'update_time' })
updateTime: Date;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}

View File

@ -0,0 +1,451 @@
import { spawn } from 'child_process';
import { randomBytes, randomUUID } from 'crypto';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { throwVbenError } from '@/common';
import { QqbotAccount } from '../account/qqbot-account.entity';
import { QqbotAccountNapcat } from './qqbot-account-napcat.entity';
import { QqbotNapcatContainer } from './qqbot-napcat-container.entity';
export type QqbotNapcatRuntime = {
baseUrl: string;
id?: string;
name: string;
webuiPort?: null | number;
webuiToken?: null | string;
};
@Injectable()
export class QqbotNapcatContainerService {
constructor(
private readonly configService: ConfigService,
@InjectRepository(QqbotNapcatContainer)
private readonly containerRepository: Repository<QqbotNapcatContainer>,
@InjectRepository(QqbotAccountNapcat)
private readonly bindingRepository: Repository<QqbotAccountNapcat>,
) {}
async prepareCreateContainer() {
if (!this.isManagedMode()) {
return this.getLegacyRuntime();
}
return this.createManagedContainer();
}
async prepareAccountContainer(account: QqbotAccount) {
if (!this.isManagedMode()) {
return this.getLegacyRuntime();
}
const existing = await this.getPrimaryRuntime(account.id);
if (existing) return existing;
return this.createManagedContainer(account.selfId);
}
async findRuntimeById(containerId?: string) {
if (!containerId) return this.getLegacyRuntime();
const container = await this.containerRepository
.createQueryBuilder('container')
.addSelect('container.webuiToken')
.where('container.id = :containerId', { containerId })
.andWhere('container.isDeleted = :isDeleted', { isDeleted: false })
.getOne();
if (!container) {
throwVbenError('NapCat 容器不存在或已删除');
}
return this.toRuntime(container);
}
async bindAccount(accountId: string, containerId?: string) {
if (!containerId) return;
await this.bindingRepository.update(
{ accountId, isDeleted: false },
{ isPrimary: false },
);
const existing = await this.bindingRepository.findOne({
where: {
accountId,
containerId,
isDeleted: false,
},
});
if (existing) {
await this.bindingRepository.update(
{ id: existing.id },
{
bindStatus: 'bound',
isPrimary: true,
lastLoginAt: new Date(),
},
);
return;
}
await this.bindingRepository.save(
this.bindingRepository.create({
accountId,
bindStatus: 'bound',
containerId,
isPrimary: true,
lastLoginAt: new Date(),
remark: '',
}),
);
}
private async getPrimaryRuntime(accountId: string) {
const binding = await this.bindingRepository.findOne({
order: {
updateTime: 'DESC',
},
where: {
accountId,
bindStatus: 'bound',
isDeleted: false,
isPrimary: true,
},
});
if (!binding) return null;
const container = await this.containerRepository
.createQueryBuilder('container')
.addSelect('container.webuiToken')
.where('container.id = :containerId', {
containerId: binding.containerId,
})
.andWhere('container.isDeleted = :isDeleted', { isDeleted: false })
.andWhere('container.status != :status', { status: 'error' })
.getOne();
return container ? this.toRuntime(container) : null;
}
private async createManagedContainer(selfId?: string) {
const mode = this.getManagedMode();
if (mode !== 'ssh') {
throwVbenError('当前仅支持通过 SSH 创建 NapCat 容器');
}
const port = await this.allocatePort();
const name = this.buildContainerName(selfId);
const token = randomBytes(24).toString('hex');
const image = this.getConfig(
'QQBOT_NAPCAT_IMAGE',
'mlikiowa/napcat-docker:latest',
);
const dataDir = `${this.getRootDir()}/${name}`;
const baseUrl = this.buildBaseUrl(port);
const reverseWsUrl = this.buildReverseWsUrl();
const container = await this.containerRepository.save(
this.containerRepository.create({
baseUrl,
dataDir,
image,
isDeleted: false,
lastError: null,
name,
remark: '',
reverseWsUrl,
status: 'creating',
webuiPort: port,
webuiToken: token,
}),
);
try {
await this.createRemoteDockerContainer({
dataDir,
image,
name,
port,
reverseWsUrl,
token,
});
await this.containerRepository.update(
{ id: container.id },
{
lastError: null,
lastStartedAt: new Date(),
status: 'running',
},
);
return {
baseUrl,
id: container.id,
name,
webuiPort: port,
webuiToken: token,
};
} catch (err) {
const message = this.getErrorMessage(err);
await this.containerRepository.update(
{ id: container.id },
{
lastError: message,
status: 'error',
},
);
throwVbenError(`创建 NapCat 容器失败:${message}`);
}
}
private async createRemoteDockerContainer(input: {
dataDir: string;
image: string;
name: string;
port: number;
reverseWsUrl: string;
token: string;
}) {
const script = this.buildRemoteCreateScript(input);
await this.runProcess('ssh', [...this.getSshArgs(), 'sh -s'], script);
}
private buildRemoteCreateScript(input: {
dataDir: string;
image: string;
name: string;
port: number;
reverseWsUrl: string;
token: string;
}) {
const dataDir = this.sh(input.dataDir);
const image = this.sh(input.image);
const name = this.sh(input.name);
const reverseWsUrl = this.sh(input.reverseWsUrl);
const token = this.sh(input.token);
return `
set -eu
DATA_DIR=${dataDir}
IMAGE=${image}
NAME=${name}
PORT=${input.port}
REVERSE_WS_URL=${reverseWsUrl}
WEBUI_TOKEN=${token}
mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs"
chmod 700 "$DATA_DIR"
cat > "$DATA_DIR/config/webui.json" <<EOF
{
"host": "0.0.0.0",
"port": 6099,
"token": "$WEBUI_TOKEN",
"loginRate": 3
}
EOF
cat > "$DATA_DIR/config/onebot11.json" <<EOF
{
"network": {
"httpServers": [],
"httpClients": [],
"websocketServers": [],
"websocketClients": [
{
"name": "kt-template-online-api-reverse",
"enable": true,
"url": "$REVERSE_WS_URL",
"messagePostFormat": "array",
"reportSelfMessage": false,
"reconnectInterval": 5000,
"token": "",
"debug": false,
"heartInterval": 30000
}
]
},
"musicSignUrl": "",
"enableLocalFile2Url": false,
"parseMultMsg": false
}
EOF
docker pull "$IMAGE" >/dev/null
docker rm -f "$NAME" >/dev/null 2>&1 || true
docker run -d \\
--name "$NAME" \\
--restart unless-stopped \\
-e NAPCAT_UID=0 \\
-e NAPCAT_GID=0 \\
-e WEBUI_TOKEN="$WEBUI_TOKEN" \\
-p "$PORT:6099" \\
-v "$DATA_DIR/QQ:/app/.config/QQ" \\
-v "$DATA_DIR/config:/app/napcat/config" \\
-v "$DATA_DIR/plugins:/app/napcat/plugins" \\
"$IMAGE" >/dev/null
`;
}
private async allocatePort() {
const start = Number(this.getConfig('QQBOT_NAPCAT_PORT_START', '6100'));
const end = Number(this.getConfig('QQBOT_NAPCAT_PORT_END', '6199'));
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) {
throwVbenError('NapCat 端口池配置错误');
}
const containers = await this.containerRepository.find({
select: ['webuiPort'],
where: {
isDeleted: false,
},
});
const used = new Set(
containers
.map((container) => container.webuiPort)
.filter((port): port is number => typeof port === 'number'),
);
for (let port = start; port <= end; port += 1) {
if (!used.has(port)) return port;
}
throwVbenError('NapCat 端口池已用完');
}
private buildContainerName(selfId?: string) {
const prefix = this.getConfig(
'QQBOT_NAPCAT_CONTAINER_PREFIX',
'kt-qqbot-napcat',
);
const suffix = `${selfId || randomUUID().slice(0, 8)}`
.replace(/[^a-zA-Z0-9_.-]/g, '-')
.toLowerCase();
return `${prefix}-${suffix}`.replace(/-+/g, '-').slice(0, 120);
}
private buildBaseUrl(port: number) {
const template = this.getConfig('QQBOT_NAPCAT_BASE_URL_TEMPLATE', '');
if (template) {
return template.replace('{port}', `${port}`);
}
const host = this.getConfig('QQBOT_NAPCAT_HOST', '127.0.0.1');
return `http://${host}:${port}`;
}
private buildReverseWsUrl() {
const configured =
this.getConfig('QQBOT_NAPCAT_REVERSE_WS_URL', '') ||
this.getConfig('QQBOT_NAPCAT_REVERSE_WS_BASE', '');
const path = this.getConfig(
'QQBOT_REVERSE_WS_PATH',
'/qqbot/onebot/reverse',
);
const base = configured || `ws://127.0.0.1:48085${path}`;
const token = this.getConfig('QQBOT_REVERSE_WS_TOKEN', '');
if (!token || base.includes('token=')) return base;
const joiner = base.includes('?') ? '&' : '?';
return `${base}${joiner}token=${encodeURIComponent(token)}`;
}
private getLegacyRuntime(): QqbotNapcatRuntime {
return {
baseUrl: this.normalizeBaseUrl(
this.getConfig('NAPCAT_WEBUI_BASE_URL', '') ||
this.getConfig('QQBOT_NAPCAT_WEBUI_URL', ''),
),
name: 'kt-qqbot-napcat',
webuiToken:
this.getConfig('NAPCAT_WEBUI_TOKEN', '') ||
this.getConfig('QQBOT_NAPCAT_WEBUI_TOKEN', ''),
};
}
private toRuntime(container: QqbotNapcatContainer): QqbotNapcatRuntime {
return {
baseUrl: this.normalizeBaseUrl(container.baseUrl),
id: container.id,
name: container.name,
webuiPort: container.webuiPort,
webuiToken: container.webuiToken,
};
}
private normalizeBaseUrl(value: string) {
const baseUrl = `${value || ''}`.trim();
if (!baseUrl) {
throwVbenError('NapCat WebUI 地址未配置');
}
return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
}
private getRootDir() {
return this.getConfig(
'QQBOT_NAPCAT_ROOT',
'/vol1/docker/kt-qqbot/napcat-instances',
).replace(/\/+$/, '');
}
private isManagedMode() {
return !!this.getManagedMode();
}
private getManagedMode() {
return this.getConfig('QQBOT_NAPCAT_CONTAINER_MODE', '').toLowerCase();
}
private getSshArgs() {
const target = this.getConfig('QQBOT_NAPCAT_SSH_TARGET', 'nas');
if (!target) {
throwVbenError('NapCat SSH 目标未配置');
}
const args: string[] = [
'-o',
'StrictHostKeyChecking=accept-new',
'-o',
'UserKnownHostsFile=/tmp/qqbot-napcat-known-hosts',
];
const port = this.getConfig('QQBOT_NAPCAT_SSH_PORT', '');
const keyPath = this.getConfig('QQBOT_NAPCAT_SSH_KEY_PATH', '');
if (port) args.push('-p', port);
if (keyPath) args.push('-i', keyPath);
args.push(target);
return args;
}
private getConfig(key: string, defaultValue = '') {
return `${this.configService.get<string>(key) || defaultValue}`.trim();
}
private sh(value: string) {
return `'${`${value}`.replace(/'/g, `'\\''`)}'`;
}
private runProcess(command: string, args: string[], input: string) {
return new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
windowsHide: true,
});
let stdout = '';
let stderr = '';
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('close', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error((stderr || stdout || `${command} failed`).trim()));
});
child.stdin.write(input);
child.stdin.end();
});
}
private getErrorMessage(err: unknown) {
return err instanceof Error ? err.message : `${err}`;
}
}

View File

@ -5,6 +5,7 @@ import { AdminAuthGuardModule } from '@/admin/auth/admin-auth-guard.module';
import { QqbotAccountController } from './account/qqbot-account.controller';
import { QqbotAccount } from './account/qqbot-account.entity';
import { QqbotAccountService } from './account/qqbot-account.service';
import { QqbotNapcatLoginService } from './account/qqbot-napcat-login.service';
import { QqbotReverseWsService } from './connection/qqbot-reverse-ws.service';
import { QqbotDashboardController } from './dashboard/qqbot-dashboard.controller';
import { QqbotDashboardService } from './dashboard/qqbot-dashboard.service';
@ -16,6 +17,9 @@ import { QqbotMessageController } from './message/qqbot-message.controller';
import { QqbotMessage } from './message/qqbot-message.entity';
import { QqbotMessageService } from './message/qqbot-message.service';
import { QqbotBusService } from './mqtt/qqbot-bus.service';
import { QqbotAccountNapcat } from './napcat/qqbot-account-napcat.entity';
import { QqbotNapcatContainer } from './napcat/qqbot-napcat-container.entity';
import { QqbotNapcatContainerService } from './napcat/qqbot-napcat-container.service';
import { QqbotAllowlist } from './permission/qqbot-allowlist.entity';
import { QqbotBlocklist } from './permission/qqbot-blocklist.entity';
import { QqbotPermissionController } from './permission/qqbot-permission.controller';
@ -40,6 +44,8 @@ import { QqbotSendService } from './send/qqbot-send.service';
QqbotConversation,
QqbotDedupe,
QqbotMessage,
QqbotAccountNapcat,
QqbotNapcatContainer,
QqbotRule,
QqbotSendLog,
]),
@ -59,6 +65,8 @@ import { QqbotSendService } from './send/qqbot-send.service';
QqbotDedupeService,
QqbotEventService,
QqbotMessageService,
QqbotNapcatLoginService,
QqbotNapcatContainerService,
QqbotPermissionService,
QqbotRateLimitService,
QqbotReverseWsService,
@ -66,6 +74,11 @@ import { QqbotSendService } from './send/qqbot-send.service';
QqbotRuleService,
QqbotSendService,
],
exports: [QqbotAccountService, QqbotReverseWsService],
exports: [
QqbotAccountService,
QqbotNapcatLoginService,
QqbotNapcatContainerService,
QqbotReverseWsService,
],
})
export class QqbotModule {}

View File

@ -4,10 +4,22 @@ export type QqbotConnectionRole = 'API' | 'Event' | 'Universal';
export type QqbotConnectionStatus = 'offline' | 'online';
export type QqbotLoginScanMode = 'create' | 'refresh';
export type QqbotLoginScanStatus = 'error' | 'expired' | 'pending' | 'success';
export type QqbotMessageDirection = 'inbound' | 'outbound';
export type QqbotMessageType = 'group' | 'private';
export type QqbotNapcatContainerStatus =
| 'creating'
| 'error'
| 'running'
| 'stopped';
export type QqbotAccountNapcatBindStatus = 'bound' | 'disabled' | 'pending';
export type QqbotRuleMatchType = 'equals' | 'keyword' | 'regex';
export type QqbotRuleTargetType = 'all' | 'group' | 'private';