diff --git a/.env.example b/.env.example index 0db3197..893c797 100644 --- a/.env.example +++ b/.env.example @@ -28,7 +28,7 @@ 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_REQUIRE_ALLOWLIST=false QQBOT_API_TIMEOUT_MS=10000 QQBOT_SEND_RATE_PER_SECOND=1 NAPCAT_WEBUI_BASE_URL=http://127.0.0.1:6099 diff --git a/sql/qqbot-init.sql b/sql/qqbot-init.sql index 1df5f4c..be089d4 100644 --- a/sql/qqbot-init.sql +++ b/sql/qqbot-init.sql @@ -63,6 +63,17 @@ CREATE TABLE IF NOT EXISTS `qqbot_account_napcat` ( 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_config` ( + `id` bigint NOT NULL, + `config_key` varchar(120) NOT NULL, + `config_value` text NOT NULL, + `remark` varchar(255) NOT NULL DEFAULT '', + `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_config_key` (`config_key`) +) 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 '', @@ -146,8 +157,10 @@ CREATE TABLE IF NOT EXISTS `qqbot_send_log` ( CREATE TABLE IF NOT EXISTS `qqbot_allowlist` ( `id` bigint NOT NULL, `self_id` varchar(64) NOT NULL DEFAULT '', - `target_type` varchar(32) NOT NULL DEFAULT 'all', + `target_type` varchar(32) NOT NULL DEFAULT 'qq', `target_id` varchar(64) NOT NULL DEFAULT '', + `user_id` varchar(64) NOT NULL DEFAULT '', + `precise_user` tinyint(1) NOT NULL DEFAULT 0, `enabled` tinyint(1) NOT NULL DEFAULT 1, `remark` varchar(255) NOT NULL DEFAULT '', `is_deleted` tinyint(1) NOT NULL DEFAULT 0, @@ -160,8 +173,10 @@ CREATE TABLE IF NOT EXISTS `qqbot_allowlist` ( CREATE TABLE IF NOT EXISTS `qqbot_blocklist` ( `id` bigint NOT NULL, `self_id` varchar(64) NOT NULL DEFAULT '', - `target_type` varchar(32) NOT NULL DEFAULT 'all', + `target_type` varchar(32) NOT NULL DEFAULT 'qq', `target_id` varchar(64) NOT NULL DEFAULT '', + `user_id` varchar(64) NOT NULL DEFAULT '', + `precise_user` tinyint(1) NOT NULL DEFAULT 0, `enabled` tinyint(1) NOT NULL DEFAULT 1, `remark` varchar(255) NOT NULL DEFAULT '', `is_deleted` tinyint(1) NOT NULL DEFAULT 0, @@ -181,6 +196,73 @@ CREATE TABLE IF NOT EXISTS `qqbot_dedupe` ( UNIQUE KEY `uk_qqbot_dedupe_event_key` (`event_key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +SET @qqbot_sql = ( + SELECT IF( + COUNT(*) = 0, + 'ALTER TABLE `qqbot_allowlist` ADD COLUMN `user_id` varchar(64) NOT NULL DEFAULT '''' AFTER `target_id`', + 'SELECT 1' + ) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'qqbot_allowlist' + AND column_name = 'user_id' +); +PREPARE qqbot_stmt FROM @qqbot_sql; +EXECUTE qqbot_stmt; +DEALLOCATE PREPARE qqbot_stmt; + +SET @qqbot_sql = ( + SELECT IF( + COUNT(*) = 0, + 'ALTER TABLE `qqbot_allowlist` ADD COLUMN `precise_user` tinyint(1) NOT NULL DEFAULT 0 AFTER `user_id`', + 'SELECT 1' + ) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'qqbot_allowlist' + AND column_name = 'precise_user' +); +PREPARE qqbot_stmt FROM @qqbot_sql; +EXECUTE qqbot_stmt; +DEALLOCATE PREPARE qqbot_stmt; + +SET @qqbot_sql = ( + SELECT IF( + COUNT(*) = 0, + 'ALTER TABLE `qqbot_blocklist` ADD COLUMN `user_id` varchar(64) NOT NULL DEFAULT '''' AFTER `target_id`', + 'SELECT 1' + ) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'qqbot_blocklist' + AND column_name = 'user_id' +); +PREPARE qqbot_stmt FROM @qqbot_sql; +EXECUTE qqbot_stmt; +DEALLOCATE PREPARE qqbot_stmt; + +SET @qqbot_sql = ( + SELECT IF( + COUNT(*) = 0, + 'ALTER TABLE `qqbot_blocklist` ADD COLUMN `precise_user` tinyint(1) NOT NULL DEFAULT 0 AFTER `user_id`', + 'SELECT 1' + ) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'qqbot_blocklist' + AND column_name = 'precise_user' +); +PREPARE qqbot_stmt FROM @qqbot_sql; +EXECUTE qqbot_stmt; +DEALLOCATE PREPARE qqbot_stmt; + +INSERT INTO `qqbot_config` (`id`, `config_key`, `config_value`, `remark`) +VALUES + (2041700000000200501, 'permission.allowlistEnabled', 'false', 'QQBot 白名单总开关'), + (2041700000000200502, 'permission.blocklistEnabled', 'true', 'QQBot 黑名单总开关') +ON DUPLICATE KEY UPDATE + `config_key` = VALUES(`config_key`); + INSERT INTO `admin_menu` (`id`, `pid`, `name`, `path`, `component`, `redirect`, `auth_code`, `type`, `meta`, `status`, `sort`) VALUES (2041700000000100400, 0, 'QqBot', '/qqbot', NULL, '/qqbot/dashboard', NULL, 'catalog', '{"icon":"lucide:bot","order":110,"title":"QQBot 管理"}', 1, 110), diff --git a/src/qqbot/account/qqbot-account.controller.ts b/src/qqbot/account/qqbot-account.controller.ts index c5501a5..ab58992 100644 --- a/src/qqbot/account/qqbot-account.controller.ts +++ b/src/qqbot/account/qqbot-account.controller.ts @@ -99,6 +99,8 @@ export class QqbotAccountController { @ApiOperation({ summary: '删除 QQBot 账号' }) @ApiQuery({ name: 'id', type: String }) async delete(@Query('id') id: string) { + const account = await this.accountService.findById(id); + if (account) await this.reverseWsService.kick(account.selfId); return vbenSuccess(await this.accountService.remove(id)); } diff --git a/src/qqbot/account/qqbot-account.service.ts b/src/qqbot/account/qqbot-account.service.ts index 8cd33cc..75389db 100644 --- a/src/qqbot/account/qqbot-account.service.ts +++ b/src/qqbot/account/qqbot-account.service.ts @@ -8,6 +8,7 @@ import type { QqbotAccountQueryDto, QqbotAccountUpdateDto, } from './qqbot-account.dto'; +import { QqbotNapcatContainerService } from '../napcat/qqbot-napcat-container.service'; import type { QqbotConnectionRole } from '../qqbot.types'; import { getPageParams, normalizeNullableString } from '../qqbot.utils'; @@ -16,6 +17,7 @@ export class QqbotAccountService { constructor( @InjectRepository(QqbotAccount) private readonly accountRepository: Repository, + private readonly napcatContainerService: QqbotNapcatContainerService, ) {} async page(query: QqbotAccountQueryDto) { @@ -202,14 +204,29 @@ export class QqbotAccountService { } async remove(id: string) { + const account = await this.accountRepository.findOne({ + where: { + id, + isDeleted: false, + }, + }); + if (!account) { + throwVbenError('QQBot 账号不存在或已删除'); + } + + const containerResult = + await this.napcatContainerService.removeAccountContainers(id); await this.accountRepository.update( { id }, { connectStatus: 'offline', + enabled: false, isDeleted: true, }, ); - return true; + return { + deletedContainers: containerResult.deletedContainers, + }; } async markOnline( diff --git a/src/qqbot/config/qqbot-config.entity.ts b/src/qqbot/config/qqbot-config.entity.ts new file mode 100644 index 0000000..a971693 --- /dev/null +++ b/src/qqbot/config/qqbot-config.entity.ts @@ -0,0 +1,37 @@ +import { + BeforeInsert, + Column, + CreateDateColumn, + Entity, + Index, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ensureSnowflakeId } from '@/common'; + +@Entity('qqbot_config') +@Index('uk_qqbot_config_key', ['configKey'], { unique: true }) +export class QqbotConfig { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ length: 120, name: 'config_key' }) + configKey: string; + + @Column({ name: 'config_value', type: 'text' }) + configValue: string; + + @Column({ default: '', length: 255 }) + remark: string; + + @CreateDateColumn({ name: 'create_time' }) + createTime: Date; + + @UpdateDateColumn({ name: 'update_time' }) + updateTime: Date; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/qqbot/config/qqbot-config.service.ts b/src/qqbot/config/qqbot-config.service.ts new file mode 100644 index 0000000..969efea --- /dev/null +++ b/src/qqbot/config/qqbot-config.service.ts @@ -0,0 +1,90 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { QqbotConfig } from './qqbot-config.entity'; + +const QQBOT_PERMISSION_CONFIG_KEYS = { + allowlistEnabled: 'permission.allowlistEnabled', + blocklistEnabled: 'permission.blocklistEnabled', +} as const; + +export type QqbotPermissionConfig = { + allowlistEnabled: boolean; + blocklistEnabled: boolean; +}; + +@Injectable() +export class QqbotConfigService { + constructor( + @InjectRepository(QqbotConfig) + private readonly configRepository: Repository, + ) {} + + async getPermissionConfig(): Promise { + const [allowlistEnabled, blocklistEnabled] = await Promise.all([ + this.getBoolean(QQBOT_PERMISSION_CONFIG_KEYS.allowlistEnabled, false), + this.getBoolean(QQBOT_PERMISSION_CONFIG_KEYS.blocklistEnabled, true), + ]); + + return { allowlistEnabled, blocklistEnabled }; + } + + async updatePermissionConfig( + config: Partial, + ): Promise { + const tasks: Array> = []; + + if (typeof config.allowlistEnabled === 'boolean') { + tasks.push( + this.setBoolean( + QQBOT_PERMISSION_CONFIG_KEYS.allowlistEnabled, + config.allowlistEnabled, + 'QQBot 白名单总开关', + ), + ); + } + if (typeof config.blocklistEnabled === 'boolean') { + tasks.push( + this.setBoolean( + QQBOT_PERMISSION_CONFIG_KEYS.blocklistEnabled, + config.blocklistEnabled, + 'QQBot 黑名单总开关', + ), + ); + } + + await Promise.all(tasks); + return this.getPermissionConfig(); + } + + private async getBoolean(configKey: string, defaultValue: boolean) { + const record = await this.configRepository.findOne({ + where: { configKey }, + }); + if (!record) return defaultValue; + return record.configValue === 'true'; + } + + private async setBoolean(configKey: string, value: boolean, remark: string) { + const exists = await this.configRepository.findOne({ + where: { configKey }, + }); + const configValue = value ? 'true' : 'false'; + + if (exists) { + await this.configRepository.update( + { id: exists.id }, + { configValue, remark }, + ); + return; + } + + await this.configRepository.save( + this.configRepository.create({ + configKey, + configValue, + remark, + }), + ); + } +} diff --git a/src/qqbot/event/qqbot-event-normalizer.ts b/src/qqbot/event/qqbot-event-normalizer.ts index 3c6ce42..e36a2d1 100644 --- a/src/qqbot/event/qqbot-event-normalizer.ts +++ b/src/qqbot/event/qqbot-event-normalizer.ts @@ -7,20 +7,31 @@ import { toStringId } from '../qqbot.utils'; export function isOneBotMessageEvent( payload: QqbotOneBotEvent, -): payload is QqbotOneBotEvent & { message_type: QqbotMessageType } { - return payload?.post_type === 'message' && !!payload.message_type; +): payload is QqbotOneBotEvent & { message_type: string } { + return ( + payload?.post_type === 'message' && + !!normalizeMessageType(payload.message_type) + ); } export function normalizeOneBotMessage( payload: QqbotOneBotEvent, ): QqbotNormalizedMessage { - const messageType = payload.message_type as QqbotMessageType; + const messageType = normalizeMessageType(payload.message_type) || 'private'; + const channelId = + toStringId(payload.channel_id) || toStringId(payload.guild_id) || undefined; const groupId = toStringId(payload.group_id) || undefined; const userId = toStringId(payload.user_id); - const targetId = messageType === 'group' ? groupId || '' : userId; + const targetId = + messageType === 'group' + ? groupId || '' + : messageType === 'channel' + ? channelId || '' + : userId; const messageText = extractMessageText(payload); return { + channelId, eventTime: payload.time ? new Date(Number(payload.time) * 1000) : new Date(), @@ -64,3 +75,13 @@ function extractMessageText(payload: QqbotOneBotEvent) { .join('') .trim(); } + +function normalizeMessageType(messageType?: string): QqbotMessageType | null { + if (messageType === 'private' || messageType === 'group') { + return messageType; + } + if (messageType === 'channel' || messageType === 'guild') { + return 'channel'; + } + return null; +} diff --git a/src/qqbot/message/qqbot-message.service.ts b/src/qqbot/message/qqbot-message.service.ts index edd363a..95d7791 100644 --- a/src/qqbot/message/qqbot-message.service.ts +++ b/src/qqbot/message/qqbot-message.service.ts @@ -7,7 +7,7 @@ import type { QqbotConversationQueryDto, QqbotMessageQueryDto, } from './qqbot-message.dto'; -import type { QqbotNormalizedMessage } from '../qqbot.types'; +import type { QqbotMessageType, QqbotNormalizedMessage } from '../qqbot.types'; import { getPageParams } from '../qqbot.utils'; @Injectable() @@ -110,7 +110,7 @@ export class QqbotMessageService { async saveOutgoing(params: { messageId?: string; messageText: string; - messageType: 'group' | 'private'; + messageType: QqbotMessageType; selfId: string; targetId: string; userId: string; diff --git a/src/qqbot/napcat/qqbot-napcat-container.service.ts b/src/qqbot/napcat/qqbot-napcat-container.service.ts index 2111a99..f01650f 100644 --- a/src/qqbot/napcat/qqbot-napcat-container.service.ts +++ b/src/qqbot/napcat/qqbot-napcat-container.service.ts @@ -100,6 +100,98 @@ export class QqbotNapcatContainerService { ); } + async removeAccountContainers(accountId: string) { + const bindings = await this.bindingRepository.find({ + where: { + accountId, + isDeleted: false, + }, + }); + if (bindings.length <= 0) return { deletedContainers: 0 }; + + let deletedContainers = 0; + for (const binding of bindings) { + const sharedCount = await this.bindingRepository + .createQueryBuilder('binding') + .where('binding.containerId = :containerId', { + containerId: binding.containerId, + }) + .andWhere('binding.accountId != :accountId', { accountId }) + .andWhere('binding.isDeleted = :isDeleted', { isDeleted: false }) + .getCount(); + if (sharedCount > 0) continue; + + const deleted = await this.removeContainer(binding.containerId); + if (deleted) deletedContainers += 1; + } + + await this.bindingRepository.update( + { accountId, isDeleted: false }, + { + bindStatus: 'disabled', + isDeleted: true, + isPrimary: false, + }, + ); + + return { deletedContainers }; + } + + private async removeContainer(containerId: string) { + const container = await this.containerRepository.findOne({ + where: { + id: containerId, + isDeleted: false, + }, + }); + if (!container) return false; + + if (this.getManagedMode() === 'ssh') { + await this.removeRemoteDockerContainer(container); + } + + await this.containerRepository.update( + { id: container.id }, + { + isDeleted: true, + lastError: null, + status: 'stopped', + }, + ); + return true; + } + + private async removeRemoteDockerContainer(container: QqbotNapcatContainer) { + const script = this.buildRemoteRemoveScript(container); + await this.runProcess('ssh', [...this.getSshArgs(), 'sh -s'], script); + } + + private buildRemoteRemoveScript(container: QqbotNapcatContainer) { + const dataDir = this.sh(container.dataDir || ''); + const name = this.sh(container.name); + const rootDir = this.sh(this.getRootDir()); + + return ` +set -eu +NAME=${name} +DATA_DIR=${dataDir} +ROOT_DIR=${rootDir} + +docker rm -f "$NAME" >/dev/null 2>&1 || true + +if [ -n "$DATA_DIR" ] && [ "$DATA_DIR" != "/" ]; then + case "$DATA_DIR" in + "$ROOT_DIR"/*) + rm -rf "$DATA_DIR" + ;; + *) + echo "skip unsafe data dir: $DATA_DIR" >&2 + ;; + esac +fi +`; + } + private async getPrimaryRuntime(accountId: string) { const binding = await this.bindingRepository.findOne({ order: { diff --git a/src/qqbot/permission/qqbot-allowlist.entity.ts b/src/qqbot/permission/qqbot-allowlist.entity.ts index bee498b..363161a 100644 --- a/src/qqbot/permission/qqbot-allowlist.entity.ts +++ b/src/qqbot/permission/qqbot-allowlist.entity.ts @@ -17,12 +17,18 @@ export class QqbotAllowlist { @Column({ default: '', length: 64, name: 'self_id' }) selfId: string; - @Column({ default: 'all', length: 32, name: 'target_type' }) + @Column({ default: 'qq', length: 32, name: 'target_type' }) targetType: QqbotPermissionTargetType; @Column({ default: '', length: 64, name: 'target_id' }) targetId: string; + @Column({ default: '', length: 64, name: 'user_id' }) + userId: string; + + @Column({ default: false, name: 'precise_user' }) + preciseUser: boolean; + @Column({ default: true }) enabled: boolean; diff --git a/src/qqbot/permission/qqbot-blocklist.entity.ts b/src/qqbot/permission/qqbot-blocklist.entity.ts index 204c937..b8b3794 100644 --- a/src/qqbot/permission/qqbot-blocklist.entity.ts +++ b/src/qqbot/permission/qqbot-blocklist.entity.ts @@ -17,12 +17,18 @@ export class QqbotBlocklist { @Column({ default: '', length: 64, name: 'self_id' }) selfId: string; - @Column({ default: 'all', length: 32, name: 'target_type' }) + @Column({ default: 'qq', length: 32, name: 'target_type' }) targetType: QqbotPermissionTargetType; @Column({ default: '', length: 64, name: 'target_id' }) targetId: string; + @Column({ default: '', length: 64, name: 'user_id' }) + userId: string; + + @Column({ default: false, name: 'precise_user' }) + preciseUser: boolean; + @Column({ default: true }) enabled: boolean; diff --git a/src/qqbot/permission/qqbot-permission.controller.ts b/src/qqbot/permission/qqbot-permission.controller.ts index b65f6aa..ba182c7 100644 --- a/src/qqbot/permission/qqbot-permission.controller.ts +++ b/src/qqbot/permission/qqbot-permission.controller.ts @@ -13,6 +13,7 @@ import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard'; import { vbenSuccess } from '@/common'; import { QqbotPermissionBodyDto, + QqbotPermissionConfigDto, QqbotPermissionQueryDto, QqbotPermissionUpdateDto, } from './qqbot-permission.dto'; @@ -24,6 +25,19 @@ import { QqbotPermissionService } from './qqbot-permission.service'; export class QqbotPermissionController { constructor(private readonly permissionService: QqbotPermissionService) {} + @Get('config') + @ApiOperation({ summary: 'QQBot 权限名单配置' }) + async config() { + return vbenSuccess(await this.permissionService.getConfig()); + } + + @Post('config') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '保存 QQBot 权限名单配置' }) + async updateConfig(@Body() body: QqbotPermissionConfigDto) { + return vbenSuccess(await this.permissionService.updateConfig(body)); + } + @Get('allowlist') @ApiOperation({ summary: 'QQBot 白名单分页' }) async allowlist(@Query() query: QqbotPermissionQueryDto) { diff --git a/src/qqbot/permission/qqbot-permission.dto.ts b/src/qqbot/permission/qqbot-permission.dto.ts index 73fe65d..32ac95b 100644 --- a/src/qqbot/permission/qqbot-permission.dto.ts +++ b/src/qqbot/permission/qqbot-permission.dto.ts @@ -1,16 +1,30 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; import type { QqbotPermissionTargetType } from '../qqbot.types'; +export class QqbotPermissionConfigDto { + @ApiPropertyOptional({ default: false }) + allowlistEnabled?: boolean; + + @ApiPropertyOptional({ default: true }) + blocklistEnabled?: boolean; +} + export class QqbotPermissionBodyDto { @ApiPropertyOptional({ example: '10000' }) selfId?: string; - @ApiProperty({ default: 'private' }) + @ApiProperty({ default: 'qq' }) targetType: QqbotPermissionTargetType; @ApiProperty({ example: '123456' }) targetId: string; + @ApiPropertyOptional({ example: '123456' }) + userId?: string; + + @ApiPropertyOptional({ default: false }) + preciseUser?: boolean; + @ApiPropertyOptional({ default: true }) enabled?: boolean; @@ -40,4 +54,10 @@ export class QqbotPermissionQueryDto { @ApiPropertyOptional() targetId?: string; + + @ApiPropertyOptional() + userId?: string; + + @ApiPropertyOptional() + preciseUser?: boolean; } diff --git a/src/qqbot/permission/qqbot-permission.service.ts b/src/qqbot/permission/qqbot-permission.service.ts index 3a52528..2fa9227 100644 --- a/src/qqbot/permission/qqbot-permission.service.ts +++ b/src/qqbot/permission/qqbot-permission.service.ts @@ -1,13 +1,16 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Brackets, Repository } from 'typeorm'; +import { throwVbenError } from '@/common'; import { QqbotAllowlist } from './qqbot-allowlist.entity'; import { QqbotBlocklist } from './qqbot-blocklist.entity'; import type { QqbotPermissionBodyDto, + QqbotPermissionConfigDto, QqbotPermissionQueryDto, QqbotPermissionUpdateDto, } from './qqbot-permission.dto'; +import { QqbotConfigService } from '../config/qqbot-config.service'; import type { QqbotNormalizedMessage } from '../qqbot.types'; import { getPageParams } from '../qqbot.utils'; @@ -17,12 +20,21 @@ type PermissionEntity = QqbotAllowlist | QqbotBlocklist; @Injectable() export class QqbotPermissionService { constructor( + private readonly configService: QqbotConfigService, @InjectRepository(QqbotAllowlist) private readonly allowlistRepository: Repository, @InjectRepository(QqbotBlocklist) private readonly blocklistRepository: Repository, ) {} + async getConfig() { + return this.configService.getPermissionConfig(); + } + + async updateConfig(body: QqbotPermissionConfigDto) { + return this.configService.updatePermissionConfig(body); + } + async page(kind: PermissionKind, query: QqbotPermissionQueryDto) { const { pageNo, pageSize, skip } = getPageParams(query); const repository = this.getRepository(kind); @@ -45,6 +57,16 @@ export class QqbotPermissionService { targetId: `%${query.targetId}%`, }); } + if (query.userId) { + builder.andWhere('permission.userId LIKE :userId', { + userId: `%${query.userId}%`, + }); + } + if (query.preciseUser !== undefined && `${query.preciseUser}` !== '') { + builder.andWhere('permission.preciseUser = :preciseUser', { + preciseUser: this.normalizeBoolean(query.preciseUser), + }); + } const [list, total] = await builder .orderBy('permission.createTime', 'DESC') @@ -56,13 +78,10 @@ export class QqbotPermissionService { async save(kind: PermissionKind, body: QqbotPermissionBodyDto) { const repository = this.getRepository(kind); + const payload = this.normalizeBody(body); const saved = await repository.save( repository.create({ - enabled: body.enabled ?? true, - remark: body.remark || '', - selfId: body.selfId || '', - targetId: body.targetId || '', - targetType: body.targetType || 'all', + ...payload, } as PermissionEntity), ); return saved.id; @@ -70,14 +89,11 @@ export class QqbotPermissionService { async update(kind: PermissionKind, body: QqbotPermissionUpdateDto) { const repository = this.getRepository(kind); + const payload = this.normalizeBody(body); await repository.update( { id: body.id } as any, { - enabled: body.enabled ?? true, - remark: body.remark || '', - selfId: body.selfId || '', - targetId: body.targetId || '', - targetType: body.targetType || 'all', + ...payload, } as any, ); return true; @@ -90,21 +106,14 @@ export class QqbotPermissionService { } async isBlocked(message: QqbotNormalizedMessage) { + const config = await this.configService.getPermissionConfig(); + if (!config.blocklistEnabled) return false; return this.existsMatched(this.blocklistRepository, message); } async isAllowed(message: QqbotNormalizedMessage) { - const requireAllowlist = - `${process.env.QQBOT_REQUIRE_ALLOWLIST ?? 'true'}` !== 'false'; - if (!requireAllowlist) return true; - - const hasRule = await this.allowlistRepository.count({ - where: { - enabled: true, - isDeleted: false, - }, - }); - if (hasRule <= 0) return false; + const config = await this.configService.getPermissionConfig(); + if (!config.allowlistEnabled) return true; return this.existsMatched(this.allowlistRepository, message); } @@ -121,17 +130,96 @@ export class QqbotPermissionService { selfId: message.selfId, }) .andWhere( - '(permission.targetType = :all OR (permission.targetType = :targetType AND permission.targetId = :targetId))', - { - all: 'all', - targetId: message.targetId, - targetType: message.messageType, - }, + new Brackets((qb) => { + qb.where('permission.targetType = :all', { all: 'all' }).orWhere( + '(permission.targetType IN (:...qqTargetTypes) AND permission.targetId = :userId)', + { + qqTargetTypes: ['qq', 'private'], + userId: message.userId, + }, + ); + + if (message.messageType === 'group') { + qb.orWhere( + `(permission.targetType = :groupType + AND permission.targetId = :targetId + AND ( + permission.preciseUser = :notPrecise + OR (permission.preciseUser = :precise AND permission.userId = :userId) + ))`, + { + groupType: 'group', + notPrecise: false, + precise: true, + targetId: message.targetId, + userId: message.userId, + }, + ); + } + + if (message.messageType === 'channel') { + qb.orWhere( + `(permission.targetType = :channelType + AND permission.targetId = :targetId + AND ( + permission.preciseUser = :notPrecise + OR (permission.preciseUser = :precise AND permission.userId = :userId) + ))`, + { + channelType: 'channel', + notPrecise: false, + precise: true, + targetId: message.targetId, + userId: message.userId, + }, + ); + } + }), ) .getCount(); return count > 0; } + private normalizeBody( + body: Partial, + ): Partial { + const targetType = body.targetType === 'private' ? 'qq' : body.targetType; + const normalizedTargetType = targetType || 'qq'; + const targetId = `${body.targetId || ''}`.trim(); + const userId = `${body.userId || ''}`.trim(); + const preciseUser = + normalizedTargetType === 'group' || normalizedTargetType === 'channel' + ? !!body.preciseUser + : false; + + if (!targetId) { + throwVbenError( + normalizedTargetType === 'qq' + ? '请填写 QQ 号' + : normalizedTargetType === 'group' + ? '请填写群号' + : '请填写频道 ID', + ); + } + if (preciseUser && !userId) { + throwVbenError('开启精确到 QQ 号后必须填写 QQ 号'); + } + + return { + enabled: body.enabled ?? true, + preciseUser, + remark: body.remark || '', + selfId: body.selfId || '', + targetId, + targetType: normalizedTargetType, + userId: preciseUser ? userId : '', + } as Partial; + } + + private normalizeBoolean(value: unknown) { + return value === true || value === 'true' || value === 1 || value === '1'; + } + private getRepository(kind: PermissionKind) { return kind === 'allowlist' ? this.allowlistRepository diff --git a/src/qqbot/qqbot.module.ts b/src/qqbot/qqbot.module.ts index 9a54017..e4cffe6 100644 --- a/src/qqbot/qqbot.module.ts +++ b/src/qqbot/qqbot.module.ts @@ -7,6 +7,8 @@ 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 { QqbotConfig } from './config/qqbot-config.entity'; +import { QqbotConfigService } from './config/qqbot-config.service'; import { QqbotDashboardController } from './dashboard/qqbot-dashboard.controller'; import { QqbotDashboardService } from './dashboard/qqbot-dashboard.service'; import { QqbotDedupe } from './dedupe/qqbot-dedupe.entity'; @@ -41,6 +43,7 @@ import { QqbotSendService } from './send/qqbot-send.service'; QqbotAccount, QqbotAllowlist, QqbotBlocklist, + QqbotConfig, QqbotConversation, QqbotDedupe, QqbotMessage, @@ -61,6 +64,7 @@ import { QqbotSendService } from './send/qqbot-send.service'; providers: [ QqbotAccountService, QqbotBusService, + QqbotConfigService, QqbotDashboardService, QqbotDedupeService, QqbotEventService, diff --git a/src/qqbot/qqbot.types.ts b/src/qqbot/qqbot.types.ts index a8d3934..b437e2f 100644 --- a/src/qqbot/qqbot.types.ts +++ b/src/qqbot/qqbot.types.ts @@ -10,7 +10,7 @@ export type QqbotLoginScanStatus = 'error' | 'expired' | 'pending' | 'success'; export type QqbotMessageDirection = 'inbound' | 'outbound'; -export type QqbotMessageType = 'group' | 'private'; +export type QqbotMessageType = 'channel' | 'group' | 'private'; export type QqbotNapcatContainerStatus = | 'creating' @@ -22,17 +22,19 @@ export type QqbotAccountNapcatBindStatus = 'bound' | 'disabled' | 'pending'; export type QqbotRuleMatchType = 'equals' | 'keyword' | 'regex'; -export type QqbotRuleTargetType = 'all' | 'group' | 'private'; +export type QqbotRuleTargetType = 'all' | 'channel' | 'group' | 'private'; export type QqbotSendStatus = 'failed' | 'pending' | 'success'; -export type QqbotPermissionTargetType = 'all' | 'group' | 'private'; +export type QqbotPermissionTargetType = 'channel' | 'group' | 'private' | 'qq'; export type QqbotOneBotEvent = Record & { + channel_id?: number | string; group_id?: number | string; + guild_id?: number | string; message?: any; message_id?: number | string; - message_type?: QqbotMessageType; + message_type?: string; post_type?: string; raw_message?: string; self_id?: number | string; @@ -42,6 +44,7 @@ export type QqbotOneBotEvent = Record & { }; export type QqbotNormalizedMessage = { + channelId?: string; eventTime: Date; groupId?: string; messageId: string; diff --git a/src/qqbot/rule/qqbot-rule-engine.service.ts b/src/qqbot/rule/qqbot-rule-engine.service.ts index 35c745b..6534919 100644 --- a/src/qqbot/rule/qqbot-rule-engine.service.ts +++ b/src/qqbot/rule/qqbot-rule-engine.service.ts @@ -26,6 +26,10 @@ export class QqbotRuleEngineService { await this.ruleService.markHit(rule); try { await this.sendService.sendText({ + channelId: message.channelId, + guildId: message.rawEvent.guild_id + ? `${message.rawEvent.guild_id}` + : undefined, message: rule.replyContent, selfId: message.selfId, targetId: message.targetId, diff --git a/src/qqbot/send/qqbot-send.service.ts b/src/qqbot/send/qqbot-send.service.ts index b90473a..611a7a1 100644 --- a/src/qqbot/send/qqbot-send.service.ts +++ b/src/qqbot/send/qqbot-send.service.ts @@ -78,6 +78,8 @@ export class QqbotSendService { } async sendText(params: { + channelId?: string; + guildId?: string; message: string; selfId?: string; targetId: string; @@ -90,12 +92,7 @@ export class QqbotSendService { this.rateLimitService.assertCanSend(account.selfId, params.targetId); - const action = - params.targetType === 'group' ? 'send_group_msg' : 'send_private_msg'; - const actionParams = - params.targetType === 'group' - ? { group_id: params.targetId, message: params.message } - : { message: params.message, user_id: params.targetId }; + const { action, actionParams } = this.buildAction(params); const log = await this.sendLogRepository.save( this.sendLogRepository.create({ @@ -175,4 +172,34 @@ export class QqbotSendService { strict: false, }); } + + private buildAction(params: { + channelId?: string; + guildId?: string; + message: string; + targetId: string; + targetType: QqbotMessageType; + }) { + if (params.targetType === 'group') { + return { + action: 'send_group_msg', + actionParams: { group_id: params.targetId, message: params.message }, + }; + } + if (params.targetType === 'channel') { + const actionParams: Record = { + channel_id: params.channelId || params.targetId, + message: params.message, + }; + if (params.guildId) actionParams.guild_id = params.guildId; + return { + action: 'send_guild_channel_msg', + actionParams, + }; + } + return { + action: 'send_private_msg', + actionParams: { message: params.message, user_id: params.targetId }, + }; + } }