From 31119bbc9341a6a48c912896168a69691bcdc4b5 Mon Sep 17 00:00:00 2001 From: sunlei Date: Tue, 2 Jun 2026 14:28:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=20QQBot=20=E5=9C=A8?= =?UTF-8?q?=E7=BA=BF=E5=91=BD=E4=BB=A4=E4=B8=8E=20FF14=20=E6=8F=92?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 + sql/qqbot-init.sql | 77 ++++- .../command/qqbot-command-engine.service.ts | 199 ++++++++++++ src/qqbot/command/qqbot-command-log.entity.ts | 68 +++++ .../command/qqbot-command-parser.service.ts | 135 ++++++++ src/qqbot/command/qqbot-command.controller.ts | 78 +++++ src/qqbot/command/qqbot-command.dto.ts | 103 +++++++ src/qqbot/command/qqbot-command.entity.ts | 83 +++++ src/qqbot/command/qqbot-command.service.ts | 288 ++++++++++++++++++ .../command/qqbot-reply-template.service.ts | 26 ++ .../plugin/qqbot-plugin-registry.service.ts | 104 +++++++ src/qqbot/plugin/qqbot-plugin.controller.ts | 32 ++ src/qqbot/plugin/qqbot-plugin.types.ts | 57 ++++ .../ff14Market/qqbot-ff14-client.service.ts | 257 ++++++++++++++++ .../ff14Market/qqbot-ff14-market.plugin.ts | 89 ++++++ src/qqbot/qqbot.module.ts | 22 ++ src/qqbot/qqbot.types.ts | 4 + src/qqbot/rule/qqbot-rule-engine.service.ts | 3 + 18 files changed, 1626 insertions(+), 4 deletions(-) create mode 100644 src/qqbot/command/qqbot-command-engine.service.ts create mode 100644 src/qqbot/command/qqbot-command-log.entity.ts create mode 100644 src/qqbot/command/qqbot-command-parser.service.ts create mode 100644 src/qqbot/command/qqbot-command.controller.ts create mode 100644 src/qqbot/command/qqbot-command.dto.ts create mode 100644 src/qqbot/command/qqbot-command.entity.ts create mode 100644 src/qqbot/command/qqbot-command.service.ts create mode 100644 src/qqbot/command/qqbot-reply-template.service.ts create mode 100644 src/qqbot/plugin/qqbot-plugin-registry.service.ts create mode 100644 src/qqbot/plugin/qqbot-plugin.controller.ts create mode 100644 src/qqbot/plugin/qqbot-plugin.types.ts create mode 100644 src/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts create mode 100644 src/qqbot/plugins/ff14Market/qqbot-ff14-market.plugin.ts diff --git a/.env.example b/.env.example index d5511e2..3d37f08 100644 --- a/.env.example +++ b/.env.example @@ -53,3 +53,8 @@ MQTT_URL=mqtt://127.0.0.1:1883 MQTT_USERNAME= MQTT_PASSWORD= MQTT_CLIENT_ID=kt-template-online-api-qqbot + +FF14_XIVAPI_BASE_URL=https://v2.xivapi.com/api +FF14_UNIVERSALIS_BASE_URL=https://universalis.app/api/v2 +FF14_DEFAULT_WORLD=中国 +FF14_MARKET_CACHE_TTL_MS=60000 diff --git a/sql/qqbot-init.sql b/sql/qqbot-init.sql index be089d4..5b8d821 100644 --- a/sql/qqbot-init.sql +++ b/sql/qqbot-init.sql @@ -94,6 +94,56 @@ CREATE TABLE IF NOT EXISTS `qqbot_rule` ( KEY `idx_qqbot_rule_enabled` (`enabled`, `is_deleted`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `qqbot_command` ( + `id` bigint NOT NULL, + `code` varchar(80) NOT NULL, + `name` varchar(120) NOT NULL DEFAULT '', + `aliases` text NOT NULL, + `prefixes` varchar(120) NOT NULL DEFAULT '/,!,!', + `plugin_key` varchar(80) NOT NULL, + `operation_key` varchar(120) NOT NULL, + `parser_key` varchar(40) NOT NULL DEFAULT 'plain', + `target_type` varchar(32) NOT NULL DEFAULT 'all', + `default_params` text DEFAULT NULL, + `reply_template` text DEFAULT NULL, + `error_template` text DEFAULT NULL, + `enabled` tinyint(1) NOT NULL DEFAULT 1, + `priority` int NOT NULL DEFAULT 0, + `cooldown_ms` int NOT NULL DEFAULT 1500, + `last_hit_at` datetime DEFAULT NULL, + `remark` varchar(255) NOT NULL DEFAULT '', + `is_deleted` tinyint(1) NOT NULL DEFAULT 0, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + KEY `idx_qqbot_command_code` (`code`, `is_deleted`), + KEY `idx_qqbot_command_plugin` (`plugin_key`, `operation_key`), + KEY `idx_qqbot_command_enabled` (`enabled`, `is_deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `qqbot_command_log` ( + `id` bigint NOT NULL, + `command_id` varchar(64) NOT NULL, + `command_code` varchar(80) NOT NULL DEFAULT '', + `plugin_key` varchar(80) NOT NULL, + `operation_key` varchar(120) NOT NULL, + `self_id` varchar(64) NOT NULL DEFAULT '', + `target_type` varchar(32) NOT NULL DEFAULT 'private', + `target_id` varchar(64) NOT NULL DEFAULT '', + `user_id` varchar(64) NOT NULL DEFAULT '', + `raw_message` text NOT NULL, + `input` longtext DEFAULT NULL, + `output` longtext DEFAULT NULL, + `status` varchar(32) NOT NULL DEFAULT 'success', + `error_message` text DEFAULT NULL, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + KEY `idx_qqbot_command_log_command` (`command_id`, `status`), + KEY `idx_qqbot_command_log_target` (`self_id`, `target_type`, `target_id`), + KEY `idx_qqbot_command_log_create_time` (`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `qqbot_conversation` ( `id` bigint NOT NULL, `self_id` varchar(64) NOT NULL, @@ -263,6 +313,18 @@ VALUES ON DUPLICATE KEY UPDATE `config_key` = VALUES(`config_key`); +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":"zh","world":"中国"}', '', 'FF14 查价失败:{{error}}', 0, 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`), + `remark` = VALUES(`remark`), + `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), @@ -278,12 +340,19 @@ VALUES (2041700000000120412, 2041700000000100403, 'QqBotRuleEdit', NULL, NULL, NULL, 'QqBot:Rule:Edit', 'button', '{"title":"common.edit"}', 1, 0), (2041700000000120413, 2041700000000100403, 'QqBotRuleDelete', NULL, NULL, NULL, 'QqBot:Rule:Delete', 'button', '{"title":"common.delete"}', 1, 0), (2041700000000120414, 2041700000000100403, 'QqBotRuleToggle', NULL, NULL, NULL, 'QqBot:Rule:Toggle', 'button', '{"title":"启停"}', 1, 0), - (2041700000000100404, 2041700000000100400, 'QqBotConversation', '/qqbot/conversation', '/qqbot/conversation/list', NULL, 'QqBot:Conversation:List', 'menu', '{"icon":"lucide:messages-square","title":"会话管理"}', 1, 3), - (2041700000000100405, 2041700000000100400, 'QqBotMessage', '/qqbot/message', '/qqbot/message/list', NULL, 'QqBot:Message:List', 'menu', '{"icon":"lucide:message-square-text","title":"消息日志"}', 1, 4), - (2041700000000100406, 2041700000000100400, 'QqBotSendLog', '/qqbot/sendLog', '/qqbot/sendLog/list', NULL, 'QqBot:SendLog:List', 'menu', '{"icon":"lucide:send","title":"发送日志"}', 1, 5), + (2041700000000100408, 2041700000000100400, 'QqBotCommand', '/qqbot/command', '/qqbot/command/list', NULL, 'QqBot:Command:List', 'menu', '{"icon":"lucide:square-terminal","title":"在线命令"}', 1, 3), + (2041700000000120441, 2041700000000100408, 'QqBotCommandCreate', NULL, NULL, NULL, 'QqBot:Command:Create', 'button', '{"title":"common.create"}', 1, 0), + (2041700000000120442, 2041700000000100408, 'QqBotCommandEdit', NULL, NULL, NULL, 'QqBot:Command:Edit', 'button', '{"title":"common.edit"}', 1, 0), + (2041700000000120443, 2041700000000100408, 'QqBotCommandDelete', NULL, NULL, NULL, 'QqBot:Command:Delete', 'button', '{"title":"common.delete"}', 1, 0), + (2041700000000120444, 2041700000000100408, 'QqBotCommandToggle', NULL, NULL, NULL, 'QqBot:Command:Toggle', 'button', '{"title":"启停"}', 1, 0), + (2041700000000120445, 2041700000000100408, 'QqBotCommandTest', NULL, NULL, NULL, 'QqBot:Command:Test', 'button', '{"title":"测试命令"}', 1, 0), + (2041700000000100409, 2041700000000100400, 'QqBotPlugin', '/qqbot/plugin', '/qqbot/plugin/list', NULL, 'QqBot:Plugin:List', 'menu', '{"icon":"lucide:plug","title":"插件能力"}', 1, 4), + (2041700000000100404, 2041700000000100400, 'QqBotConversation', '/qqbot/conversation', '/qqbot/conversation/list', NULL, 'QqBot:Conversation:List', 'menu', '{"icon":"lucide:messages-square","title":"会话管理"}', 1, 5), + (2041700000000100405, 2041700000000100400, 'QqBotMessage', '/qqbot/message', '/qqbot/message/list', NULL, 'QqBot:Message:List', 'menu', '{"icon":"lucide:message-square-text","title":"消息日志"}', 1, 6), + (2041700000000100406, 2041700000000100400, 'QqBotSendLog', '/qqbot/sendLog', '/qqbot/sendLog/list', NULL, 'QqBot:SendLog:List', 'menu', '{"icon":"lucide:send","title":"发送日志"}', 1, 7), (2041700000000120421, 2041700000000100406, 'QqBotSendPrivate', NULL, NULL, NULL, 'QqBot:Send:Private', 'button', '{"title":"发送私聊"}', 1, 0), (2041700000000120422, 2041700000000100406, 'QqBotSendGroup', NULL, NULL, NULL, 'QqBot:Send:Group', 'button', '{"title":"发送群聊"}', 1, 0), - (2041700000000100407, 2041700000000100400, 'QqBotPermission', '/qqbot/permission', '/qqbot/permission/list', NULL, 'QqBot:Permission:List', 'menu', '{"icon":"lucide:shield-check","title":"权限名单"}', 1, 6), + (2041700000000100407, 2041700000000100400, 'QqBotPermission', '/qqbot/permission', '/qqbot/permission/list', NULL, 'QqBot:Permission:List', 'menu', '{"icon":"lucide:shield-check","title":"权限名单"}', 1, 8), (2041700000000120431, 2041700000000100407, 'QqBotPermissionCreate', NULL, NULL, NULL, 'QqBot:Permission:Create', 'button', '{"title":"common.create"}', 1, 0), (2041700000000120432, 2041700000000100407, 'QqBotPermissionEdit', NULL, NULL, NULL, 'QqBot:Permission:Edit', 'button', '{"title":"common.edit"}', 1, 0), (2041700000000120433, 2041700000000100407, 'QqBotPermissionDelete', NULL, NULL, NULL, 'QqBot:Permission:Delete', 'button', '{"title":"common.delete"}', 1, 0) diff --git a/src/qqbot/command/qqbot-command-engine.service.ts b/src/qqbot/command/qqbot-command-engine.service.ts new file mode 100644 index 0000000..eb9d1d1 --- /dev/null +++ b/src/qqbot/command/qqbot-command-engine.service.ts @@ -0,0 +1,199 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { QqbotPluginRegistryService } from '../plugin/qqbot-plugin-registry.service'; +import type { QqbotNormalizedMessage } from '../qqbot.types'; +import { QqbotSendService } from '../send/qqbot-send.service'; +import type { QqbotCommandTestDto } from './qqbot-command.dto'; +import type { QqbotCommand } from './qqbot-command.entity'; +import { QqbotCommandParserService } from './qqbot-command-parser.service'; +import { QqbotCommandService } from './qqbot-command.service'; +import { QqbotReplyTemplateService } from './qqbot-reply-template.service'; + +@Injectable() +export class QqbotCommandEngineService { + private readonly logger = new Logger(QqbotCommandEngineService.name); + + constructor( + private readonly commandParser: QqbotCommandParserService, + private readonly commandService: QqbotCommandService, + private readonly pluginRegistry: QqbotPluginRegistryService, + private readonly replyTemplate: QqbotReplyTemplateService, + private readonly sendService: QqbotSendService, + ) {} + + async handleMessage(message: QqbotNormalizedMessage) { + const commands = await this.commandService.listEnabledForMessage(message); + for (const command of commands) { + const matched = this.commandParser.match(command, message); + if (!matched) continue; + if (this.commandService.isInCooldown(command)) return true; + + await this.commandService.markHit(command); + const input = this.mergeInput(command, matched.input); + try { + const output = await this.pluginRegistry.execute( + command.pluginKey, + command.operationKey, + input, + { + args: matched.input, + command, + message, + }, + ); + const replyText = this.buildReplyText(command, input, output); + if (replyText) { + await this.sendService.sendText({ + channelId: message.channelId, + guildId: message.rawEvent.guild_id + ? `${message.rawEvent.guild_id}` + : undefined, + message: replyText, + selfId: message.selfId, + targetId: message.targetId, + targetType: message.messageType, + }); + } + await this.commandService.logExecution({ + command, + input, + message, + output, + status: 'success', + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '命令执行失败'; + await this.commandService.logExecution({ + command, + errorMessage, + input, + message, + status: 'failed', + }); + await this.sendErrorReply(command, input, message, errorMessage); + this.logger.warn(`QQBot 命令执行失败: ${errorMessage}`); + } + return true; + } + return false; + } + + async preview(body: QqbotCommandTestDto) { + const message = this.buildPreviewMessage(body); + const command = body.commandId + ? await this.commandService.findById(body.commandId) + : await this.findMatchedCommand(message); + const matched = this.commandParser.match(command, message); + if (!matched) { + return { + matched: false, + message: '未匹配到命令', + }; + } + + const input = this.mergeInput(command, matched.input); + const output = await this.pluginRegistry.execute( + command.pluginKey, + command.operationKey, + input, + { + args: matched.input, + command, + message, + }, + ); + const replyText = this.buildReplyText(command, input, output); + return { + command: this.commandService.toResponse(command), + input, + matched: true, + output, + replyText, + }; + } + + private async findMatchedCommand(message: QqbotNormalizedMessage) { + const commands = await this.commandService.listEnabledForMessage(message); + const command = commands.find((item) => + this.commandParser.match(item, message), + ); + if (!command) { + throw new Error('未匹配到命令'); + } + return command; + } + + private buildReplyText( + command: QqbotCommand, + input: Record, + output: any, + ) { + const data = { input, output, ...output }; + return ( + this.replyTemplate.render(command.replyTemplate, data) || + this.replyTemplate.stringifyOutput(output) + ); + } + + private async sendErrorReply( + command: QqbotCommand, + input: Record, + message: QqbotNormalizedMessage, + errorMessage: string, + ) { + const reply = + this.replyTemplate.render(command.errorTemplate, { + error: errorMessage, + input, + }) || `命令执行失败:${errorMessage}`; + try { + await this.sendService.sendText({ + channelId: message.channelId, + guildId: message.rawEvent.guild_id + ? `${message.rawEvent.guild_id}` + : undefined, + message: reply, + selfId: message.selfId, + targetId: message.targetId, + targetType: message.messageType, + }); + } catch (err) { + const sendErr = err instanceof Error ? err.message : '错误回复发送失败'; + this.logger.warn(`QQBot 命令错误回复发送失败: ${sendErr}`); + } + } + + private mergeInput(command: QqbotCommand, input: Record) { + return { + ...this.commandService.parseDefaultParams(command), + ...this.removeUndefined(input), + }; + } + + private removeUndefined(input: Record) { + return Object.entries(input).reduce>( + (result, [key, value]) => { + if (value !== undefined && value !== '') result[key] = value; + return result; + }, + {}, + ); + } + + private buildPreviewMessage(body: QqbotCommandTestDto): QqbotNormalizedMessage { + const targetType = body.targetType || 'private'; + const targetId = body.targetId || body.userId || '10000'; + const userId = body.userId || targetId; + return { + eventTime: new Date(), + groupId: targetType === 'group' ? targetId : undefined, + messageId: `preview-${Date.now()}`, + messageText: body.text, + messageType: targetType, + rawEvent: {}, + rawMessage: body.text, + selfId: body.selfId || 'preview', + targetId, + userId, + }; + } +} diff --git a/src/qqbot/command/qqbot-command-log.entity.ts b/src/qqbot/command/qqbot-command-log.entity.ts new file mode 100644 index 0000000..dc42e7f --- /dev/null +++ b/src/qqbot/command/qqbot-command-log.entity.ts @@ -0,0 +1,68 @@ +import { + BeforeInsert, + Column, + CreateDateColumn, + Entity, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ensureSnowflakeId } from '@/common'; +import type { QqbotMessageType } from '../qqbot.types'; + +export type QqbotCommandLogStatus = 'failed' | 'success'; + +@Entity('qqbot_command_log') +export class QqbotCommandLog { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ length: 64, name: 'command_id' }) + commandId: string; + + @Column({ default: '', length: 80, name: 'command_code' }) + commandCode: string; + + @Column({ length: 80, name: 'plugin_key' }) + pluginKey: string; + + @Column({ length: 120, name: 'operation_key' }) + operationKey: string; + + @Column({ default: '', length: 64, name: 'self_id' }) + selfId: string; + + @Column({ default: 'private', length: 32, name: 'target_type' }) + targetType: QqbotMessageType; + + @Column({ default: '', length: 64, name: 'target_id' }) + targetId: string; + + @Column({ default: '', length: 64, name: 'user_id' }) + userId: string; + + @Column({ name: 'raw_message', type: 'text' }) + rawMessage: string; + + @Column({ default: null, nullable: true, type: 'text' }) + input: string | null; + + @Column({ default: null, nullable: true, type: 'text' }) + output: string | null; + + @Column({ default: 'success', length: 32 }) + status: QqbotCommandLogStatus; + + @Column({ default: null, name: 'error_message', nullable: true, type: 'text' }) + errorMessage: string | null; + + @CreateDateColumn({ name: 'create_time' }) + createTime: Date; + + @UpdateDateColumn({ name: 'update_time' }) + updateTime: Date; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/qqbot/command/qqbot-command-parser.service.ts b/src/qqbot/command/qqbot-command-parser.service.ts new file mode 100644 index 0000000..0b0bad3 --- /dev/null +++ b/src/qqbot/command/qqbot-command-parser.service.ts @@ -0,0 +1,135 @@ +import { Injectable } from '@nestjs/common'; +import type { QqbotCommand } from './qqbot-command.entity'; +import type { QqbotNormalizedMessage } from '../qqbot.types'; + +export type QqbotCommandMatchResult = { + alias: string; + input: Record; + matched: true; + rawArgs: string; +}; + +@Injectable() +export class QqbotCommandParserService { + match(command: QqbotCommand, message: QqbotNormalizedMessage) { + const source = `${message.messageText || ''}`.trim(); + if (!source) return null; + + const aliases = this.getAliases(command); + const prefixes = this.getPrefixes(command); + for (const alias of aliases) { + for (const prefix of prefixes) { + const commandText = `${prefix}${alias}`.trim(); + const rawArgs = this.pickArgs(source, commandText); + if (rawArgs === null) continue; + return { + alias, + input: this.parseInput(command, rawArgs), + matched: true, + rawArgs, + } satisfies QqbotCommandMatchResult; + } + } + return null; + } + + getAliases(command: QqbotCommand) { + return this.normalizeList(command.aliases, [command.code, command.name]); + } + + getPrefixes(command: QqbotCommand) { + return this.normalizeList(command.prefixes, ['/', '!', '!']); + } + + private pickArgs(source: string, commandText: string) { + if (!commandText) return null; + if (source === commandText) return ''; + if (source.startsWith(`${commandText} `)) { + return source.slice(commandText.length).trim(); + } + return null; + } + + private parseInput(command: QqbotCommand, rawArgs: string) { + if (command.parserKey === 'ff14Price') { + return this.parseFf14PriceInput(rawArgs); + } + const args = rawArgs ? rawArgs.split(/\s+/).filter(Boolean) : []; + return { + args, + raw: rawArgs, + text: rawArgs, + }; + } + + private parseFf14PriceInput(rawArgs: string) { + const tokens = rawArgs.split(/\s+/).filter(Boolean); + const flags = new Map(); + const positional: string[] = []; + + for (const token of tokens) { + if (/^(hq|HQ)$/.test(token)) { + flags.set('hq', true); + } else if (/^(nq|NQ)$/.test(token)) { + flags.set('hq', 'false'); + } else if (token.includes('=')) { + const [key, ...rest] = token.split('='); + flags.set(key, rest.join('=')); + } else { + positional.push(token); + } + } + + let world = this.normalizeString(flags.get('world') || flags.get('server')); + let item = positional.join(' '); + if (!world && positional.length > 1) { + world = positional[positional.length - 1]; + item = positional.slice(0, -1).join(' '); + } + if (item.includes('@')) { + const [itemName, worldName] = item.split('@'); + item = itemName.trim(); + world = world || worldName?.trim(); + } + + return { + hq: this.normalizeHq(flags.get('hq')), + item, + language: this.normalizeString(flags.get('lang')) || 'zh', + raw: rawArgs, + world, + }; + } + + private normalizeHq(value?: string | true) { + if (value === undefined) return undefined; + if (value === true) return true; + if (value === 'false') return false; + return ['1', 'true', 'yes', 'hq'].includes(`${value}`.toLowerCase()); + } + + private normalizeList(value: string | undefined, fallback: string[]) { + const raw = `${value || ''}`.trim(); + const parsed = this.tryParseJsonArray(raw); + const source = parsed.length > 0 ? parsed : raw.split(','); + const list = [...source, ...fallback] + .map((item) => `${item || ''}`.trim()) + .filter(Boolean); + return [...new Set(list)]; + } + + private tryParseJsonArray(value: string) { + if (!value.startsWith('[')) return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + + private normalizeString(value?: string | true) { + if (value === true) return ''; + return `${value || ''}`.trim(); + } +} diff --git a/src/qqbot/command/qqbot-command.controller.ts b/src/qqbot/command/qqbot-command.controller.ts new file mode 100644 index 0000000..d9582bd --- /dev/null +++ b/src/qqbot/command/qqbot-command.controller.ts @@ -0,0 +1,78 @@ +import { + Body, + 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 { + QqbotCommandBodyDto, + QqbotCommandQueryDto, + QqbotCommandTestDto, + QqbotCommandUpdateDto, +} from './qqbot-command.dto'; +import { QqbotCommandEngineService } from './qqbot-command-engine.service'; +import { QqbotCommandService } from './qqbot-command.service'; +import { normalizeBoolean } from '../qqbot.utils'; + +@ApiTags('qqbot-command') +@Controller('qqbot/command') +@UseGuards(JwtAuthGuard) +export class QqbotCommandController { + constructor( + private readonly commandEngine: QqbotCommandEngineService, + private readonly commandService: QqbotCommandService, + ) {} + + @Get('list') + @ApiOperation({ summary: 'QQBot 在线命令分页' }) + async list(@Query() query: QqbotCommandQueryDto) { + return vbenSuccess(await this.commandService.page(query)); + } + + @Post('save') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '新增 QQBot 在线命令' }) + async save(@Body() body: QqbotCommandBodyDto) { + return vbenSuccess(await this.commandService.save(body)); + } + + @Post('update') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '编辑 QQBot 在线命令' }) + async update(@Body() body: QqbotCommandUpdateDto) { + return vbenSuccess(await this.commandService.update(body)); + } + + @Post('delete') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '删除 QQBot 在线命令' }) + @ApiQuery({ name: 'id', type: String }) + async delete(@Query('id') id: string) { + return vbenSuccess(await this.commandService.remove(id)); + } + + @Post('toggle') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '启停 QQBot 在线命令' }) + @ApiQuery({ name: 'id', type: String }) + @ApiQuery({ name: 'enabled', type: Boolean }) + async toggle(@Query('id') id: string, @Query('enabled') enabled: string) { + return vbenSuccess( + await this.commandService.toggle(id, normalizeBoolean(enabled)), + ); + } + + @Post('test') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '测试 QQBot 在线命令' }) + async test(@Body() body: QqbotCommandTestDto) { + return vbenSuccess(await this.commandEngine.preview(body)); + } +} diff --git a/src/qqbot/command/qqbot-command.dto.ts b/src/qqbot/command/qqbot-command.dto.ts new file mode 100644 index 0000000..104c811 --- /dev/null +++ b/src/qqbot/command/qqbot-command.dto.ts @@ -0,0 +1,103 @@ +import { PartialType } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import type { + QqbotCommandParserType, + QqbotMessageType, + QqbotRuleTargetType, +} from '../qqbot.types'; +import type { QqbotPageQuery } from '../qqbot.utils'; + +export class QqbotCommandQueryDto implements QqbotPageQuery { + @ApiPropertyOptional() + pageNo?: number | string; + + @ApiPropertyOptional() + pageSize?: number | string; + + @ApiPropertyOptional() + keyword?: string; + + @ApiPropertyOptional() + pluginKey?: string; + + @ApiPropertyOptional() + operationKey?: string; + + @ApiPropertyOptional() + enabled?: boolean | string; + + @ApiPropertyOptional() + targetType?: QqbotRuleTargetType; +} + +export class QqbotCommandBodyDto { + @ApiProperty() + code: string; + + @ApiProperty() + name: string; + + @ApiPropertyOptional({ type: [String] }) + aliases?: string[] | string; + + @ApiPropertyOptional({ type: [String] }) + prefixes?: string[] | string; + + @ApiProperty() + pluginKey: string; + + @ApiProperty() + operationKey: string; + + @ApiPropertyOptional() + parserKey?: QqbotCommandParserType; + + @ApiPropertyOptional() + targetType?: QqbotRuleTargetType; + + @ApiPropertyOptional() + defaultParams?: any; + + @ApiPropertyOptional() + replyTemplate?: string; + + @ApiPropertyOptional() + errorTemplate?: string; + + @ApiPropertyOptional() + enabled?: boolean; + + @ApiPropertyOptional() + priority?: number; + + @ApiPropertyOptional() + cooldownMs?: number; + + @ApiPropertyOptional() + remark?: string; +} + +export class QqbotCommandUpdateDto extends PartialType(QqbotCommandBodyDto) { + @ApiProperty() + id: string; +} + +export class QqbotCommandTestDto { + @ApiPropertyOptional() + commandId?: string; + + @ApiProperty() + text: string; + + @ApiPropertyOptional() + selfId?: string; + + @ApiPropertyOptional() + targetType?: QqbotMessageType; + + @ApiPropertyOptional() + targetId?: string; + + @ApiPropertyOptional() + userId?: string; +} diff --git a/src/qqbot/command/qqbot-command.entity.ts b/src/qqbot/command/qqbot-command.entity.ts new file mode 100644 index 0000000..5a7b1d6 --- /dev/null +++ b/src/qqbot/command/qqbot-command.entity.ts @@ -0,0 +1,83 @@ +import { + BeforeInsert, + Column, + CreateDateColumn, + Entity, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ensureSnowflakeId } from '@/common'; +import type { QqbotCommandParserType, QqbotRuleTargetType } from '../qqbot.types'; + +@Entity('qqbot_command') +export class QqbotCommand { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ length: 80 }) + code: string; + + @Column({ default: '', length: 120 }) + name: string; + + @Column({ type: 'text' }) + aliases: string; + + @Column({ default: '/,!,!', length: 120 }) + prefixes: string; + + @Column({ length: 80, name: 'plugin_key' }) + pluginKey: string; + + @Column({ length: 120, name: 'operation_key' }) + operationKey: string; + + @Column({ default: 'plain', length: 40, name: 'parser_key' }) + parserKey: QqbotCommandParserType; + + @Column({ default: 'all', length: 32, name: 'target_type' }) + targetType: QqbotRuleTargetType; + + @Column({ default: null, name: 'default_params', nullable: true, type: 'text' }) + defaultParams: string | null; + + @Column({ default: null, name: 'reply_template', nullable: true, type: 'text' }) + replyTemplate: string | null; + + @Column({ default: null, name: 'error_template', nullable: true, type: 'text' }) + errorTemplate: string | null; + + @Column({ default: true }) + enabled: boolean; + + @Column({ default: 0 }) + priority: number; + + @Column({ default: 1500, name: 'cooldown_ms' }) + cooldownMs: number; + + @Column({ + default: null, + name: 'last_hit_at', + nullable: true, + type: 'datetime', + }) + lastHitAt: Date | null; + + @Column({ default: '', length: 255 }) + remark: string; + + @Column({ default: false, name: 'is_deleted' }) + isDeleted: boolean; + + @CreateDateColumn({ name: 'create_time' }) + createTime: Date; + + @UpdateDateColumn({ name: 'update_time' }) + updateTime: Date; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/qqbot/command/qqbot-command.service.ts b/src/qqbot/command/qqbot-command.service.ts new file mode 100644 index 0000000..e957085 --- /dev/null +++ b/src/qqbot/command/qqbot-command.service.ts @@ -0,0 +1,288 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Not, Repository } from 'typeorm'; +import { throwVbenError } from '@/common'; +import { QqbotPluginRegistryService } from '../plugin/qqbot-plugin-registry.service'; +import type { + QqbotCommandParserType, + QqbotNormalizedMessage, + QqbotRuleTargetType, +} from '../qqbot.types'; +import { getPageParams, normalizeBoolean } from '../qqbot.utils'; +import type { + QqbotCommandBodyDto, + QqbotCommandQueryDto, + QqbotCommandUpdateDto, +} from './qqbot-command.dto'; +import { QqbotCommandLog } from './qqbot-command-log.entity'; +import { QqbotCommand } from './qqbot-command.entity'; + +@Injectable() +export class QqbotCommandService { + constructor( + @InjectRepository(QqbotCommand) + private readonly commandRepository: Repository, + @InjectRepository(QqbotCommandLog) + private readonly commandLogRepository: Repository, + private readonly pluginRegistry: QqbotPluginRegistryService, + ) {} + + async page(query: QqbotCommandQueryDto) { + const { pageNo, pageSize, skip } = getPageParams(query); + const builder = this.commandRepository + .createQueryBuilder('command') + .where('command.isDeleted = :isDeleted', { isDeleted: false }); + + if (query.keyword) { + builder.andWhere( + '(command.code LIKE :keyword OR command.name LIKE :keyword OR command.aliases LIKE :keyword)', + { keyword: `%${query.keyword}%` }, + ); + } + if (query.pluginKey) { + builder.andWhere('command.pluginKey = :pluginKey', { + pluginKey: query.pluginKey, + }); + } + if (query.operationKey) { + builder.andWhere('command.operationKey = :operationKey', { + operationKey: query.operationKey, + }); + } + if (query.targetType) { + builder.andWhere('command.targetType = :targetType', { + targetType: query.targetType, + }); + } + if (query.enabled !== undefined && `${query.enabled}` !== '') { + builder.andWhere('command.enabled = :enabled', { + enabled: normalizeBoolean(query.enabled), + }); + } + + const [list, total] = await builder + .orderBy('command.priority', 'DESC') + .addOrderBy('command.createTime', 'DESC') + .skip(skip) + .take(pageSize) + .getManyAndCount(); + return { + list: list.map((item) => this.toResponse(item)), + pageNo, + pageSize, + total, + }; + } + + async listEnabledForMessage(message: QqbotNormalizedMessage) { + return this.commandRepository + .createQueryBuilder('command') + .where('command.isDeleted = :isDeleted', { isDeleted: false }) + .andWhere('command.enabled = :enabled', { enabled: true }) + .andWhere('command.targetType IN (:...targetTypes)', { + targetTypes: ['all', message.messageType], + }) + .orderBy('command.priority', 'DESC') + .addOrderBy('command.createTime', 'ASC') + .getMany(); + } + + async findById(id: string) { + const command = await this.commandRepository.findOne({ + where: { id, isDeleted: false }, + }); + if (!command) throwVbenError('命令不存在'); + return command; + } + + async save(body: QqbotCommandBodyDto) { + const payload = await this.normalizeBody(body); + await this.assertCodeAvailable(payload.code); + const saved = await this.commandRepository.save( + this.commandRepository.create(payload), + ); + return saved.id; + } + + async update(body: QqbotCommandUpdateDto) { + const current = await this.findById(body.id); + const payload = await this.normalizeBody({ + ...this.toRawBody(current), + ...body, + }); + await this.assertCodeAvailable(payload.code, body.id); + await this.commandRepository.update({ id: body.id }, payload); + return true; + } + + async remove(id: string) { + await this.commandRepository.update({ id }, { isDeleted: true }); + return true; + } + + async toggle(id: string, enabled: boolean) { + await this.commandRepository.update({ id }, { enabled }); + return true; + } + + async markHit(command: QqbotCommand) { + await this.commandRepository.update( + { id: command.id }, + { lastHitAt: new Date() }, + ); + } + + isInCooldown(command: QqbotCommand) { + if (!command.lastHitAt || !command.cooldownMs) return false; + return ( + Date.now() - new Date(command.lastHitAt).getTime() < command.cooldownMs + ); + } + + async logExecution(params: { + command: QqbotCommand; + errorMessage?: string; + input: Record; + message: QqbotNormalizedMessage; + output?: any; + status: 'failed' | 'success'; + }) { + await this.commandLogRepository.save( + this.commandLogRepository.create({ + commandCode: params.command.code, + commandId: params.command.id, + errorMessage: params.errorMessage || null, + input: JSON.stringify(params.input || {}), + operationKey: params.command.operationKey, + output: + params.output === undefined ? null : JSON.stringify(params.output), + pluginKey: params.command.pluginKey, + rawMessage: params.message.messageText, + selfId: params.message.selfId, + status: params.status, + targetId: params.message.targetId, + targetType: params.message.messageType, + userId: params.message.userId, + }), + ); + } + + parseDefaultParams(command: QqbotCommand) { + return this.parseJson(command.defaultParams); + } + + toResponse(command: QqbotCommand) { + return { + ...command, + aliases: this.parseList(command.aliases), + defaultParams: this.parseDefaultParams(command), + prefixes: this.parseList(command.prefixes), + }; + } + + private async normalizeBody(body: QqbotCommandBodyDto) { + const code = `${body.code || ''}`.trim(); + const pluginKey = `${body.pluginKey || ''}`.trim(); + const operationKey = `${body.operationKey || ''}`.trim(); + if (!code) throwVbenError('命令编码不能为空'); + if (!body.name?.trim()) throwVbenError('命令名称不能为空'); + this.pluginRegistry.assertOperation(pluginKey, operationKey); + + return { + aliases: this.stringifyList(body.aliases), + code, + cooldownMs: Number(body.cooldownMs ?? 1500), + defaultParams: this.stringifyParams(body.defaultParams), + enabled: body.enabled ?? true, + errorTemplate: body.errorTemplate || null, + name: body.name.trim(), + operationKey, + parserKey: (body.parserKey || 'plain') as QqbotCommandParserType, + pluginKey, + prefixes: this.stringifyList(body.prefixes, ['/', '!', '!']), + priority: Number(body.priority || 0), + remark: body.remark || '', + replyTemplate: body.replyTemplate || null, + targetType: (body.targetType || 'all') as QqbotRuleTargetType, + } as Partial; + } + + private async assertCodeAvailable(code: string, currentId?: string) { + const where = currentId + ? { code, id: Not(currentId), isDeleted: false } + : { code, isDeleted: false }; + const existed = await this.commandRepository.findOne({ where }); + if (existed) throwVbenError(`命令编码已存在:${code}`); + } + + private stringifyList(value: string[] | string | undefined, fallback = []) { + const list = Array.isArray(value) + ? value + : `${value || ''}` + .split(',') + .map((item) => item.trim()); + const normalized = list + .map((item) => `${item || ''}`.trim()) + .filter(Boolean); + return JSON.stringify([ + ...new Set(normalized.length > 0 ? normalized : fallback), + ]); + } + + private parseList(value: string | null | undefined) { + const source = `${value || ''}`.trim(); + if (!source) return []; + if (source.startsWith('[')) { + try { + const parsed = JSON.parse(source); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + return source + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + } + + private stringifyParams(value: any) { + if (value === undefined || value === null || value === '') return null; + if (typeof value === 'string') { + const source = value.trim(); + if (!source) return null; + this.parseJson(source); + return source; + } + return JSON.stringify(value); + } + + private parseJson(value: string | null | undefined) { + if (!value) return {}; + try { + return JSON.parse(value); + } catch { + throwVbenError('默认参数必须是合法 JSON'); + } + } + + private toRawBody(command: QqbotCommand): QqbotCommandBodyDto { + return { + aliases: this.parseList(command.aliases), + code: command.code, + cooldownMs: command.cooldownMs, + defaultParams: this.parseDefaultParams(command), + enabled: command.enabled, + errorTemplate: command.errorTemplate || '', + name: command.name, + operationKey: command.operationKey, + parserKey: command.parserKey, + pluginKey: command.pluginKey, + prefixes: this.parseList(command.prefixes), + priority: command.priority, + remark: command.remark, + replyTemplate: command.replyTemplate || '', + targetType: command.targetType, + }; + } +} diff --git a/src/qqbot/command/qqbot-reply-template.service.ts b/src/qqbot/command/qqbot-reply-template.service.ts new file mode 100644 index 0000000..7c8317e --- /dev/null +++ b/src/qqbot/command/qqbot-reply-template.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class QqbotReplyTemplateService { + render(template: string | undefined | null, data: Record) { + const source = `${template || ''}`.trim(); + if (!source) return ''; + return source.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_match, path) => { + const value = this.pickValue(data, path); + return value === undefined || value === null ? '' : `${value}`; + }); + } + + stringifyOutput(output: any) { + if (!output) return ''; + if (typeof output === 'string') return output; + if (typeof output.replyText === 'string') return output.replyText; + return JSON.stringify(output, null, 2); + } + + private pickValue(data: Record, path: string) { + return `${path}` + .split('.') + .reduce((current, key) => current?.[key], data); + } +} diff --git a/src/qqbot/plugin/qqbot-plugin-registry.service.ts b/src/qqbot/plugin/qqbot-plugin-registry.service.ts new file mode 100644 index 0000000..dc36bc8 --- /dev/null +++ b/src/qqbot/plugin/qqbot-plugin-registry.service.ts @@ -0,0 +1,104 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { throwVbenError } from '@/common'; +import { QqbotFf14MarketPluginService } from '../plugins/ff14Market/qqbot-ff14-market.plugin'; +import type { + QqbotIntegrationPlugin, + QqbotPluginHealth, + QqbotPluginOperationContext, + QqbotPluginOperationSummary, + QqbotPluginSummary, +} from './qqbot-plugin.types'; + +@Injectable() +export class QqbotPluginRegistryService implements OnModuleInit { + private readonly plugins = new Map(); + + constructor(private readonly ff14MarketPlugin: QqbotFf14MarketPluginService) {} + + onModuleInit() { + this.register(this.ff14MarketPlugin.getPlugin()); + } + + register(plugin: QqbotIntegrationPlugin) { + if (!plugin.key || !plugin.operations.length) { + throwVbenError('QQBot 插件必须包含 key 和 operation'); + } + this.plugins.set(plugin.key, plugin); + } + + listPlugins(): QqbotPluginSummary[] { + return [...this.plugins.values()].map((plugin) => ({ + description: plugin.description, + key: plugin.key, + name: plugin.name, + operationCount: plugin.operations.length, + version: plugin.version, + })); + } + + listOperations(pluginKey?: string): QqbotPluginOperationSummary[] { + return this.getPlugins(pluginKey).flatMap((plugin) => + plugin.operations.map((operation) => ({ + cacheTtlMs: operation.cacheTtlMs, + description: operation.description, + inputSchema: operation.inputSchema, + key: operation.key, + name: operation.name, + outputSchema: operation.outputSchema, + pluginKey: plugin.key, + })), + ); + } + + async health(pluginKey?: string): Promise { + const plugins = this.getPlugins(pluginKey); + return Promise.all( + plugins.map(async (plugin) => { + if (!plugin.healthCheck) { + return { + checkedAt: new Date().toISOString(), + message: '插件未提供健康检查', + status: 'healthy', + }; + } + return plugin.healthCheck(); + }), + ); + } + + async execute( + pluginKey: string, + operationKey: string, + input: Record, + context: QqbotPluginOperationContext = {}, + ) { + const operation = this.getOperation(pluginKey, operationKey); + return operation.execute(input, context); + } + + assertOperation(pluginKey?: string, operationKey?: string) { + if (!pluginKey || !operationKey) { + throwVbenError('请选择插件和插件能力'); + } + this.getOperation(pluginKey, operationKey); + } + + private getOperation(pluginKey: string, operationKey: string) { + const plugin = this.plugins.get(pluginKey); + if (!plugin) throwVbenError(`QQBot 插件不存在:${pluginKey}`); + + const operation = plugin.operations.find( + (item) => item.key === operationKey, + ); + if (!operation) { + throwVbenError(`QQBot 插件能力不存在:${pluginKey}.${operationKey}`); + } + return operation; + } + + private getPlugins(pluginKey?: string) { + if (!pluginKey) return [...this.plugins.values()]; + const plugin = this.plugins.get(pluginKey); + return plugin ? [plugin] : []; + } +} diff --git a/src/qqbot/plugin/qqbot-plugin.controller.ts b/src/qqbot/plugin/qqbot-plugin.controller.ts new file mode 100644 index 0000000..0d670d9 --- /dev/null +++ b/src/qqbot/plugin/qqbot-plugin.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Get, 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 { QqbotPluginRegistryService } from './qqbot-plugin-registry.service'; + +@ApiTags('qqbot-plugin') +@Controller('qqbot/plugin') +@UseGuards(JwtAuthGuard) +export class QqbotPluginController { + constructor(private readonly pluginRegistry: QqbotPluginRegistryService) {} + + @Get('list') + @ApiOperation({ summary: 'QQBot 插件列表' }) + async list() { + return vbenSuccess(this.pluginRegistry.listPlugins()); + } + + @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)); + } + + @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)); + } +} diff --git a/src/qqbot/plugin/qqbot-plugin.types.ts b/src/qqbot/plugin/qqbot-plugin.types.ts new file mode 100644 index 0000000..3fdc556 --- /dev/null +++ b/src/qqbot/plugin/qqbot-plugin.types.ts @@ -0,0 +1,57 @@ +import type { + QqbotNormalizedMessage, + QqbotPluginHealthStatus, +} from '../qqbot.types'; +import type { QqbotCommand } from '../command/qqbot-command.entity'; + +export type QqbotPluginHealth = { + checkedAt: string; + message?: string; + status: QqbotPluginHealthStatus; +}; + +export type QqbotPluginOperationContext = { + args?: Record; + command?: QqbotCommand; + message?: QqbotNormalizedMessage; +}; + +export type QqbotPluginOperation = { + cacheTtlMs?: number; + description?: string; + inputSchema?: Record; + key: string; + name: string; + outputSchema?: Record; + execute: ( + input: Input, + context: QqbotPluginOperationContext, + ) => Promise; +}; + +export type QqbotIntegrationPlugin = { + description?: string; + healthCheck?: () => Promise; + key: string; + name: string; + operations: QqbotPluginOperation[]; + version: string; +}; + +export type QqbotPluginSummary = { + description?: string; + key: string; + name: string; + operationCount: number; + version: string; +}; + +export type QqbotPluginOperationSummary = { + cacheTtlMs?: number; + description?: string; + inputSchema?: Record; + key: string; + name: string; + outputSchema?: Record; + pluginKey: string; +}; diff --git a/src/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts b/src/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts new file mode 100644 index 0000000..fadad87 --- /dev/null +++ b/src/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts @@ -0,0 +1,257 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as http from 'node:http'; +import * as https from 'node:https'; + +type HttpMethod = 'GET'; + +type XivapiSearchItem = { + fields?: { + Icon?: string; + LevelItem?: number; + Name?: string; + }; + id?: number; + name?: string; + row_id?: number; + sheet?: string; +}; + +type UniversalisListing = { + hq?: boolean; + lastReviewTime?: number; + pricePerUnit?: number; + quantity?: number; + worldName?: string; +}; + +type UniversalisMarketResponse = { + currentAveragePrice?: number; + currentAveragePriceHQ?: number; + currentAveragePriceNQ?: number; + itemID?: number; + lastUploadTime?: number; + listings?: UniversalisListing[]; + minPrice?: number; + minPriceHQ?: number; + minPriceNQ?: number; + worldName?: string; +}; + +export type QqbotFf14ResolvedItem = { + icon?: string; + itemId: number; + itemLevel?: number; + name: string; +}; + +export type QqbotFf14PriceResult = { + averagePrice?: number; + hq?: boolean; + item: QqbotFf14ResolvedItem; + listings: UniversalisListing[]; + minPrice?: number; + replyText: string; + updatedAt?: string; + world: string; +}; + +@Injectable() +export class QqbotFf14ClientService { + private readonly xivapiBaseUrl: string; + private readonly universalisBaseUrl: string; + + constructor(private readonly configService: ConfigService) { + this.xivapiBaseUrl = + this.configService.get('FF14_XIVAPI_BASE_URL') || + 'https://v2.xivapi.com/api'; + this.universalisBaseUrl = + this.configService.get('FF14_UNIVERSALIS_BASE_URL') || + 'https://universalis.app/api/v2'; + } + + async resolveItem(params: { + item?: string; + itemId?: number | string; + language?: string; + }): Promise { + const itemId = Number(params.itemId || params.item); + if (Number.isInteger(itemId) && itemId > 0) { + return this.getItemById(itemId, params.language); + } + + const keyword = `${params.item || ''}`.trim(); + if (!keyword) throw new Error('请提供 FF14 物品名称或物品 ID'); + + const url = new URL(`${this.xivapiBaseUrl}/search`); + url.searchParams.set('sheets', 'Item'); + url.searchParams.set('query', keyword); + url.searchParams.set('language', params.language || 'zh'); + + const data = await this.requestJson<{ results?: XivapiSearchItem[] }>( + url, + 'GET', + ); + const item = (data.results || []).find( + (result) => + result.sheet === 'Item' || result.fields?.Name || result.name, + ); + if (!item) throw new Error(`未找到 FF14 物品:${keyword}`); + + return { + icon: item.fields?.Icon, + itemId: Number(item.row_id || item.id), + itemLevel: item.fields?.LevelItem, + name: item.fields?.Name || item.name || keyword, + }; + } + + async getPrice(params: { + hq?: boolean; + item?: string; + itemId?: number | string; + language?: string; + world?: string; + }): Promise { + const world = this.normalizeWorld(params.world); + const item = await this.resolveItem(params); + const url = new URL(`${this.universalisBaseUrl}/${world}/${item.itemId}`); + url.searchParams.set('entries', '5'); + url.searchParams.set('listings', '5'); + if (params.hq !== undefined) url.searchParams.set('hq', `${params.hq}`); + + const data = await this.requestJson(url, 'GET'); + const minPrice = this.pickPrice(data, params.hq, 'min'); + const averagePrice = this.pickPrice(data, params.hq, 'average'); + const listings = (data.listings || []).slice(0, 5); + const updatedAt = data.lastUploadTime + ? new Date(data.lastUploadTime).toISOString() + : undefined; + + return { + averagePrice, + hq: params.hq, + item, + listings, + minPrice, + replyText: this.buildReplyText({ + averagePrice, + hq: params.hq, + item, + listings, + minPrice, + updatedAt, + world, + }), + updatedAt, + world, + }; + } + + private async getItemById( + itemId: number, + language = 'zh', + ): Promise { + const url = new URL(`${this.xivapiBaseUrl}/sheet/Item/${itemId}`); + url.searchParams.set('language', language); + const data = await this.requestJson>(url, 'GET'); + const fields = data.fields || data; + return { + icon: fields.Icon, + itemId, + itemLevel: fields.LevelItem, + name: fields.Name || `${itemId}`, + }; + } + + private buildReplyText(result: Omit) { + const quality = result.hq === undefined ? '' : result.hq ? ' HQ' : ' NQ'; + const minPrice = + result.minPrice === undefined ? '暂无' : `${result.minPrice}`; + const averagePrice = + result.averagePrice === undefined ? '暂无' : `${result.averagePrice}`; + const listingText = result.listings.length + ? result.listings + .slice(0, 3) + .map((item, index) => { + const hq = item.hq ? 'HQ' : 'NQ'; + return `${index + 1}. ${item.pricePerUnit} x${item.quantity || 1} ${hq}`; + }) + .join('\n') + : '暂无在售记录'; + + return [ + `FF14 查价:${result.item.name}${quality}`, + `服务器:${result.world}`, + `最低价:${minPrice}`, + `均价:${averagePrice}`, + `近期挂单:`, + listingText, + ].join('\n'); + } + + private pickPrice( + data: UniversalisMarketResponse, + hq: boolean | undefined, + type: 'average' | 'min', + ) { + if (type === 'min') { + if (hq === true) return data.minPriceHQ; + if (hq === false) return data.minPriceNQ; + return data.minPrice ?? data.minPriceNQ ?? data.minPriceHQ; + } + if (hq === true) return data.currentAveragePriceHQ; + if (hq === false) return data.currentAveragePriceNQ; + return ( + data.currentAveragePrice ?? + data.currentAveragePriceNQ ?? + data.currentAveragePriceHQ + ); + } + + private normalizeWorld(world?: string) { + const raw = + `${world || this.configService.get('FF14_DEFAULT_WORLD') || '中国'}`.trim(); + return encodeURIComponent(raw); + } + + private requestJson(url: URL, method: HttpMethod) { + return new Promise((resolve, reject) => { + const client = url.protocol === 'http:' ? http : https; + const request = client.request( + url, + { + headers: { + Accept: 'application/json', + 'User-Agent': 'kt-template-online-api/qqbot', + }, + method, + timeout: 8000, + }, + (response) => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', (chunk) => { + body += chunk; + }); + response.on('end', () => { + if ((response.statusCode || 500) >= 400) { + reject(new Error(`FF14 接口请求失败:${response.statusCode}`)); + return; + } + try { + resolve(JSON.parse(body) as T); + } catch { + reject(new Error('FF14 接口返回不是合法 JSON')); + } + }); + }, + ); + request.on('timeout', () => { + request.destroy(new Error('FF14 接口请求超时')); + }); + request.on('error', reject); + request.end(); + }); + } +} diff --git a/src/qqbot/plugins/ff14Market/qqbot-ff14-market.plugin.ts b/src/qqbot/plugins/ff14Market/qqbot-ff14-market.plugin.ts new file mode 100644 index 0000000..93cbf97 --- /dev/null +++ b/src/qqbot/plugins/ff14Market/qqbot-ff14-market.plugin.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@nestjs/common'; +import type { QqbotIntegrationPlugin } from '../../plugin/qqbot-plugin.types'; +import { QqbotFf14ClientService } from './qqbot-ff14-client.service'; + +@Injectable() +export class QqbotFf14MarketPluginService { + constructor(private readonly ff14ClientService: QqbotFf14ClientService) {} + + getPlugin(): QqbotIntegrationPlugin { + return { + description: '对接 XIVAPI v2 与 Universalis,提供 FF14 物品解析和市场价格查询能力。', + healthCheck: async () => { + const checkedAt = new Date().toISOString(); + try { + await this.ff14ClientService.resolveItem({ + itemId: 2, + language: 'en', + }); + return { + checkedAt, + message: 'FF14 插件可用', + status: 'healthy', + }; + } catch (err) { + return { + checkedAt, + message: err instanceof Error ? err.message : 'FF14 插件不可用', + status: 'degraded', + }; + } + }, + key: 'ff14Market', + name: 'FF14 市场价格', + operations: [ + { + description: '按物品名称或 ID 解析 XIVAPI 物品信息。', + inputSchema: { + properties: { + item: { description: '物品名称或 ID', type: 'string' }, + itemId: { description: '物品 ID', type: 'number' }, + language: { default: 'zh', type: 'string' }, + }, + required: ['item'], + type: 'object', + }, + key: 'ff14.item.resolve', + name: '解析物品', + outputSchema: { + properties: { + itemId: { type: 'number' }, + name: { type: 'string' }, + }, + type: 'object', + }, + execute: async (input) => + await this.ff14ClientService.resolveItem(input), + }, + { + cacheTtlMs: 60_000, + description: '查询指定服务器的 FF14 市场最低价、均价与近期挂单。', + inputSchema: { + properties: { + hq: { description: '是否只查 HQ', type: 'boolean' }, + item: { description: '物品名称或 ID', type: 'string' }, + itemId: { description: '物品 ID', type: 'number' }, + language: { default: 'zh', type: 'string' }, + world: { description: '服务器名或大区名', type: 'string' }, + }, + required: ['item', 'world'], + type: 'object', + }, + key: 'ff14.market.price', + name: '市场查价', + outputSchema: { + properties: { + averagePrice: { type: 'number' }, + minPrice: { type: 'number' }, + replyText: { type: 'string' }, + world: { type: 'string' }, + }, + type: 'object', + }, + execute: async (input) => await this.ff14ClientService.getPrice(input), + }, + ], + version: '1.0.0', + }; + } +} diff --git a/src/qqbot/qqbot.module.ts b/src/qqbot/qqbot.module.ts index e4cffe6..9e039f9 100644 --- a/src/qqbot/qqbot.module.ts +++ b/src/qqbot/qqbot.module.ts @@ -6,6 +6,13 @@ import { QqbotAccountController } from './account/qqbot-account.controller'; import { QqbotAccount } from './account/qqbot-account.entity'; import { QqbotAccountService } from './account/qqbot-account.service'; import { QqbotNapcatLoginService } from './account/qqbot-napcat-login.service'; +import { QqbotCommandController } from './command/qqbot-command.controller'; +import { QqbotCommand } from './command/qqbot-command.entity'; +import { QqbotCommandEngineService } from './command/qqbot-command-engine.service'; +import { QqbotCommandLog } from './command/qqbot-command-log.entity'; +import { QqbotCommandParserService } from './command/qqbot-command-parser.service'; +import { QqbotCommandService } from './command/qqbot-command.service'; +import { QqbotReplyTemplateService } from './command/qqbot-reply-template.service'; import { QqbotReverseWsService } from './connection/qqbot-reverse-ws.service'; import { QqbotConfig } from './config/qqbot-config.entity'; import { QqbotConfigService } from './config/qqbot-config.service'; @@ -26,6 +33,10 @@ 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 { 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 { QqbotRuleController } from './rule/qqbot-rule.controller'; import { QqbotRule } from './rule/qqbot-rule.entity'; import { QqbotRuleEngineService } from './rule/qqbot-rule-engine.service'; @@ -43,6 +54,8 @@ import { QqbotSendService } from './send/qqbot-send.service'; QqbotAccount, QqbotAllowlist, QqbotBlocklist, + QqbotCommand, + QqbotCommandLog, QqbotConfig, QqbotConversation, QqbotDedupe, @@ -55,24 +68,33 @@ import { QqbotSendService } from './send/qqbot-send.service'; ], controllers: [ QqbotAccountController, + QqbotCommandController, QqbotDashboardController, QqbotMessageController, QqbotPermissionController, + QqbotPluginController, QqbotRuleController, QqbotSendController, ], providers: [ QqbotAccountService, QqbotBusService, + QqbotCommandEngineService, + QqbotCommandParserService, + QqbotCommandService, QqbotConfigService, QqbotDashboardService, QqbotDedupeService, QqbotEventService, + QqbotFf14ClientService, + QqbotFf14MarketPluginService, QqbotMessageService, QqbotNapcatLoginService, QqbotNapcatContainerService, QqbotPermissionService, + QqbotPluginRegistryService, QqbotRateLimitService, + QqbotReplyTemplateService, QqbotReverseWsService, QqbotRuleEngineService, QqbotRuleService, diff --git a/src/qqbot/qqbot.types.ts b/src/qqbot/qqbot.types.ts index b437e2f..2394bd5 100644 --- a/src/qqbot/qqbot.types.ts +++ b/src/qqbot/qqbot.types.ts @@ -24,6 +24,10 @@ export type QqbotRuleMatchType = 'equals' | 'keyword' | 'regex'; export type QqbotRuleTargetType = 'all' | 'channel' | 'group' | 'private'; +export type QqbotCommandParserType = 'ff14Price' | 'plain'; + +export type QqbotPluginHealthStatus = 'degraded' | 'healthy' | 'offline'; + export type QqbotSendStatus = 'failed' | 'pending' | 'success'; export type QqbotPermissionTargetType = 'channel' | 'group' | 'private' | 'qq'; diff --git a/src/qqbot/rule/qqbot-rule-engine.service.ts b/src/qqbot/rule/qqbot-rule-engine.service.ts index 6534919..b34c62e 100644 --- a/src/qqbot/rule/qqbot-rule-engine.service.ts +++ b/src/qqbot/rule/qqbot-rule-engine.service.ts @@ -1,5 +1,6 @@ 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 { QqbotSendService } from '../send/qqbot-send.service'; import { QqbotRuleService } from './qqbot-rule.service'; @@ -9,6 +10,7 @@ export class QqbotRuleEngineService { private readonly logger = new Logger(QqbotRuleEngineService.name); constructor( + private readonly commandEngineService: QqbotCommandEngineService, private readonly permissionService: QqbotPermissionService, private readonly ruleService: QqbotRuleService, private readonly sendService: QqbotSendService, @@ -17,6 +19,7 @@ export class QqbotRuleEngineService { async handleMessage(message: QqbotNormalizedMessage) { if (await this.permissionService.isBlocked(message)) return; if (!(await this.permissionService.isAllowed(message))) return; + if (await this.commandEngineService.handleMessage(message)) return; const rules = await this.ruleService.listEnabledForMessage(message); for (const rule of rules) {