From 7a93e49af6d2bf1bcc8e77d559d0aa359ae9b3d3 Mon Sep 17 00:00:00 2001 From: sunlei Date: Tue, 2 Jun 2026 19:21:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84QQBot=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E4=B8=8E=E8=B4=A6=E5=8F=B7=E8=83=BD=E5=8A=9B=E7=BB=91?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 + sql/qqbot-init.sql | 226 +++++++++++++++++- src/admin/menu/admin-menu.service.ts | 33 +++ .../account/qqbot-account-ability.entity.ts | 48 ++++ src/qqbot/account/qqbot-account.controller.ts | 52 ++++ src/qqbot/account/qqbot-account.service.ts | 144 +++++++++++ src/qqbot/command/qqbot-command.dto.ts | 3 + src/qqbot/command/qqbot-command.service.ts | 24 +- src/qqbot/config/qqbot-config.service.ts | 16 +- .../qqbot-event-plugin-registry.service.ts | 93 +++++++ .../plugin/qqbot-plugin-registry.service.ts | 12 +- src/qqbot/plugin/qqbot-plugin.controller.ts | 119 ++++++++- src/qqbot/plugin/qqbot-plugin.types.ts | 29 +++ .../plugins/repeater/qqbot-repeater.plugin.ts | 218 +++++++++++++++++ src/qqbot/qqbot.module.ts | 6 + src/qqbot/rule/qqbot-rule-engine.service.ts | 4 + src/qqbot/rule/qqbot-rule.dto.ts | 3 + src/qqbot/rule/qqbot-rule.service.ts | 19 +- 18 files changed, 1031 insertions(+), 22 deletions(-) create mode 100644 src/qqbot/account/qqbot-account-ability.entity.ts create mode 100644 src/qqbot/plugin/qqbot-event-plugin-registry.service.ts create mode 100644 src/qqbot/plugins/repeater/qqbot-repeater.plugin.ts diff --git a/.env.example b/.env.example index 06c48f1..7b7ec55 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,10 @@ QQBOT_REVERSE_WS_TOKEN= QQBOT_AUTO_REGISTER_ACCOUNT=true QQBOT_API_TIMEOUT_MS=10000 QQBOT_SEND_RATE_PER_SECOND=1 +QQBOT_REPEATER_THRESHOLD=2 +QQBOT_REPEATER_MAX_TEXT_LENGTH=300 +QQBOT_REPEATER_STATE_TTL_MS=600000 +QQBOT_REPEATER_CONFIG_CACHE_TTL_MS=2000 NAPCAT_WEBUI_BASE_URL=http://127.0.0.1:6099 NAPCAT_WEBUI_TOKEN= NAPCAT_WEBUI_TIMEOUT_MS=8000 diff --git a/sql/qqbot-init.sql b/sql/qqbot-init.sql index c8616fe..e0061e9 100644 --- a/sql/qqbot-init.sql +++ b/sql/qqbot-init.sql @@ -25,6 +25,20 @@ 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_account_ability` ( + `id` bigint NOT NULL, + `account_id` bigint NOT NULL, + `self_id` varchar(64) NOT NULL, + `ability_type` varchar(32) NOT NULL, + `ability_key` varchar(128) NOT NULL, + `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_account_ability` (`account_id`, `ability_type`, `ability_key`), + KEY `idx_qqbot_account_ability_self` (`self_id`, `ability_type`, `is_deleted`) +) 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, @@ -246,6 +260,192 @@ 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, + 'INSERT INTO `qqbot_account_ability` (`id`, `account_id`, `self_id`, `ability_type`, `ability_key`, `is_deleted`) + SELECT CAST((UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 - 1288834974657) * 4194304 + 100000 + ROW_NUMBER() OVER (ORDER BY `legacy`.`account_id`, `legacy`.`ability_key`) AS UNSIGNED) AS `id`, + `legacy`.`account_id`, + `legacy`.`self_id`, + ''command'' AS `ability_type`, + `legacy`.`ability_key`, + 0 AS `is_deleted` + FROM ( + SELECT `account`.`id` AS `account_id`, + `account`.`self_id`, + TRIM(CASE JSON_TYPE(`binding`.`raw_item`) + WHEN ''STRING'' THEN JSON_UNQUOTE(`binding`.`raw_item`) + WHEN ''OBJECT'' THEN COALESCE(JSON_UNQUOTE(JSON_EXTRACT(`binding`.`raw_item`, ''$.id'')), JSON_UNQUOTE(JSON_EXTRACT(`binding`.`raw_item`, ''$.key'')), '''') + ELSE '''' + END) AS `ability_key`, + COALESCE(JSON_UNQUOTE(JSON_EXTRACT(`binding`.`raw_item`, ''$.enabled'')), ''true'') AS `binding_enabled` + FROM `qqbot_account` `account` + JOIN JSON_TABLE(IF(JSON_VALID(`account`.`command_bindings`), `account`.`command_bindings`, ''[]''), ''$[*]'' COLUMNS (`raw_item` json PATH ''$'')) AS `binding` + WHERE `account`.`is_deleted` = 0 + ) `legacy` + WHERE `legacy`.`ability_key` <> '''' + AND `legacy`.`binding_enabled` <> ''false'' + ON DUPLICATE KEY UPDATE `self_id` = VALUES(`self_id`), `is_deleted` = 0', + 'SELECT 1' + ) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'qqbot_account' + AND column_name = 'command_bindings' +); +PREPARE qqbot_stmt FROM @qqbot_sql; +EXECUTE qqbot_stmt; +DEALLOCATE PREPARE qqbot_stmt; + +SET @qqbot_sql = ( + SELECT IF( + COUNT(*) > 0, + 'INSERT INTO `qqbot_account_ability` (`id`, `account_id`, `self_id`, `ability_type`, `ability_key`, `is_deleted`) + SELECT CAST((UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 - 1288834974657) * 4194304 + 200000 + ROW_NUMBER() OVER (ORDER BY `legacy`.`account_id`, `legacy`.`ability_key`) AS UNSIGNED) AS `id`, + `legacy`.`account_id`, + `legacy`.`self_id`, + ''rule'' AS `ability_type`, + `legacy`.`ability_key`, + 0 AS `is_deleted` + FROM ( + SELECT `account`.`id` AS `account_id`, + `account`.`self_id`, + TRIM(CASE JSON_TYPE(`binding`.`raw_item`) + WHEN ''STRING'' THEN JSON_UNQUOTE(`binding`.`raw_item`) + WHEN ''OBJECT'' THEN COALESCE(JSON_UNQUOTE(JSON_EXTRACT(`binding`.`raw_item`, ''$.id'')), JSON_UNQUOTE(JSON_EXTRACT(`binding`.`raw_item`, ''$.key'')), '''') + ELSE '''' + END) AS `ability_key`, + COALESCE(JSON_UNQUOTE(JSON_EXTRACT(`binding`.`raw_item`, ''$.enabled'')), ''true'') AS `binding_enabled` + FROM `qqbot_account` `account` + JOIN JSON_TABLE(IF(JSON_VALID(`account`.`rule_bindings`), `account`.`rule_bindings`, ''[]''), ''$[*]'' COLUMNS (`raw_item` json PATH ''$'')) AS `binding` + WHERE `account`.`is_deleted` = 0 + ) `legacy` + WHERE `legacy`.`ability_key` <> '''' + AND `legacy`.`binding_enabled` <> ''false'' + ON DUPLICATE KEY UPDATE `self_id` = VALUES(`self_id`), `is_deleted` = 0', + 'SELECT 1' + ) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'qqbot_account' + AND column_name = 'rule_bindings' +); +PREPARE qqbot_stmt FROM @qqbot_sql; +EXECUTE qqbot_stmt; +DEALLOCATE PREPARE qqbot_stmt; + +SET @qqbot_sql = ( + SELECT IF( + COUNT(*) > 0, + 'INSERT INTO `qqbot_account_ability` (`id`, `account_id`, `self_id`, `ability_type`, `ability_key`, `is_deleted`) + SELECT CAST((UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 - 1288834974657) * 4194304 + 300000 + ROW_NUMBER() OVER (ORDER BY `legacy`.`account_id`, `legacy`.`ability_key`) AS UNSIGNED) AS `id`, + `legacy`.`account_id`, + `legacy`.`self_id`, + ''event_plugin'' AS `ability_type`, + `legacy`.`ability_key`, + 0 AS `is_deleted` + FROM ( + SELECT `account`.`id` AS `account_id`, + `account`.`self_id`, + TRIM(CASE JSON_TYPE(`binding`.`raw_item`) + WHEN ''STRING'' THEN JSON_UNQUOTE(`binding`.`raw_item`) + WHEN ''OBJECT'' THEN COALESCE(JSON_UNQUOTE(JSON_EXTRACT(`binding`.`raw_item`, ''$.id'')), JSON_UNQUOTE(JSON_EXTRACT(`binding`.`raw_item`, ''$.key'')), '''') + ELSE '''' + END) AS `ability_key`, + COALESCE(JSON_UNQUOTE(JSON_EXTRACT(`binding`.`raw_item`, ''$.enabled'')), ''true'') AS `binding_enabled` + FROM `qqbot_account` `account` + JOIN JSON_TABLE(IF(JSON_VALID(`account`.`event_plugin_bindings`), `account`.`event_plugin_bindings`, ''[]''), ''$[*]'' COLUMNS (`raw_item` json PATH ''$'')) AS `binding` + WHERE `account`.`is_deleted` = 0 + ) `legacy` + WHERE `legacy`.`ability_key` <> '''' + AND `legacy`.`binding_enabled` <> ''false'' + ON DUPLICATE KEY UPDATE `self_id` = VALUES(`self_id`), `is_deleted` = 0', + 'SELECT 1' + ) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'qqbot_account' + AND column_name = 'event_plugin_bindings' +); +PREPARE qqbot_stmt FROM @qqbot_sql; +EXECUTE qqbot_stmt; +DEALLOCATE PREPARE qqbot_stmt; + +SET @qqbot_sql = ( + SELECT IF( + COUNT(*) > 0, + 'ALTER TABLE `qqbot_rule` DROP COLUMN `self_id`', + 'SELECT 1' + ) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'qqbot_rule' + AND column_name = 'self_id' +); +PREPARE qqbot_stmt FROM @qqbot_sql; +EXECUTE qqbot_stmt; +DEALLOCATE PREPARE qqbot_stmt; + +SET @qqbot_sql = ( + SELECT IF( + COUNT(*) > 0, + 'ALTER TABLE `qqbot_command` DROP COLUMN `self_id`', + 'SELECT 1' + ) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'qqbot_command' + AND column_name = 'self_id' +); +PREPARE qqbot_stmt FROM @qqbot_sql; +EXECUTE qqbot_stmt; +DEALLOCATE PREPARE qqbot_stmt; + +SET @qqbot_sql = ( + SELECT IF( + COUNT(*) > 0, + 'ALTER TABLE `qqbot_account` DROP COLUMN `command_bindings`', + 'SELECT 1' + ) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'qqbot_account' + AND column_name = 'command_bindings' +); +PREPARE qqbot_stmt FROM @qqbot_sql; +EXECUTE qqbot_stmt; +DEALLOCATE PREPARE qqbot_stmt; + +SET @qqbot_sql = ( + SELECT IF( + COUNT(*) > 0, + 'ALTER TABLE `qqbot_account` DROP COLUMN `rule_bindings`', + 'SELECT 1' + ) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'qqbot_account' + AND column_name = 'rule_bindings' +); +PREPARE qqbot_stmt FROM @qqbot_sql; +EXECUTE qqbot_stmt; +DEALLOCATE PREPARE qqbot_stmt; + +SET @qqbot_sql = ( + SELECT IF( + COUNT(*) > 0, + 'ALTER TABLE `qqbot_account` DROP COLUMN `event_plugin_bindings`', + 'SELECT 1' + ) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'qqbot_account' + AND column_name = 'event_plugin_bindings' +); +PREPARE qqbot_stmt FROM @qqbot_sql; +EXECUTE qqbot_stmt; +DEALLOCATE PREPARE qqbot_stmt; + SET @qqbot_sql = ( SELECT IF( COUNT(*) = 0, @@ -315,26 +515,45 @@ ON DUPLICATE KEY UPDATE INSERT INTO `qqbot_command` (`id`, `code`, `name`, `aliases`, `prefixes`, `plugin_key`, `operation_key`, `parser_key`, `target_type`, `default_params`, `reply_template`, `error_template`, `enabled`, `priority`, `cooldown_ms`, `remark`) VALUES - (2041700000000300501, 'ff14_price', 'FF14 查价', '["查价","price","ff14price"]', '["/","!","!"]', 'ff14Market', 'ff14.market.price', 'ff14Price', 'all', '{"language":"chs","world":"中国"}', '', 'FF14 查价失败:{{error}}', 0, 0, 1500, '默认示例命令;默认查询范围为中国,可按需改为具体服务器') + (2041700000000300501, 'ff14_price', 'FF14 查价', '["查价","price","ff14price"]', '["/","!","!"]', 'ff14Market', 'ff14.market.price', 'ff14Price', 'all', '{"language":"chs","world":"中国"}', '', 'FF14 查价失败:{{error}}', 1, 0, 1500, '默认示例命令;请在账号配置中绑定后启用') ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `plugin_key` = VALUES(`plugin_key`), `operation_key` = VALUES(`operation_key`), `parser_key` = VALUES(`parser_key`), `target_type` = VALUES(`target_type`), + `enabled` = VALUES(`enabled`), `remark` = VALUES(`remark`), `is_deleted` = 0; +UPDATE `admin_menu` +SET `name` = 'QqBotAccountConfigButton' +WHERE `id` = 2041700000000120406 + AND `name` = 'QqBotAccountConfig'; + +INSERT INTO `admin_dict` (`id`, `dict_code`, `label`, `value`, `children_code`, `sort`, `status`) +VALUES + (2041700000000300401, 'QQBOT_PLUGIN_TRIGGER_MODE', '命令', 'command', NULL, 1, 1), + (2041700000000300402, 'QQBOT_PLUGIN_TRIGGER_MODE', '事件', 'event', NULL, 2, 1) +ON DUPLICATE KEY UPDATE + `label` = VALUES(`label`), + `children_code` = VALUES(`children_code`), + `sort` = VALUES(`sort`), + `status` = VALUES(`status`), + `is_deleted` = 0; + 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), (2041700000000100401, 2041700000000100400, 'QqBotDashboard', '/qqbot/dashboard', '/qqbot/dashboard/list', NULL, 'QqBot:Dashboard:List', 'menu', '{"icon":"lucide:gauge","title":"工作台"}', 1, 0), (2041700000000100402, 2041700000000100400, 'QqBotAccount', '/qqbot/account', '/qqbot/account/list', NULL, 'QqBot:Account:List', 'menu', '{"icon":"lucide:radio-receiver","title":"账号连接"}', 1, 1), + (2041700000000100410, 2041700000000100400, 'QqBotAccountConfig', '/qqbot/account/config', '/qqbot/account/config', NULL, 'QqBot:Account:Config', 'menu', '{"activePath":"/qqbot/account","hideInMenu":true,"title":"账号功能配置"}', 1, 0), (2041700000000120401, 2041700000000100402, 'QqBotAccountCreate', NULL, NULL, NULL, 'QqBot:Account:Create', 'button', '{"title":"common.create"}', 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), (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), + (2041700000000120406, 2041700000000100402, 'QqBotAccountConfigButton', NULL, NULL, NULL, 'QqBot:Account:Config', '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), @@ -368,6 +587,11 @@ ON DUPLICATE KEY UPDATE `sort` = VALUES(`sort`), `is_deleted` = 0; +UPDATE `admin_menu` +SET `status` = 0, + `is_deleted` = 1 +WHERE `name` IN ('QqBotEventPlugin', 'QqBotEventPluginToggle'); + INSERT IGNORE INTO `admin_role_menu` (`role_id`, `menu_id`) SELECT role.`id`, menu.`id` FROM `admin_role` role diff --git a/src/admin/menu/admin-menu.service.ts b/src/admin/menu/admin-menu.service.ts index 9495d38..580275c 100644 --- a/src/admin/menu/admin-menu.service.ts +++ b/src/admin/menu/admin-menu.service.ts @@ -115,6 +115,39 @@ export class AdminMenuService { .filter((menu) => !menu.isDeleted && menu.status === 1) .forEach((menu) => menuMap.set(menu.id, menu)); }); + return this.includeAncestorMenus([...menuMap.values()]); + } + + private async includeAncestorMenus(menus: AdminMenu[]) { + const menuMap = new Map(); + menus.forEach((menu) => menuMap.set(menu.id, menu)); + + const pendingParentIds = new Set(); + const collectMissingParent = (pid?: null | string) => { + if (!pid || pid === '0' || menuMap.has(pid)) return; + pendingParentIds.add(pid); + }; + + menus.forEach((menu) => collectMissingParent(menu.pid)); + + while (pendingParentIds.size > 0) { + const ids = [...pendingParentIds]; + pendingParentIds.clear(); + const parents = await this.menuRepository.find({ + where: { + id: In(ids), + isDeleted: false, + status: 1, + }, + }); + + parents.forEach((parent) => { + if (menuMap.has(parent.id)) return; + menuMap.set(parent.id, parent); + collectMissingParent(parent.pid); + }); + } + return [...menuMap.values()]; } diff --git a/src/qqbot/account/qqbot-account-ability.entity.ts b/src/qqbot/account/qqbot-account-ability.entity.ts new file mode 100644 index 0000000..bb1acb2 --- /dev/null +++ b/src/qqbot/account/qqbot-account-ability.entity.ts @@ -0,0 +1,48 @@ +import { + BeforeInsert, + Column, + CreateDateColumn, + Entity, + Index, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ensureSnowflakeId } from '@/common'; + +export type QqbotAccountAbilityType = 'command' | 'event_plugin' | 'rule'; + +@Entity('qqbot_account_ability') +@Index('uk_qqbot_account_ability', ['accountId', 'abilityType', 'abilityKey'], { + unique: true, +}) +@Index('idx_qqbot_account_ability_self', ['selfId', 'abilityType', 'isDeleted']) +export class QqbotAccountAbility { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ name: 'account_id', type: 'bigint' }) + accountId: string; + + @Column({ length: 64, name: 'self_id' }) + selfId: string; + + @Column({ length: 32, name: 'ability_type' }) + abilityType: QqbotAccountAbilityType; + + @Column({ length: 128, name: 'ability_key' }) + abilityKey: 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); + } +} diff --git a/src/qqbot/account/qqbot-account.controller.ts b/src/qqbot/account/qqbot-account.controller.ts index 0fec251..69caedc 100644 --- a/src/qqbot/account/qqbot-account.controller.ts +++ b/src/qqbot/account/qqbot-account.controller.ts @@ -104,6 +104,58 @@ export class QqbotAccountController { return vbenSuccess(await this.accountService.remove(id)); } + @Post('bind/command') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '绑定账号在线命令' }) + @ApiQuery({ name: 'selfId', type: String }) + @ApiQuery({ name: 'commandId', type: String }) + async bindCommand( + @Query('selfId') selfId: string, + @Query('commandId') commandId: string, + ) { + return vbenSuccess( + await this.accountService.bindCommand(selfId, commandId), + ); + } + + @Post('unbind/command') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '解绑账号在线命令' }) + @ApiQuery({ name: 'selfId', type: String }) + @ApiQuery({ name: 'commandId', type: String }) + async unbindCommand( + @Query('selfId') selfId: string, + @Query('commandId') commandId: string, + ) { + return vbenSuccess( + await this.accountService.unbindCommand(selfId, commandId), + ); + } + + @Post('bind/rule') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '绑定账号自动回复规则' }) + @ApiQuery({ name: 'selfId', type: String }) + @ApiQuery({ name: 'ruleId', type: String }) + async bindRule( + @Query('selfId') selfId: string, + @Query('ruleId') ruleId: string, + ) { + return vbenSuccess(await this.accountService.bindRule(selfId, ruleId)); + } + + @Post('unbind/rule') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '解绑账号自动回复规则' }) + @ApiQuery({ name: 'selfId', type: String }) + @ApiQuery({ name: 'ruleId', type: String }) + async unbindRule( + @Query('selfId') selfId: string, + @Query('ruleId') ruleId: string, + ) { + return vbenSuccess(await this.accountService.unbindRule(selfId, ruleId)); + } + @Post('kick') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '断开 QQBot 反向 WS 会话' }) diff --git a/src/qqbot/account/qqbot-account.service.ts b/src/qqbot/account/qqbot-account.service.ts index 3eef5d4..d53ad15 100644 --- a/src/qqbot/account/qqbot-account.service.ts +++ b/src/qqbot/account/qqbot-account.service.ts @@ -2,6 +2,10 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { throwVbenError } from '@/common'; +import { + QqbotAccountAbility, + type QqbotAccountAbilityType, +} from './qqbot-account-ability.entity'; import { QqbotAccount } from './qqbot-account.entity'; import type { QqbotAccountBodyDto, @@ -17,6 +21,8 @@ export class QqbotAccountService { constructor( @InjectRepository(QqbotAccount) private readonly accountRepository: Repository, + @InjectRepository(QqbotAccountAbility) + private readonly accountAbilityRepository: Repository, private readonly napcatContainerService: QqbotNapcatContainerService, ) {} @@ -60,6 +66,42 @@ export class QqbotAccountService { }); } + async getBoundCommandIds(selfId: string) { + return this.getBoundAbilityKeys(selfId, 'command'); + } + + async getBoundRuleIds(selfId: string) { + return this.getBoundAbilityKeys(selfId, 'rule'); + } + + async getBoundEventPluginKeys(selfId: string) { + return this.getBoundAbilityKeys(selfId, 'event_plugin'); + } + + async bindCommand(selfId: string, commandId: string) { + return this.bindAbility(selfId, commandId, 'command'); + } + + async bindRule(selfId: string, ruleId: string) { + return this.bindAbility(selfId, ruleId, 'rule'); + } + + async bindEventPlugin(selfId: string, pluginKey: string) { + return this.bindAbility(selfId, pluginKey, 'event_plugin'); + } + + async unbindCommand(selfId: string, commandId: string) { + return this.unbindAbility(selfId, commandId, 'command'); + } + + async unbindRule(selfId: string, ruleId: string) { + return this.unbindAbility(selfId, ruleId, 'rule'); + } + + async unbindEventPlugin(selfId: string, pluginKey: string) { + return this.unbindAbility(selfId, pluginKey, 'event_plugin'); + } + async getDefaultAccount(selfId?: string) { if (selfId) { const account = await this.accountRepository.findOne({ @@ -134,6 +176,10 @@ export class QqbotAccountService { if (existing) { await this.accountRepository.update({ id: existing.id }, payload); + await this.accountAbilityRepository.update( + { accountId: existing.id }, + { selfId }, + ); return existing.id; } @@ -204,6 +250,12 @@ export class QqbotAccountService { delete payload.accessToken; } await this.accountRepository.update({ id: body.id }, payload); + if (payload.selfId) { + await this.accountAbilityRepository.update( + { accountId: body.id }, + { selfId: payload.selfId }, + ); + } return true; } @@ -228,6 +280,10 @@ export class QqbotAccountService { isDeleted: true, }, ); + await this.accountAbilityRepository.update( + { accountId: id }, + { isDeleted: true }, + ); return { deletedContainers: containerResult.deletedContainers, }; @@ -319,4 +375,92 @@ export class QqbotAccountService { typeof body.selfId === 'string' ? body.selfId.trim() : body.selfId, } as Partial; } + + private async bindAbility( + selfId: string, + abilityKey: string, + type: QqbotAccountAbilityType, + ) { + const account = await this.assertConfigurableAccount(selfId); + const normalizedKey = this.normalizeAbilityId(abilityKey); + const existing = await this.accountAbilityRepository.findOne({ + where: { + abilityKey: normalizedKey, + abilityType: type, + accountId: account.id, + }, + }); + + if (existing) { + await this.accountAbilityRepository.update( + { id: existing.id }, + { isDeleted: false, selfId: account.selfId }, + ); + return true; + } + + await this.accountAbilityRepository.save( + this.accountAbilityRepository.create({ + abilityKey: normalizedKey, + abilityType: type, + accountId: account.id, + isDeleted: false, + selfId: account.selfId, + }), + ); + return true; + } + + private async unbindAbility( + selfId: string, + abilityKey: string, + type: QqbotAccountAbilityType, + ) { + const account = await this.assertConfigurableAccount(selfId); + const normalizedKey = this.normalizeAbilityId(abilityKey); + await this.accountAbilityRepository.update( + { + abilityKey: normalizedKey, + abilityType: type, + accountId: account.id, + }, + { isDeleted: true, selfId: account.selfId }, + ); + return true; + } + + private async assertConfigurableAccount(selfId: string) { + const normalizedSelfId = `${selfId || ''}`.trim(); + if (!normalizedSelfId) throwVbenError('请选择所属 QQBot 账号'); + const account = await this.findBySelfId(normalizedSelfId); + if (!account || !account.enabled) { + throwVbenError(`QQBot 账号不存在或已停用:${normalizedSelfId}`); + } + return account; + } + + private normalizeAbilityId(abilityId: string) { + const normalizedId = `${abilityId || ''}`.trim(); + if (!normalizedId) throwVbenError('绑定能力 ID 不能为空'); + return normalizedId; + } + + private async getBoundAbilityKeys( + selfId: string, + abilityType: QqbotAccountAbilityType, + ) { + const account = await this.findBySelfId(`${selfId || ''}`.trim()); + if (!account || !account.enabled || account.isDeleted) return []; + const bindings = await this.accountAbilityRepository.find({ + order: { + createTime: 'ASC', + }, + where: { + abilityType, + accountId: account.id, + isDeleted: false, + }, + }); + return bindings.map((item) => item.abilityKey); + } } diff --git a/src/qqbot/command/qqbot-command.dto.ts b/src/qqbot/command/qqbot-command.dto.ts index 104c811..5e39118 100644 --- a/src/qqbot/command/qqbot-command.dto.ts +++ b/src/qqbot/command/qqbot-command.dto.ts @@ -17,6 +17,9 @@ export class QqbotCommandQueryDto implements QqbotPageQuery { @ApiPropertyOptional() keyword?: string; + @ApiPropertyOptional() + selfId?: string; + @ApiPropertyOptional() pluginKey?: string; diff --git a/src/qqbot/command/qqbot-command.service.ts b/src/qqbot/command/qqbot-command.service.ts index e957085..f6a6a33 100644 --- a/src/qqbot/command/qqbot-command.service.ts +++ b/src/qqbot/command/qqbot-command.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Not, Repository } from 'typeorm'; import { throwVbenError } from '@/common'; +import { QqbotAccountService } from '../account/qqbot-account.service'; import { QqbotPluginRegistryService } from '../plugin/qqbot-plugin-registry.service'; import type { QqbotCommandParserType, @@ -24,6 +25,7 @@ export class QqbotCommandService { private readonly commandRepository: Repository, @InjectRepository(QqbotCommandLog) private readonly commandLogRepository: Repository, + private readonly accountService: QqbotAccountService, private readonly pluginRegistry: QqbotPluginRegistryService, ) {} @@ -39,6 +41,15 @@ export class QqbotCommandService { { keyword: `%${query.keyword}%` }, ); } + if (query.selfId) { + const boundIds = await this.accountService.getBoundCommandIds( + query.selfId, + ); + if (boundIds.length === 0) { + return { list: [], pageNo, pageSize, total: 0 }; + } + builder.andWhere('command.id IN (:...boundIds)', { boundIds }); + } if (query.pluginKey) { builder.andWhere('command.pluginKey = :pluginKey', { pluginKey: query.pluginKey, @@ -75,10 +86,15 @@ export class QqbotCommandService { } async listEnabledForMessage(message: QqbotNormalizedMessage) { + const boundIds = await this.accountService.getBoundCommandIds( + message.selfId, + ); + if (boundIds.length === 0) return []; return this.commandRepository .createQueryBuilder('command') .where('command.isDeleted = :isDeleted', { isDeleted: false }) .andWhere('command.enabled = :enabled', { enabled: true }) + .andWhere('command.id IN (:...boundIds)', { boundIds }) .andWhere('command.targetType IN (:...targetTypes)', { targetTypes: ['all', message.messageType], }) @@ -97,7 +113,7 @@ export class QqbotCommandService { async save(body: QqbotCommandBodyDto) { const payload = await this.normalizeBody(body); - await this.assertCodeAvailable(payload.code); + await this.assertCodeAvailable(payload.code || ''); const saved = await this.commandRepository.save( this.commandRepository.create(payload), ); @@ -110,7 +126,7 @@ export class QqbotCommandService { ...this.toRawBody(current), ...body, }); - await this.assertCodeAvailable(payload.code, body.id); + await this.assertCodeAvailable(payload.code || '', body.id); await this.commandRepository.update({ id: body.id }, payload); return true; } @@ -218,9 +234,7 @@ export class QqbotCommandService { private stringifyList(value: string[] | string | undefined, fallback = []) { const list = Array.isArray(value) ? value - : `${value || ''}` - .split(',') - .map((item) => item.trim()); + : `${value || ''}`.split(',').map((item) => item.trim()); const normalized = list .map((item) => `${item || ''}`.trim()) .filter(Boolean); diff --git a/src/qqbot/config/qqbot-config.service.ts b/src/qqbot/config/qqbot-config.service.ts index 969efea..c7fe50d 100644 --- a/src/qqbot/config/qqbot-config.service.ts +++ b/src/qqbot/config/qqbot-config.service.ts @@ -22,8 +22,8 @@ export class QqbotConfigService { 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), + this.getBooleanConfig(QQBOT_PERMISSION_CONFIG_KEYS.allowlistEnabled, false), + this.getBooleanConfig(QQBOT_PERMISSION_CONFIG_KEYS.blocklistEnabled, true), ]); return { allowlistEnabled, blocklistEnabled }; @@ -36,7 +36,7 @@ export class QqbotConfigService { if (typeof config.allowlistEnabled === 'boolean') { tasks.push( - this.setBoolean( + this.setBooleanConfig( QQBOT_PERMISSION_CONFIG_KEYS.allowlistEnabled, config.allowlistEnabled, 'QQBot 白名单总开关', @@ -45,7 +45,7 @@ export class QqbotConfigService { } if (typeof config.blocklistEnabled === 'boolean') { tasks.push( - this.setBoolean( + this.setBooleanConfig( QQBOT_PERMISSION_CONFIG_KEYS.blocklistEnabled, config.blocklistEnabled, 'QQBot 黑名单总开关', @@ -57,7 +57,7 @@ export class QqbotConfigService { return this.getPermissionConfig(); } - private async getBoolean(configKey: string, defaultValue: boolean) { + async getBooleanConfig(configKey: string, defaultValue: boolean) { const record = await this.configRepository.findOne({ where: { configKey }, }); @@ -65,7 +65,11 @@ export class QqbotConfigService { return record.configValue === 'true'; } - private async setBoolean(configKey: string, value: boolean, remark: string) { + async setBooleanConfig( + configKey: string, + value: boolean, + remark: string, + ) { const exists = await this.configRepository.findOne({ where: { configKey }, }); diff --git a/src/qqbot/plugin/qqbot-event-plugin-registry.service.ts b/src/qqbot/plugin/qqbot-event-plugin-registry.service.ts new file mode 100644 index 0000000..75c8500 --- /dev/null +++ b/src/qqbot/plugin/qqbot-event-plugin-registry.service.ts @@ -0,0 +1,93 @@ +import { Injectable } from '@nestjs/common'; +import { throwVbenError } from '@/common'; +import { QqbotAccountService } from '../account/qqbot-account.service'; +import { QqbotRepeaterPluginService } from '../plugins/repeater/qqbot-repeater.plugin'; +import type { + QqbotEventPluginDefinition, + QqbotPluginHealth, + QqbotPluginOperationSummary, +} from './qqbot-plugin.types'; + +@Injectable() +export class QqbotEventPluginRegistryService { + constructor( + private readonly accountService: QqbotAccountService, + private readonly repeaterPlugin: QqbotRepeaterPluginService, + ) {} + + listDefinitions(pluginKey?: string): QqbotEventPluginDefinition[] { + return this.getDefinitions(pluginKey); + } + + async listPlugins(selfId?: string) { + const accounts = selfId + ? [await this.accountService.findBySelfId(selfId)] + : await this.accountService.allEnabled(); + return Promise.all( + accounts + .filter((account): account is NonNullable => !!account) + .map((account) => + this.repeaterPlugin.getSummary({ + accountName: account.name, + connectStatus: account.connectStatus, + selfId: account.selfId, + }), + ), + ); + } + + listOperations(pluginKey?: string): QqbotPluginOperationSummary[] { + return this.getDefinitions(pluginKey).map((definition) => ({ + description: definition.description, + inputSchema: { + triggerType: definition.triggerType, + }, + key: definition.triggerType, + name: definition.triggerType === 'message' ? '消息事件' : definition.name, + outputSchema: undefined, + pluginKey: definition.key, + triggerMode: 'event', + })); + } + + async health(pluginKey?: string): Promise { + return this.getDefinitions(pluginKey).map((definition) => ({ + checkedAt: new Date().toISOString(), + message: definition.remark || '事件插件由账号配置绑定后触发', + name: definition.name, + pluginKey: definition.key, + status: 'healthy', + triggerMode: 'event', + })); + } + + async bind(pluginKey: string, selfId: string) { + this.assertPlugin(pluginKey); + if (pluginKey === 'repeater') { + return this.repeaterPlugin.bind(selfId); + } + await this.accountService.bindEventPlugin(selfId, pluginKey); + return true; + } + + async unbind(pluginKey: string, selfId: string) { + this.assertPlugin(pluginKey); + if (pluginKey === 'repeater') { + return this.repeaterPlugin.unbind(selfId); + } + await this.accountService.unbindEventPlugin(selfId, pluginKey); + return true; + } + + private assertPlugin(pluginKey: string) { + if (pluginKey === 'repeater') return; + throwVbenError(`QQBot 事件插件不存在:${pluginKey}`); + } + + private getDefinitions(pluginKey?: string): QqbotEventPluginDefinition[] { + const definitions = [this.repeaterPlugin.getDefinition()]; + return pluginKey + ? definitions.filter((definition) => definition.key === pluginKey) + : definitions; + } +} diff --git a/src/qqbot/plugin/qqbot-plugin-registry.service.ts b/src/qqbot/plugin/qqbot-plugin-registry.service.ts index dc36bc8..484176f 100644 --- a/src/qqbot/plugin/qqbot-plugin-registry.service.ts +++ b/src/qqbot/plugin/qqbot-plugin-registry.service.ts @@ -32,6 +32,7 @@ export class QqbotPluginRegistryService implements OnModuleInit { key: plugin.key, name: plugin.name, operationCount: plugin.operations.length, + triggerMode: 'command', version: plugin.version, })); } @@ -46,6 +47,7 @@ export class QqbotPluginRegistryService implements OnModuleInit { name: operation.name, outputSchema: operation.outputSchema, pluginKey: plugin.key, + triggerMode: 'command', })), ); } @@ -58,10 +60,18 @@ export class QqbotPluginRegistryService implements OnModuleInit { return { checkedAt: new Date().toISOString(), message: '插件未提供健康检查', + name: plugin.name, + pluginKey: plugin.key, status: 'healthy', + triggerMode: 'command' as const, }; } - return plugin.healthCheck(); + return { + ...(await plugin.healthCheck()), + name: plugin.name, + pluginKey: plugin.key, + triggerMode: 'command' as const, + }; }), ); } diff --git a/src/qqbot/plugin/qqbot-plugin.controller.ts b/src/qqbot/plugin/qqbot-plugin.controller.ts index 0d670d9..631d1db 100644 --- a/src/qqbot/plugin/qqbot-plugin.controller.ts +++ b/src/qqbot/plugin/qqbot-plugin.controller.ts @@ -1,32 +1,135 @@ -import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Query, + UseGuards, +} from '@nestjs/common'; import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard'; import { vbenSuccess } from '@/common'; +import { QqbotEventPluginRegistryService } from './qqbot-event-plugin-registry.service'; import { QqbotPluginRegistryService } from './qqbot-plugin-registry.service'; +import type { QqbotPluginTriggerMode } from './qqbot-plugin.types'; @ApiTags('qqbot-plugin') @Controller('qqbot/plugin') @UseGuards(JwtAuthGuard) export class QqbotPluginController { - constructor(private readonly pluginRegistry: QqbotPluginRegistryService) {} + constructor( + private readonly eventPluginRegistry: QqbotEventPluginRegistryService, + private readonly pluginRegistry: QqbotPluginRegistryService, + ) {} @Get('list') @ApiOperation({ summary: 'QQBot 插件列表' }) - async list() { - return vbenSuccess(this.pluginRegistry.listPlugins()); + @ApiQuery({ + enum: ['command', 'event'], + name: 'triggerMode', + required: false, + }) + async list(@Query('triggerMode') triggerMode?: QqbotPluginTriggerMode) { + return vbenSuccess([ + ...(this.includesTriggerMode('command', triggerMode) + ? this.pluginRegistry.listPlugins() + : []), + ...(this.includesTriggerMode('event', triggerMode) + ? this.eventPluginRegistry.listDefinitions().map((definition) => ({ + description: definition.description, + key: definition.key, + name: definition.name, + operationCount: 1, + triggerMode: 'event' as const, + version: definition.version, + })) + : []), + ]); } @Get('operation/list') @ApiOperation({ summary: 'QQBot 插件能力列表' }) @ApiQuery({ name: 'pluginKey', required: false, type: String }) - async operationList(@Query('pluginKey') pluginKey?: string) { - return vbenSuccess(this.pluginRegistry.listOperations(pluginKey)); + @ApiQuery({ + enum: ['command', 'event'], + name: 'triggerMode', + required: false, + }) + async operationList( + @Query('pluginKey') pluginKey?: string, + @Query('triggerMode') triggerMode?: QqbotPluginTriggerMode, + ) { + return vbenSuccess([ + ...(this.includesTriggerMode('command', triggerMode) + ? this.pluginRegistry.listOperations(pluginKey) + : []), + ...(this.includesTriggerMode('event', triggerMode) + ? this.eventPluginRegistry.listOperations(pluginKey) + : []), + ]); } @Get('health') @ApiOperation({ summary: 'QQBot 插件健康检查' }) @ApiQuery({ name: 'pluginKey', required: false, type: String }) - async health(@Query('pluginKey') pluginKey?: string) { - return vbenSuccess(await this.pluginRegistry.health(pluginKey)); + @ApiQuery({ + enum: ['command', 'event'], + name: 'triggerMode', + required: false, + }) + async health( + @Query('pluginKey') pluginKey?: string, + @Query('triggerMode') triggerMode?: QqbotPluginTriggerMode, + ) { + const [commandHealth, eventHealth] = await Promise.all([ + this.includesTriggerMode('command', triggerMode) + ? this.pluginRegistry.health(pluginKey) + : Promise.resolve([]), + this.includesTriggerMode('event', triggerMode) + ? this.eventPluginRegistry.health(pluginKey) + : Promise.resolve([]), + ]); + return vbenSuccess([...commandHealth, ...eventHealth]); + } + + @Get('event/list') + @ApiOperation({ summary: 'QQBot 事件触发插件列表' }) + @ApiQuery({ name: 'selfId', required: false, type: String }) + async eventList(@Query('selfId') selfId?: string) { + return vbenSuccess(await this.eventPluginRegistry.listPlugins(selfId)); + } + + @Post('event/bind') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '绑定 QQBot 事件触发插件' }) + @ApiQuery({ name: 'pluginKey', type: String }) + @ApiQuery({ name: 'selfId', type: String }) + async eventBind( + @Query('pluginKey') pluginKey: string, + @Query('selfId') selfId: string, + ) { + return vbenSuccess(await this.eventPluginRegistry.bind(pluginKey, selfId)); + } + + @Post('event/unbind') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '解绑 QQBot 事件触发插件' }) + @ApiQuery({ name: 'pluginKey', type: String }) + @ApiQuery({ name: 'selfId', type: String }) + async eventUnbind( + @Query('pluginKey') pluginKey: string, + @Query('selfId') selfId: string, + ) { + return vbenSuccess( + await this.eventPluginRegistry.unbind(pluginKey, selfId), + ); + } + + private includesTriggerMode( + target: QqbotPluginTriggerMode, + triggerMode?: QqbotPluginTriggerMode, + ) { + return !triggerMode || triggerMode === target; } } diff --git a/src/qqbot/plugin/qqbot-plugin.types.ts b/src/qqbot/plugin/qqbot-plugin.types.ts index 3fdc556..60b25ac 100644 --- a/src/qqbot/plugin/qqbot-plugin.types.ts +++ b/src/qqbot/plugin/qqbot-plugin.types.ts @@ -7,9 +7,14 @@ import type { QqbotCommand } from '../command/qqbot-command.entity'; export type QqbotPluginHealth = { checkedAt: string; message?: string; + name?: string; + pluginKey?: string; status: QqbotPluginHealthStatus; + triggerMode?: QqbotPluginTriggerMode; }; +export type QqbotPluginTriggerMode = 'command' | 'event'; + export type QqbotPluginOperationContext = { args?: Record; command?: QqbotCommand; @@ -38,11 +43,34 @@ export type QqbotIntegrationPlugin = { version: string; }; +export type QqbotEventPluginSummary = { + accountName?: string; + bound: boolean; + connectStatus?: string; + description?: string; + key: string; + name: string; + remark?: string; + selfId: string; + triggerType: 'message'; + version: string; +}; + +export type QqbotEventPluginDefinition = { + description?: string; + key: string; + name: string; + remark?: string; + triggerType: 'message'; + version: string; +}; + export type QqbotPluginSummary = { description?: string; key: string; name: string; operationCount: number; + triggerMode: QqbotPluginTriggerMode; version: string; }; @@ -54,4 +82,5 @@ export type QqbotPluginOperationSummary = { name: string; outputSchema?: Record; pluginKey: string; + triggerMode: QqbotPluginTriggerMode; }; diff --git a/src/qqbot/plugins/repeater/qqbot-repeater.plugin.ts b/src/qqbot/plugins/repeater/qqbot-repeater.plugin.ts new file mode 100644 index 0000000..bfd58b0 --- /dev/null +++ b/src/qqbot/plugins/repeater/qqbot-repeater.plugin.ts @@ -0,0 +1,218 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { QqbotAccountService } from '../../account/qqbot-account.service'; +import type { + QqbotEventPluginDefinition, + QqbotEventPluginSummary, +} from '../../plugin/qqbot-plugin.types'; +import type { QqbotNormalizedMessage } from '../../qqbot.types'; +import { QqbotSendService } from '../../send/qqbot-send.service'; + +const QQBOT_REPEATER_VERSION = '1.0.0'; +const QQBOT_REPEATER_PLUGIN_KEY = 'repeater'; + +type QqbotRepeaterConversationState = { + count: number; + lastText: string; + repeatedText: string; + updatedAt: number; +}; + +@Injectable() +export class QqbotRepeaterPluginService { + private readonly logger = new Logger(QqbotRepeaterPluginService.name); + private readonly states = new Map(); + private readonly boundCache = new Map< + string, + { + expiresAt: number; + value: boolean; + } + >(); + private readonly pluginDescription = + '监听同一会话内连续重复的普通文本消息,达到阈值后自动复读一次。'; + private readonly pluginName = '复读机'; + private readonly pluginRemark = + '连续重复达到阈值后触发;命令、CQ 码和机器人自身消息不触发。'; + + constructor( + private readonly configService: ConfigService, + private readonly accountService: QqbotAccountService, + private readonly sendService: QqbotSendService, + ) {} + + async getSummary(params: { + accountName?: string; + connectStatus?: string; + selfId: string; + }): Promise { + const definition = this.getDefinition(); + return { + accountName: params.accountName, + bound: await this.isBound(params.selfId), + connectStatus: params.connectStatus, + description: definition.description, + key: definition.key, + name: definition.name, + remark: definition.remark, + selfId: params.selfId, + triggerType: definition.triggerType, + version: definition.version, + }; + } + + getDefinition(): QqbotEventPluginDefinition { + return { + description: this.pluginDescription, + key: QQBOT_REPEATER_PLUGIN_KEY, + name: this.pluginName, + remark: this.pluginRemark, + triggerType: 'message', + version: QQBOT_REPEATER_VERSION, + }; + } + + async bind(selfId: string) { + await this.accountService.bindEventPlugin( + selfId, + QQBOT_REPEATER_PLUGIN_KEY, + ); + this.clearBoundCache(selfId); + return this.getSummary({ selfId }); + } + + async unbind(selfId: string) { + await this.accountService.unbindEventPlugin( + selfId, + QQBOT_REPEATER_PLUGIN_KEY, + ); + this.clearBoundCache(selfId); + return this.getSummary({ selfId }); + } + + clearBoundCache(selfId: string) { + this.boundCache.delete(`${selfId || ''}`.trim()); + } + + async handleMessage(message: QqbotNormalizedMessage) { + if (!(await this.isBound(message.selfId))) return false; + const text = this.normalizeText(message.messageText); + if (!this.canRepeat(message, text)) { + this.resetState(message); + return false; + } + + const key = this.buildStateKey(message); + const state = this.getNextState(key, text); + if (!this.shouldRepeat(state, text)) return false; + + state.repeatedText = text; + try { + await this.sendService.sendText({ + channelId: message.channelId, + guildId: message.rawEvent.guild_id + ? `${message.rawEvent.guild_id}` + : undefined, + message: text, + selfId: message.selfId, + targetId: message.targetId, + targetType: message.messageType, + }); + return true; + } catch (err) { + const errMsg = err instanceof Error ? err.message : '复读失败'; + this.logger.warn(`QQBot 复读机发送失败: ${errMsg}`); + return false; + } + } + + private async isBound(selfId: string) { + const normalizedSelfId = `${selfId || ''}`.trim(); + if (!normalizedSelfId) return false; + const now = Date.now(); + const cached = this.boundCache.get(normalizedSelfId); + if (cached && cached.expiresAt > now) { + return cached.value; + } + const value = ( + await this.accountService.getBoundEventPluginKeys(normalizedSelfId) + ).includes(QQBOT_REPEATER_PLUGIN_KEY); + this.boundCache.set(normalizedSelfId, { + expiresAt: now + this.getConfigCacheTtlMs(), + value, + }); + return value; + } + + private canRepeat(message: QqbotNormalizedMessage, text: string) { + if (!text) return false; + if (message.userId === message.selfId) return false; + if (/^[!/!]/.test(text)) return false; + if (text.includes('[CQ:')) return false; + return text.length <= this.getMaxTextLength(); + } + + private getNextState(key: string, text: string) { + const current = this.states.get(key); + const now = Date.now(); + const next = + current?.lastText === text + ? { ...current, count: current.count + 1, updatedAt: now } + : { + count: 1, + lastText: text, + repeatedText: '', + updatedAt: now, + }; + this.states.set(key, next); + this.pruneStates(now); + return next; + } + + private shouldRepeat(state: QqbotRepeaterConversationState, text: string) { + return state.count >= this.getThreshold() && state.repeatedText !== text; + } + + private normalizeText(value: string) { + return `${value || ''}`.trim().replace(/\s+/g, ' '); + } + + private resetState(message: QqbotNormalizedMessage) { + this.states.delete(this.buildStateKey(message)); + } + + private buildStateKey(message: QqbotNormalizedMessage) { + return [message.selfId, message.messageType, message.targetId].join(':'); + } + + private getThreshold() { + const value = Number(this.configService.get('QQBOT_REPEATER_THRESHOLD')); + return Number.isInteger(value) && value > 1 ? value : 2; + } + + private getMaxTextLength() { + const value = Number( + this.configService.get('QQBOT_REPEATER_MAX_TEXT_LENGTH'), + ); + return Number.isInteger(value) && value > 0 ? value : 300; + } + + private getStateTtlMs() { + const value = Number(this.configService.get('QQBOT_REPEATER_STATE_TTL_MS')); + return Number.isInteger(value) && value > 0 ? value : 10 * 60 * 1000; + } + + private getConfigCacheTtlMs() { + const value = Number( + this.configService.get('QQBOT_REPEATER_CONFIG_CACHE_TTL_MS'), + ); + return Number.isInteger(value) && value > 0 ? value : 2000; + } + + private pruneStates(now: number) { + const ttl = this.getStateTtlMs(); + for (const [key, state] of this.states.entries()) { + if (now - state.updatedAt > ttl) this.states.delete(key); + } + } +} diff --git a/src/qqbot/qqbot.module.ts b/src/qqbot/qqbot.module.ts index 9e039f9..1891162 100644 --- a/src/qqbot/qqbot.module.ts +++ b/src/qqbot/qqbot.module.ts @@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AdminAuthGuardModule } from '@/admin/auth/admin-auth-guard.module'; import { QqbotAccountController } from './account/qqbot-account.controller'; +import { QqbotAccountAbility } from './account/qqbot-account-ability.entity'; import { QqbotAccount } from './account/qqbot-account.entity'; import { QqbotAccountService } from './account/qqbot-account.service'; import { QqbotNapcatLoginService } from './account/qqbot-napcat-login.service'; @@ -33,10 +34,12 @@ import { QqbotAllowlist } from './permission/qqbot-allowlist.entity'; import { QqbotBlocklist } from './permission/qqbot-blocklist.entity'; import { QqbotPermissionController } from './permission/qqbot-permission.controller'; import { QqbotPermissionService } from './permission/qqbot-permission.service'; +import { QqbotEventPluginRegistryService } from './plugin/qqbot-event-plugin-registry.service'; import { QqbotPluginController } from './plugin/qqbot-plugin.controller'; import { QqbotPluginRegistryService } from './plugin/qqbot-plugin-registry.service'; import { QqbotFf14ClientService } from './plugins/ff14Market/qqbot-ff14-client.service'; import { QqbotFf14MarketPluginService } from './plugins/ff14Market/qqbot-ff14-market.plugin'; +import { QqbotRepeaterPluginService } from './plugins/repeater/qqbot-repeater.plugin'; import { QqbotRuleController } from './rule/qqbot-rule.controller'; import { QqbotRule } from './rule/qqbot-rule.entity'; import { QqbotRuleEngineService } from './rule/qqbot-rule-engine.service'; @@ -52,6 +55,7 @@ import { QqbotSendService } from './send/qqbot-send.service'; AdminAuthGuardModule, TypeOrmModule.forFeature([ QqbotAccount, + QqbotAccountAbility, QqbotAllowlist, QqbotBlocklist, QqbotCommand, @@ -92,7 +96,9 @@ import { QqbotSendService } from './send/qqbot-send.service'; QqbotNapcatLoginService, QqbotNapcatContainerService, QqbotPermissionService, + QqbotEventPluginRegistryService, QqbotPluginRegistryService, + QqbotRepeaterPluginService, QqbotRateLimitService, QqbotReplyTemplateService, QqbotReverseWsService, diff --git a/src/qqbot/rule/qqbot-rule-engine.service.ts b/src/qqbot/rule/qqbot-rule-engine.service.ts index b34c62e..85d8eaa 100644 --- a/src/qqbot/rule/qqbot-rule-engine.service.ts +++ b/src/qqbot/rule/qqbot-rule-engine.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import type { QqbotNormalizedMessage } from '../qqbot.types'; import { QqbotCommandEngineService } from '../command/qqbot-command-engine.service'; import { QqbotPermissionService } from '../permission/qqbot-permission.service'; +import { QqbotRepeaterPluginService } from '../plugins/repeater/qqbot-repeater.plugin'; import { QqbotSendService } from '../send/qqbot-send.service'; import { QqbotRuleService } from './qqbot-rule.service'; @@ -12,6 +13,7 @@ export class QqbotRuleEngineService { constructor( private readonly commandEngineService: QqbotCommandEngineService, private readonly permissionService: QqbotPermissionService, + private readonly repeaterPluginService: QqbotRepeaterPluginService, private readonly ruleService: QqbotRuleService, private readonly sendService: QqbotSendService, ) {} @@ -44,5 +46,7 @@ export class QqbotRuleEngineService { } return; } + + await this.repeaterPluginService.handleMessage(message); } } diff --git a/src/qqbot/rule/qqbot-rule.dto.ts b/src/qqbot/rule/qqbot-rule.dto.ts index 6731798..20f2a39 100644 --- a/src/qqbot/rule/qqbot-rule.dto.ts +++ b/src/qqbot/rule/qqbot-rule.dto.ts @@ -45,6 +45,9 @@ export class QqbotRuleQueryDto { @ApiPropertyOptional() keyword?: string; + @ApiPropertyOptional() + selfId?: string; + @ApiPropertyOptional() targetType?: QqbotRuleTargetType; diff --git a/src/qqbot/rule/qqbot-rule.service.ts b/src/qqbot/rule/qqbot-rule.service.ts index 667889e..61055f8 100644 --- a/src/qqbot/rule/qqbot-rule.service.ts +++ b/src/qqbot/rule/qqbot-rule.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { throwVbenError } from '@/common'; +import { QqbotAccountService } from '../account/qqbot-account.service'; import { QqbotRule } from './qqbot-rule.entity'; import type { QqbotRuleBodyDto, @@ -20,6 +21,7 @@ export class QqbotRuleService { constructor( @InjectRepository(QqbotRule) private readonly ruleRepository: Repository, + private readonly accountService: QqbotAccountService, ) {} async page(query: QqbotRuleQueryDto) { @@ -36,6 +38,13 @@ export class QqbotRuleService { }, ); } + if (query.selfId) { + const boundIds = await this.accountService.getBoundRuleIds(query.selfId); + if (boundIds.length === 0) { + return { list: [], pageNo, pageSize, total: 0 }; + } + builder.andWhere('rule.id IN (:...boundIds)', { boundIds }); + } if (query.targetType) { builder.andWhere('rule.targetType = :targetType', { targetType: query.targetType, @@ -53,14 +62,22 @@ export class QqbotRuleService { .skip(skip) .take(pageSize) .getManyAndCount(); - return { list, pageNo, pageSize, total }; + return { + list, + pageNo, + pageSize, + total, + }; } async listEnabledForMessage(message: QqbotNormalizedMessage) { + const boundIds = await this.accountService.getBoundRuleIds(message.selfId); + if (boundIds.length === 0) return []; return this.ruleRepository .createQueryBuilder('rule') .where('rule.isDeleted = :isDeleted', { isDeleted: false }) .andWhere('rule.enabled = :enabled', { enabled: true }) + .andWhere('rule.id IN (:...boundIds)', { boundIds }) .andWhere('rule.targetType IN (:...targetTypes)', { targetTypes: ['all', message.messageType], })