feat: 完善QQBot插件与账号能力绑定
This commit is contained in:
parent
ae2fd41e0f
commit
7a93e49af6
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<string, AdminMenu>();
|
||||
menus.forEach((menu) => menuMap.set(menu.id, menu));
|
||||
|
||||
const pendingParentIds = new Set<string>();
|
||||
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()];
|
||||
}
|
||||
|
||||
|
||||
48
src/qqbot/account/qqbot-account-ability.entity.ts
Normal file
48
src/qqbot/account/qqbot-account-ability.entity.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 会话' })
|
||||
|
||||
@ -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<QqbotAccount>,
|
||||
@InjectRepository(QqbotAccountAbility)
|
||||
private readonly accountAbilityRepository: Repository<QqbotAccountAbility>,
|
||||
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<QqbotAccount>;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,9 @@ export class QqbotCommandQueryDto implements QqbotPageQuery {
|
||||
@ApiPropertyOptional()
|
||||
keyword?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
selfId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
pluginKey?: string;
|
||||
|
||||
|
||||
@ -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<QqbotCommand>,
|
||||
@InjectRepository(QqbotCommandLog)
|
||||
private readonly commandLogRepository: Repository<QqbotCommandLog>,
|
||||
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);
|
||||
|
||||
@ -22,8 +22,8 @@ export class QqbotConfigService {
|
||||
|
||||
async getPermissionConfig(): Promise<QqbotPermissionConfig> {
|
||||
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 },
|
||||
});
|
||||
|
||||
93
src/qqbot/plugin/qqbot-event-plugin-registry.service.ts
Normal file
93
src/qqbot/plugin/qqbot-event-plugin-registry.service.ts
Normal file
@ -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<typeof account> => !!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<QqbotPluginHealth[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<string, any>;
|
||||
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<string, any>;
|
||||
pluginKey: string;
|
||||
triggerMode: QqbotPluginTriggerMode;
|
||||
};
|
||||
|
||||
218
src/qqbot/plugins/repeater/qqbot-repeater.plugin.ts
Normal file
218
src/qqbot/plugins/repeater/qqbot-repeater.plugin.ts
Normal file
@ -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<string, QqbotRepeaterConversationState>();
|
||||
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<QqbotEventPluginSummary> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,6 +45,9 @@ export class QqbotRuleQueryDto {
|
||||
@ApiPropertyOptional()
|
||||
keyword?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
selfId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
targetType?: QqbotRuleTargetType;
|
||||
|
||||
|
||||
@ -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<QqbotRule>,
|
||||
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],
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user