feat: 支持 QQBot NapCat 多容器扫码链路
This commit is contained in:
parent
391499928e
commit
6ed1eecb9a
18
.env.example
18
.env.example
@ -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=
|
||||
|
||||
@ -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 \
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 账号' })
|
||||
|
||||
@ -42,3 +42,8 @@ export class QqbotAccountQueryDto {
|
||||
@ApiPropertyOptional()
|
||||
connectStatus?: string;
|
||||
}
|
||||
|
||||
export class QqbotAccountScanStatusDto {
|
||||
@ApiProperty()
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
@ -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));
|
||||
|
||||
521
src/qqbot/account/qqbot-napcat-login.service.ts
Normal file
521
src/qqbot/account/qqbot-napcat-login.service.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
56
src/qqbot/napcat/qqbot-account-napcat.entity.ts
Normal file
56
src/qqbot/napcat/qqbot-account-napcat.entity.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
85
src/qqbot/napcat/qqbot-napcat-container.entity.ts
Normal file
85
src/qqbot/napcat/qqbot-napcat-container.entity.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
451
src/qqbot/napcat/qqbot-napcat-container.service.ts
Normal file
451
src/qqbot/napcat/qqbot-napcat-container.service.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user