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_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_REQUIRE_ALLOWLIST=true
|
QQBOT_REQUIRE_ALLOWLIST=true
|
||||||
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_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_URL=mqtt://127.0.0.1:1883
|
||||||
MQTT_USERNAME=
|
MQTT_USERNAME=
|
||||||
|
|||||||
@ -6,6 +6,10 @@ ENV NODE_ENV=production
|
|||||||
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
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 提前产出。
|
# 生产镜像只安装运行依赖,dist 由 Jenkins Build stage 提前产出。
|
||||||
# 跳过安装阶段脚本,避免 NODE_ENV=production 时 devDependency 中的 husky 不存在导致 prepare 失败。
|
# 跳过安装阶段脚本,避免 NODE_ENV=production 时 devDependency 中的 husky 不存在导致 prepare 失败。
|
||||||
RUN corepack enable \
|
RUN corepack enable \
|
||||||
|
|||||||
@ -31,10 +31,16 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- name: NODE_ENV
|
- name: NODE_ENV
|
||||||
value: production
|
value: production
|
||||||
|
- name: QQBOT_NAPCAT_SSH_KEY_PATH
|
||||||
|
value: /app/secrets/napcat-ssh/id_rsa
|
||||||
# Jenkins 每次发布会从 Agent 私有 .env.production 重建这个 Secret。
|
# Jenkins 每次发布会从 Agent 私有 .env.production 重建这个 Secret。
|
||||||
envFrom:
|
envFrom:
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: kt-template-online-api-env
|
name: kt-template-online-api-env
|
||||||
|
volumeMounts:
|
||||||
|
- name: napcat-ssh-key
|
||||||
|
mountPath: /app/secrets/napcat-ssh
|
||||||
|
readOnly: true
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
tcpSocket:
|
tcpSocket:
|
||||||
port: 48085
|
port: 48085
|
||||||
@ -56,6 +62,12 @@ spec:
|
|||||||
limits:
|
limits:
|
||||||
cpu: 1000m
|
cpu: 1000m
|
||||||
memory: 768Mi
|
memory: 768Mi
|
||||||
|
volumes:
|
||||||
|
- name: napcat-ssh-key
|
||||||
|
secret:
|
||||||
|
secretName: kt-qqbot-napcat-ssh-key
|
||||||
|
optional: true
|
||||||
|
defaultMode: 0400
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
|
|||||||
@ -25,6 +25,44 @@ CREATE TABLE IF NOT EXISTS `qqbot_account` (
|
|||||||
UNIQUE KEY `uk_qqbot_account_self_id` (`self_id`)
|
UNIQUE KEY `uk_qqbot_account_self_id` (`self_id`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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` (
|
CREATE TABLE IF NOT EXISTS `qqbot_rule` (
|
||||||
`id` bigint NOT NULL,
|
`id` bigint NOT NULL,
|
||||||
`name` varchar(120) NOT NULL DEFAULT '',
|
`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),
|
(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),
|
(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),
|
(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),
|
(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),
|
(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),
|
(2041700000000120412, 2041700000000100403, 'QqBotRuleEdit', NULL, NULL, NULL, 'QqBot:Rule:Edit', 'button', '{"title":"common.edit"}', 1, 0),
|
||||||
|
|||||||
@ -14,9 +14,11 @@ import { vbenSuccess } from '@/common';
|
|||||||
import {
|
import {
|
||||||
QqbotAccountBodyDto,
|
QqbotAccountBodyDto,
|
||||||
QqbotAccountQueryDto,
|
QqbotAccountQueryDto,
|
||||||
|
QqbotAccountScanStatusDto,
|
||||||
QqbotAccountUpdateDto,
|
QqbotAccountUpdateDto,
|
||||||
} from './qqbot-account.dto';
|
} from './qqbot-account.dto';
|
||||||
import { QqbotAccountService } from './qqbot-account.service';
|
import { QqbotAccountService } from './qqbot-account.service';
|
||||||
|
import { QqbotNapcatLoginService } from './qqbot-napcat-login.service';
|
||||||
import { QqbotReverseWsService } from '../connection/qqbot-reverse-ws.service';
|
import { QqbotReverseWsService } from '../connection/qqbot-reverse-ws.service';
|
||||||
|
|
||||||
@ApiTags('qqbot-account')
|
@ApiTags('qqbot-account')
|
||||||
@ -25,6 +27,7 @@ import { QqbotReverseWsService } from '../connection/qqbot-reverse-ws.service';
|
|||||||
export class QqbotAccountController {
|
export class QqbotAccountController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly accountService: QqbotAccountService,
|
private readonly accountService: QqbotAccountService,
|
||||||
|
private readonly napcatLoginService: QqbotNapcatLoginService,
|
||||||
private readonly reverseWsService: QqbotReverseWsService,
|
private readonly reverseWsService: QqbotReverseWsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -54,6 +57,43 @@ export class QqbotAccountController {
|
|||||||
return vbenSuccess(await this.accountService.update(body));
|
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')
|
@Post('delete')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: '删除 QQBot 账号' })
|
@ApiOperation({ summary: '删除 QQBot 账号' })
|
||||||
|
|||||||
@ -42,3 +42,8 @@ export class QqbotAccountQueryDto {
|
|||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
connectStatus?: string;
|
connectStatus?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class QqbotAccountScanStatusDto {
|
||||||
|
@ApiProperty()
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|||||||
@ -87,6 +87,100 @@ export class QqbotAccountService {
|
|||||||
.getOne();
|
.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) {
|
async save(body: QqbotAccountBodyDto) {
|
||||||
await this.assertSelfIdAvailable(body.selfId);
|
await this.assertSelfIdAvailable(body.selfId);
|
||||||
const account = this.accountRepository.create(this.normalizeBody(body));
|
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(
|
const account = await this.accountService.findEnabledBySelfIdWithToken(
|
||||||
selfId,
|
selfId,
|
||||||
);
|
);
|
||||||
if (!account) {
|
|
||||||
return { ok: false as const, message: 'unknown account' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const expectedToken =
|
const expectedToken =
|
||||||
account.accessToken ||
|
account?.accessToken ||
|
||||||
this.configService.get<string>('QQBOT_REVERSE_WS_TOKEN') ||
|
this.configService.get<string>('QQBOT_REVERSE_WS_TOKEN') ||
|
||||||
'';
|
'';
|
||||||
if (expectedToken && token !== expectedToken) {
|
if (expectedToken && token !== expectedToken) {
|
||||||
return { ok: false as const, message: 'invalid token' };
|
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 };
|
return { ok: true as const, role, selfId };
|
||||||
}
|
}
|
||||||
@ -299,6 +305,13 @@ export class QqbotReverseWsService
|
|||||||
return `${this.configService.get('QQBOT_ENABLED') || 'false'}` === 'true';
|
return `${this.configService.get('QQBOT_ENABLED') || 'false'}` === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isAutoRegisterEnabled() {
|
||||||
|
return (
|
||||||
|
`${this.configService.get('QQBOT_AUTO_REGISTER_ACCOUNT') || 'true'}` ===
|
||||||
|
'true'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private isReversePath(request: IncomingMessage) {
|
private isReversePath(request: IncomingMessage) {
|
||||||
const url = new URL(request.url || '', `http://${request.headers.host}`);
|
const url = new URL(request.url || '', `http://${request.headers.host}`);
|
||||||
return url.pathname === this.getReversePath();
|
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 { QqbotAccountController } from './account/qqbot-account.controller';
|
||||||
import { QqbotAccount } from './account/qqbot-account.entity';
|
import { QqbotAccount } from './account/qqbot-account.entity';
|
||||||
import { QqbotAccountService } from './account/qqbot-account.service';
|
import { QqbotAccountService } from './account/qqbot-account.service';
|
||||||
|
import { QqbotNapcatLoginService } from './account/qqbot-napcat-login.service';
|
||||||
import { QqbotReverseWsService } from './connection/qqbot-reverse-ws.service';
|
import { QqbotReverseWsService } from './connection/qqbot-reverse-ws.service';
|
||||||
import { QqbotDashboardController } from './dashboard/qqbot-dashboard.controller';
|
import { QqbotDashboardController } from './dashboard/qqbot-dashboard.controller';
|
||||||
import { QqbotDashboardService } from './dashboard/qqbot-dashboard.service';
|
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 { QqbotMessage } from './message/qqbot-message.entity';
|
||||||
import { QqbotMessageService } from './message/qqbot-message.service';
|
import { QqbotMessageService } from './message/qqbot-message.service';
|
||||||
import { QqbotBusService } from './mqtt/qqbot-bus.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 { QqbotAllowlist } from './permission/qqbot-allowlist.entity';
|
||||||
import { QqbotBlocklist } from './permission/qqbot-blocklist.entity';
|
import { QqbotBlocklist } from './permission/qqbot-blocklist.entity';
|
||||||
import { QqbotPermissionController } from './permission/qqbot-permission.controller';
|
import { QqbotPermissionController } from './permission/qqbot-permission.controller';
|
||||||
@ -40,6 +44,8 @@ import { QqbotSendService } from './send/qqbot-send.service';
|
|||||||
QqbotConversation,
|
QqbotConversation,
|
||||||
QqbotDedupe,
|
QqbotDedupe,
|
||||||
QqbotMessage,
|
QqbotMessage,
|
||||||
|
QqbotAccountNapcat,
|
||||||
|
QqbotNapcatContainer,
|
||||||
QqbotRule,
|
QqbotRule,
|
||||||
QqbotSendLog,
|
QqbotSendLog,
|
||||||
]),
|
]),
|
||||||
@ -59,6 +65,8 @@ import { QqbotSendService } from './send/qqbot-send.service';
|
|||||||
QqbotDedupeService,
|
QqbotDedupeService,
|
||||||
QqbotEventService,
|
QqbotEventService,
|
||||||
QqbotMessageService,
|
QqbotMessageService,
|
||||||
|
QqbotNapcatLoginService,
|
||||||
|
QqbotNapcatContainerService,
|
||||||
QqbotPermissionService,
|
QqbotPermissionService,
|
||||||
QqbotRateLimitService,
|
QqbotRateLimitService,
|
||||||
QqbotReverseWsService,
|
QqbotReverseWsService,
|
||||||
@ -66,6 +74,11 @@ import { QqbotSendService } from './send/qqbot-send.service';
|
|||||||
QqbotRuleService,
|
QqbotRuleService,
|
||||||
QqbotSendService,
|
QqbotSendService,
|
||||||
],
|
],
|
||||||
exports: [QqbotAccountService, QqbotReverseWsService],
|
exports: [
|
||||||
|
QqbotAccountService,
|
||||||
|
QqbotNapcatLoginService,
|
||||||
|
QqbotNapcatContainerService,
|
||||||
|
QqbotReverseWsService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class QqbotModule {}
|
export class QqbotModule {}
|
||||||
|
|||||||
@ -4,10 +4,22 @@ export type QqbotConnectionRole = 'API' | 'Event' | 'Universal';
|
|||||||
|
|
||||||
export type QqbotConnectionStatus = 'offline' | 'online';
|
export type QqbotConnectionStatus = 'offline' | 'online';
|
||||||
|
|
||||||
|
export type QqbotLoginScanMode = 'create' | 'refresh';
|
||||||
|
|
||||||
|
export type QqbotLoginScanStatus = 'error' | 'expired' | 'pending' | 'success';
|
||||||
|
|
||||||
export type QqbotMessageDirection = 'inbound' | 'outbound';
|
export type QqbotMessageDirection = 'inbound' | 'outbound';
|
||||||
|
|
||||||
export type QqbotMessageType = 'group' | 'private';
|
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 QqbotRuleMatchType = 'equals' | 'keyword' | 'regex';
|
||||||
|
|
||||||
export type QqbotRuleTargetType = 'all' | 'group' | 'private';
|
export type QqbotRuleTargetType = 'all' | 'group' | 'private';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user