From ae2fd41e0f62d03334c8dd59a8795e7fd0527c66 Mon Sep 17 00:00:00 2001 From: sunlei Date: Tue, 2 Jun 2026 15:56:07 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20QQBot=20FF14=20?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E7=89=A9=E5=93=81=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + sql/qqbot-init.sql | 2 +- .../command/qqbot-command-engine.service.ts | 69 +++++--- .../command/qqbot-command-parser.service.ts | 2 +- .../ff14Market/qqbot-ff14-client.service.ts | 156 +++++++++++++----- .../ff14Market/qqbot-ff14-market.plugin.ts | 4 +- 6 files changed, 167 insertions(+), 67 deletions(-) diff --git a/.env.example b/.env.example index 3d37f08..06c48f1 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,7 @@ MQTT_PASSWORD= MQTT_CLIENT_ID=kt-template-online-api-qqbot FF14_XIVAPI_BASE_URL=https://v2.xivapi.com/api +FF14_XIVAPI_CHS_BASE_URL=https://xivapi-v2.xivcdn.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 5b8d821..c8616fe 100644 --- a/sql/qqbot-init.sql +++ b/sql/qqbot-init.sql @@ -315,7 +315,7 @@ ON DUPLICATE KEY UPDATE INSERT INTO `qqbot_command` (`id`, `code`, `name`, `aliases`, `prefixes`, `plugin_key`, `operation_key`, `parser_key`, `target_type`, `default_params`, `reply_template`, `error_template`, `enabled`, `priority`, `cooldown_ms`, `remark`) VALUES - (2041700000000300501, 'ff14_price', 'FF14 查价', '["查价","price","ff14price"]', '["/","!","!"]', 'ff14Market', 'ff14.market.price', 'ff14Price', 'all', '{"language":"zh","world":"中国"}', '', 'FF14 查价失败:{{error}}', 0, 0, 1500, '默认示例命令;默认查询范围为中国,可按需改为具体服务器') + (2041700000000300501, 'ff14_price', 'FF14 查价', '["查价","price","ff14price"]', '["/","!","!"]', 'ff14Market', 'ff14.market.price', 'ff14Price', 'all', '{"language":"chs","world":"中国"}', '', 'FF14 查价失败:{{error}}', 0, 0, 1500, '默认示例命令;默认查询范围为中国,可按需改为具体服务器') ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `plugin_key` = VALUES(`plugin_key`), diff --git a/src/qqbot/command/qqbot-command-engine.service.ts b/src/qqbot/command/qqbot-command-engine.service.ts index eb9d1d1..4fb710a 100644 --- a/src/qqbot/command/qqbot-command-engine.service.ts +++ b/src/qqbot/command/qqbot-command-engine.service.ts @@ -91,24 +91,38 @@ export class QqbotCommandEngineService { } 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, - }; + try { + 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, + status: 'success', + }; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '命令执行失败'; + return { + command: this.commandService.toResponse(command), + errorMessage, + input, + matched: true, + output: null, + replyText: this.buildErrorReplyText(command, input, errorMessage), + status: 'failed', + }; + } } private async findMatchedCommand(message: QqbotNormalizedMessage) { @@ -140,11 +154,7 @@ export class QqbotCommandEngineService { message: QqbotNormalizedMessage, errorMessage: string, ) { - const reply = - this.replyTemplate.render(command.errorTemplate, { - error: errorMessage, - input, - }) || `命令执行失败:${errorMessage}`; + const reply = this.buildErrorReplyText(command, input, errorMessage); try { await this.sendService.sendText({ channelId: message.channelId, @@ -162,6 +172,19 @@ export class QqbotCommandEngineService { } } + private buildErrorReplyText( + command: QqbotCommand, + input: Record, + errorMessage: string, + ) { + return ( + this.replyTemplate.render(command.errorTemplate, { + error: errorMessage, + input, + }) || `命令执行失败:${errorMessage}` + ); + } + private mergeInput(command: QqbotCommand, input: Record) { return { ...this.commandService.parseDefaultParams(command), diff --git a/src/qqbot/command/qqbot-command-parser.service.ts b/src/qqbot/command/qqbot-command-parser.service.ts index 7efe2c0..a14a2cb 100644 --- a/src/qqbot/command/qqbot-command-parser.service.ts +++ b/src/qqbot/command/qqbot-command-parser.service.ts @@ -131,7 +131,7 @@ export class QqbotCommandParserService { dataCenter, hq: this.normalizeHq(flags.get('hq')), item, - language: this.normalizeString(flags.get('lang')) || 'zh', + language: this.normalizeString(flags.get('lang')) || 'chs', raw: rawArgs, region, world, diff --git a/src/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts b/src/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts index 84f03bd..e36c6bf 100644 --- a/src/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts +++ b/src/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts @@ -8,9 +8,9 @@ type HttpMethod = 'GET'; type XivapiSearchItem = { fields?: { - Icon?: string; + Icon?: string | { path?: string; path_hr1?: string }; IsUntradable?: boolean; - LevelItem?: number; + LevelItem?: number | { row_id?: number; value?: number }; Name?: string; }; id?: number; @@ -64,19 +64,16 @@ export type QqbotFf14PriceResult = { @Injectable() export class QqbotFf14ClientService { private readonly xivapiBaseUrl: string; + private readonly xivapiChsBaseUrl: string; private readonly universalisBaseUrl: string; - private readonly cnItemAliases = new Map< - string, - { itemId: number; name: string } - >([ - ['小鸣鼠', { itemId: 43590, name: '小鸣鼠角笛' }], - ['小鸣鼠角笛', { itemId: 43590, name: '小鸣鼠角笛' }], - ]); constructor(private readonly configService: ConfigService) { this.xivapiBaseUrl = this.configService.get('FF14_XIVAPI_BASE_URL') || 'https://v2.xivapi.com/api'; + this.xivapiChsBaseUrl = + this.configService.get('FF14_XIVAPI_CHS_BASE_URL') || + 'https://xivapi-v2.xivcdn.com/api'; this.universalisBaseUrl = this.configService.get('FF14_UNIVERSALIS_BASE_URL') || 'https://universalis.app/api/v2'; @@ -96,34 +93,14 @@ export class QqbotFf14ClientService { const keyword = `${params.item || ''}`.trim(); if (!keyword) throw new Error('请提供 FF14 物品名称或物品 ID'); - const alias = this.resolveCnItemAlias(keyword); - if (alias) { - return this.getItemById(alias.itemId, language, alias.name); - } - - const url = new URL(`${this.xivapiBaseUrl}/search`); - url.searchParams.set('sheets', 'Item'); - url.searchParams.set('fields', 'Name,Icon,LevelItem,IsUntradable'); - url.searchParams.set('query', `Name~"${this.escapeXivapiValue(keyword)}"`); - url.searchParams.set('language', language); - url.searchParams.set('limit', '10'); - - const data = await this.requestJson<{ results?: XivapiSearchItem[] }>( - url, - 'GET', - 'XIVAPI 物品解析', - ); - const item = (data.results || []).find( - (result) => - result.sheet === 'Item' || result.fields?.Name || result.name, - ); + const item = await this.searchItem(keyword, language); if (!item) throw new Error(`未找到 FF14 物品:${keyword}`); return { - icon: item.fields?.Icon, + icon: this.normalizeItemIcon(item.fields?.Icon), isUntradable: item.fields?.IsUntradable, itemId: Number(item.row_id || item.id), - itemLevel: item.fields?.LevelItem, + itemLevel: this.normalizeItemLevel(item.fields?.LevelItem), name: item.fields?.Name || item.name || keyword, }; } @@ -198,12 +175,16 @@ export class QqbotFf14ClientService { private async getItemById( itemId: number, - language = 'zh', + language = 'chs', displayName?: string, ): Promise { - const url = new URL(`${this.xivapiBaseUrl}/sheet/Item/${itemId}`); + const normalizedLanguage = this.normalizeXivapiLanguage(language); + const url = this.buildXivapiUrl( + `/sheet/Item/${itemId}`, + normalizedLanguage, + ); url.searchParams.set('fields', 'Name,Icon,LevelItem,IsUntradable'); - url.searchParams.set('language', this.normalizeXivapiLanguage(language)); + url.searchParams.set('language', normalizedLanguage); const data = await this.requestJson>( url, 'GET', @@ -211,10 +192,10 @@ export class QqbotFf14ClientService { ); const fields = data.fields || data; return { - icon: fields.Icon, + icon: this.normalizeItemIcon(fields.Icon), isUntradable: fields.IsUntradable, itemId, - itemLevel: fields.LevelItem, + itemLevel: this.normalizeItemLevel(fields.LevelItem), name: displayName || fields.Name || `${itemId}`, }; } @@ -294,12 +275,107 @@ export class QqbotFf14ClientService { } private normalizeXivapiLanguage(language?: string) { - const value = `${language || 'en'}`.trim().toLowerCase(); + const value = `${language || 'chs'}`.trim().toLowerCase(); + if (['zh', 'zh-cn', 'zh_hans', 'cn', 'chs'].includes(value)) return 'chs'; return ['en', 'ja', 'de', 'fr'].includes(value) ? value : 'en'; } - private resolveCnItemAlias(keyword: string) { - return this.cnItemAliases.get(keyword.trim()); + private buildXivapiUrl(path: string, language: string) { + const baseUrl = + language === 'chs' ? this.xivapiChsBaseUrl : this.xivapiBaseUrl; + return new URL(`${baseUrl.replace(/\/+$/, '')}${path}`); + } + + private async searchItem(keyword: string, language: string) { + const item = this.pickFirstSearchItem( + await this.searchItemsByLanguage(keyword, language, '='), + ); + if (item) return item; + + if (language !== 'en') { + const enItem = this.pickFirstSearchItem( + await this.searchItemsByLanguage(keyword, 'en', '='), + ); + if (enItem) return enItem; + } + + const fuzzyItems = await this.searchItemsByLanguage(keyword, language, '~'); + const fuzzyItem = this.pickSingleFuzzySearchItem(keyword, fuzzyItems); + if (fuzzyItem || language === 'en') return fuzzyItem; + + const enFuzzyItems = await this.searchItemsByLanguage(keyword, 'en', '~'); + return this.pickSingleFuzzySearchItem(keyword, enFuzzyItems); + } + + private async searchItemsByLanguage( + keyword: string, + language: string, + operator: '=' | '~', + ) { + const url = this.buildXivapiUrl('/search', language); + url.searchParams.set('sheets', 'Item'); + url.searchParams.set('fields', 'Name,Icon,LevelItem,IsUntradable'); + url.searchParams.set( + 'query', + `Name${operator}"${this.escapeXivapiValue(keyword)}"`, + ); + url.searchParams.set('language', language); + url.searchParams.set('limit', '10'); + + const data = await this.requestJson<{ results?: XivapiSearchItem[] }>( + url, + 'GET', + 'XIVAPI 物品解析', + ); + return (data.results || []).filter( + (result) => + result.sheet === 'Item' || result.fields?.Name || result.name, + ); + } + + private pickFirstSearchItem(items: XivapiSearchItem[]) { + return items[0]; + } + + private pickSingleFuzzySearchItem( + keyword: string, + items: XivapiSearchItem[], + ) { + if (items.length <= 1) return items[0]; + throw new Error( + `找到多个相似物品,请输入更完整名称或物品 ID:${this.formatSearchCandidates( + items, + )}`, + ); + } + + private formatSearchCandidates(items: XivapiSearchItem[]) { + return items + .slice(0, 5) + .map((item) => { + const name = item.fields?.Name || item.name || '未知物品'; + const id = item.row_id || item.id; + return id ? `${name}(ID:${id})` : name; + }) + .join('、'); + } + + private normalizeItemIcon(icon: unknown) { + if (typeof icon === 'string') return icon; + if (icon && typeof icon === 'object') { + const item = icon as { path?: string; path_hr1?: string }; + return item.path_hr1 || item.path; + } + return undefined; + } + + private normalizeItemLevel(level: unknown) { + if (typeof level === 'number') return level; + if (level && typeof level === 'object') { + const item = level as { row_id?: number; value?: number }; + return item.row_id ?? item.value; + } + return undefined; } private escapeXivapiValue(value: string) { diff --git a/src/qqbot/plugins/ff14Market/qqbot-ff14-market.plugin.ts b/src/qqbot/plugins/ff14Market/qqbot-ff14-market.plugin.ts index fd80d1d..61bd247 100644 --- a/src/qqbot/plugins/ff14Market/qqbot-ff14-market.plugin.ts +++ b/src/qqbot/plugins/ff14Market/qqbot-ff14-market.plugin.ts @@ -38,7 +38,7 @@ export class QqbotFf14MarketPluginService { properties: { item: { description: '物品名称或 ID', type: 'string' }, itemId: { description: '物品 ID', type: 'number' }, - language: { default: 'zh', type: 'string' }, + language: { default: 'chs', type: 'string' }, }, required: ['item'], type: 'object', @@ -64,7 +64,7 @@ export class QqbotFf14MarketPluginService { hq: { description: '是否只查 HQ', type: 'boolean' }, item: { description: '物品名称或 ID', type: 'string' }, itemId: { description: '物品 ID', type: 'number' }, - language: { default: 'zh', type: 'string' }, + language: { default: 'chs', type: 'string' }, region: { description: '地区名,如中国', type: 'string' }, world: { description: '小区/服务器名,如红玉海', type: 'string' }, },