feat: 接入 QQBot 在线命令与 FF14 插件

This commit is contained in:
sunlei 2026-06-02 14:28:41 +08:00
parent c51535eed8
commit 31119bbc93
18 changed files with 1626 additions and 4 deletions

View File

@ -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

View File

@ -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)

View File

@ -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<string, any>,
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<string, any>,
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<string, any>) {
return {
...this.commandService.parseDefaultParams(command),
...this.removeUndefined(input),
};
}
private removeUndefined(input: Record<string, any>) {
return Object.entries(input).reduce<Record<string, any>>(
(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,
};
}
}

View File

@ -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);
}
}

View File

@ -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<string, any>;
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<string, string | true>();
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();
}
}

View File

@ -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));
}
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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<QqbotCommand>,
@InjectRepository(QqbotCommandLog)
private readonly commandLogRepository: Repository<QqbotCommandLog>,
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<string, any>;
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<QqbotCommand>;
}
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,
};
}
}

View File

@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class QqbotReplyTemplateService {
render(template: string | undefined | null, data: Record<string, any>) {
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<string, any>, path: string) {
return `${path}`
.split('.')
.reduce((current, key) => current?.[key], data);
}
}

View File

@ -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<string, QqbotIntegrationPlugin>();
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<QqbotPluginHealth[]> {
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<string, any>,
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] : [];
}
}

View File

@ -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));
}
}

View File

@ -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<string, any>;
command?: QqbotCommand;
message?: QqbotNormalizedMessage;
};
export type QqbotPluginOperation<Input = any, Output = any> = {
cacheTtlMs?: number;
description?: string;
inputSchema?: Record<string, any>;
key: string;
name: string;
outputSchema?: Record<string, any>;
execute: (
input: Input,
context: QqbotPluginOperationContext,
) => Promise<Output>;
};
export type QqbotIntegrationPlugin = {
description?: string;
healthCheck?: () => Promise<QqbotPluginHealth>;
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<string, any>;
key: string;
name: string;
outputSchema?: Record<string, any>;
pluginKey: string;
};

View File

@ -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<string>('FF14_XIVAPI_BASE_URL') ||
'https://v2.xivapi.com/api';
this.universalisBaseUrl =
this.configService.get<string>('FF14_UNIVERSALIS_BASE_URL') ||
'https://universalis.app/api/v2';
}
async resolveItem(params: {
item?: string;
itemId?: number | string;
language?: string;
}): Promise<QqbotFf14ResolvedItem> {
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<QqbotFf14PriceResult> {
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<UniversalisMarketResponse>(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<QqbotFf14ResolvedItem> {
const url = new URL(`${this.xivapiBaseUrl}/sheet/Item/${itemId}`);
url.searchParams.set('language', language);
const data = await this.requestJson<Record<string, any>>(url, 'GET');
const fields = data.fields || data;
return {
icon: fields.Icon,
itemId,
itemLevel: fields.LevelItem,
name: fields.Name || `${itemId}`,
};
}
private buildReplyText(result: Omit<QqbotFf14PriceResult, 'replyText'>) {
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<string>('FF14_DEFAULT_WORLD') || '中国'}`.trim();
return encodeURIComponent(raw);
}
private requestJson<T>(url: URL, method: HttpMethod) {
return new Promise<T>((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();
});
}
}

View File

@ -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',
};
}
}

View File

@ -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,

View File

@ -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';

View File

@ -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) {