From 92462ae736c775f4b3f0f226c8cb8d25e2423147 Mon Sep 17 00:00:00 2001 From: sunlei Date: Tue, 2 Jun 2026 15:14:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=89=A9=E5=B1=95=20QQBot=20FF14=20?= =?UTF-8?q?=E6=9F=A5=E4=BB=B7=E5=8C=BA=E6=9C=8D=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/qqbot-command-parser.service.ts | 99 ++++++++- .../ff14Market/qqbot-ff14-client.service.ts | 123 +++++++++-- .../ff14Market/qqbot-ff14-market.plugin.ts | 6 +- .../plugins/ff14Market/qqbot-ff14-worlds.ts | 205 ++++++++++++++++++ 4 files changed, 407 insertions(+), 26 deletions(-) create mode 100644 src/qqbot/plugins/ff14Market/qqbot-ff14-worlds.ts diff --git a/src/qqbot/command/qqbot-command-parser.service.ts b/src/qqbot/command/qqbot-command-parser.service.ts index 0b0bad3..7efe2c0 100644 --- a/src/qqbot/command/qqbot-command-parser.service.ts +++ b/src/qqbot/command/qqbot-command-parser.service.ts @@ -1,6 +1,13 @@ import { Injectable } from '@nestjs/common'; import type { QqbotCommand } from './qqbot-command.entity'; import type { QqbotNormalizedMessage } from '../qqbot.types'; +import { + isQqbotFf14DataCenterName, + isQqbotFf14LocationName, + isQqbotFf14RegionName, + isQqbotFf14WorldName, + splitQqbotFf14WorldPath, +} from '../plugins/ff14Market/qqbot-ff14-worlds'; export type QqbotCommandMatchResult = { alias: string; @@ -68,9 +75,9 @@ export class QqbotCommandParserService { const positional: string[] = []; for (const token of tokens) { - if (/^(hq|HQ)$/.test(token)) { + if (/^hq$/i.test(token)) { flags.set('hq', true); - } else if (/^(nq|NQ)$/.test(token)) { + } else if (/^nq$/i.test(token)) { flags.set('hq', 'false'); } else if (token.includes('=')) { const [key, ...rest] = token.split('='); @@ -80,27 +87,105 @@ export class QqbotCommandParserService { } } - let world = this.normalizeString(flags.get('world') || flags.get('server')); + let region = this.normalizeString(flags.get('region') || flags.get('地区')); + let dataCenter = this.normalizeString( + flags.get('dataCenter') || + flags.get('datacenter') || + flags.get('dc') || + flags.get('大区'), + ); + let world = this.normalizeString( + flags.get('world') || + flags.get('server') || + flags.get('服务器') || + flags.get('小区'), + ); let item = positional.join(' '); - if (!world && positional.length > 1) { - world = positional[positional.length - 1]; - item = positional.slice(0, -1).join(' '); + + const worldPath = splitQqbotFf14WorldPath(world); + if (worldPath.dataCenter && worldPath.world) { + dataCenter = dataCenter || worldPath.dataCenter; + region = region || worldPath.region || ''; + world = worldPath.world; + } + + if (!world && !dataCenter && positional.length > 1) { + const picked = this.pickTrailingFf14Location(positional); + if (picked) { + dataCenter = picked.dataCenter || dataCenter; + item = picked.item; + region = picked.region || region; + world = picked.world || world; + } } if (item.includes('@')) { const [itemName, worldName] = item.split('@'); + const itemWorldPath = splitQqbotFf14WorldPath(worldName); item = itemName.trim(); - world = world || worldName?.trim(); + dataCenter = dataCenter || itemWorldPath.dataCenter || ''; + region = region || itemWorldPath.region || ''; + world = world || itemWorldPath.world || worldName?.trim(); } return { + dataCenter, hq: this.normalizeHq(flags.get('hq')), item, language: this.normalizeString(flags.get('lang')) || 'zh', raw: rawArgs, + region, world, }; } + private pickTrailingFf14Location(positional: string[]) { + const last = positional[positional.length - 1]; + if (!isQqbotFf14LocationName(last)) return null; + + const path = splitQqbotFf14WorldPath(last); + if (path.dataCenter && path.world) { + return { + dataCenter: path.dataCenter, + item: positional.slice(0, -1).join(' '), + region: path.region, + world: path.world, + }; + } + + const previous = positional[positional.length - 2]; + const beforePrevious = positional[positional.length - 3]; + if ( + previous && + isQqbotFf14DataCenterName(previous) && + isQqbotFf14WorldName(last) + ) { + const hasRegion = beforePrevious && isQqbotFf14RegionName(beforePrevious); + return { + dataCenter: previous, + item: positional.slice(0, hasRegion ? -3 : -2).join(' '), + region: hasRegion ? beforePrevious : undefined, + world: last, + }; + } + + if ( + previous && + isQqbotFf14RegionName(previous) && + isQqbotFf14DataCenterName(last) + ) { + return { + dataCenter: last, + item: positional.slice(0, -2).join(' '), + region: previous, + }; + } + + return { + item: positional.slice(0, -1).join(' '), + world: last, + }; + } + private normalizeHq(value?: string | true) { if (value === undefined) return undefined; if (value === true) return true; diff --git a/src/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts b/src/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts index fadad87..c0ba33a 100644 --- a/src/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts +++ b/src/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts @@ -2,12 +2,14 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as http from 'node:http'; import * as https from 'node:https'; +import { resolveQqbotFf14MarketTarget } from './qqbot-ff14-worlds'; type HttpMethod = 'GET'; type XivapiSearchItem = { fields?: { Icon?: string; + IsUntradable?: boolean; LevelItem?: number; Name?: string; }; @@ -40,6 +42,7 @@ type UniversalisMarketResponse = { export type QqbotFf14ResolvedItem = { icon?: string; + isUntradable?: boolean; itemId: number; itemLevel?: number; name: string; @@ -60,6 +63,13 @@ export type QqbotFf14PriceResult = { export class QqbotFf14ClientService { private readonly xivapiBaseUrl: 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 = @@ -75,22 +85,31 @@ export class QqbotFf14ClientService { itemId?: number | string; language?: string; }): Promise { + const language = this.normalizeXivapiLanguage(params.language); const itemId = Number(params.itemId || params.item); if (Number.isInteger(itemId) && itemId > 0) { - return this.getItemById(itemId, params.language); + return this.getItemById(itemId, language); } 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('query', keyword); - url.searchParams.set('language', params.language || 'zh'); + 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) => @@ -100,6 +119,7 @@ export class QqbotFf14ClientService { return { icon: item.fields?.Icon, + isUntradable: item.fields?.IsUntradable, itemId: Number(item.row_id || item.id), itemLevel: item.fields?.LevelItem, name: item.fields?.Name || item.name || keyword, @@ -107,23 +127,49 @@ export class QqbotFf14ClientService { } async getPrice(params: { + dataCenter?: string; hq?: boolean; item?: string; itemId?: number | string; language?: string; + region?: string; world?: string; }): Promise { - const world = this.normalizeWorld(params.world); + const marketTarget = this.resolveMarketTarget(params); const item = await this.resolveItem(params); - const url = new URL(`${this.universalisBaseUrl}/${world}/${item.itemId}`); + if (item.isUntradable) { + return { + hq: params.hq, + item, + listings: [], + replyText: `FF14 查价:${item.name}\n该物品不可交易,暂无市场价格。`, + world: marketTarget.label, + }; + } + + const url = new URL( + `${this.universalisBaseUrl}/${encodeURIComponent( + marketTarget.target, + )}/${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 data = await this.requestJson( + url, + 'GET', + 'Universalis 市场查询', + ); const listings = (data.listings || []).slice(0, 5); + const minPrice = this.normalizeMarketPrice( + this.pickPrice(data, params.hq, 'min'), + listings, + ); + const averagePrice = this.normalizeMarketPrice( + this.pickPrice(data, params.hq, 'average'), + listings, + ); const updatedAt = data.lastUploadTime ? new Date(data.lastUploadTime).toISOString() : undefined; @@ -141,26 +187,33 @@ export class QqbotFf14ClientService { listings, minPrice, updatedAt, - world, + world: marketTarget.label, }), updatedAt, - world, + world: marketTarget.label, }; } private async getItemById( itemId: number, language = 'zh', + displayName?: string, ): Promise { const url = new URL(`${this.xivapiBaseUrl}/sheet/Item/${itemId}`); - url.searchParams.set('language', language); - const data = await this.requestJson>(url, 'GET'); + url.searchParams.set('fields', 'Name,Icon,LevelItem,IsUntradable'); + url.searchParams.set('language', this.normalizeXivapiLanguage(language)); + const data = await this.requestJson>( + url, + 'GET', + 'XIVAPI 物品解析', + ); const fields = data.fields || data; return { icon: fields.Icon, + isUntradable: fields.IsUntradable, itemId, itemLevel: fields.LevelItem, - name: fields.Name || `${itemId}`, + name: displayName || fields.Name || `${itemId}`, }; } @@ -182,7 +235,7 @@ export class QqbotFf14ClientService { return [ `FF14 查价:${result.item.name}${quality}`, - `服务器:${result.world}`, + `查询范围:${result.world}`, `最低价:${minPrice}`, `均价:${averagePrice}`, `近期挂单:`, @@ -209,13 +262,47 @@ export class QqbotFf14ClientService { ); } + private normalizeMarketPrice( + price: number | undefined, + listings: UniversalisListing[], + ) { + if (!listings.length && (!price || price <= 0)) return undefined; + return price; + } + private normalizeWorld(world?: string) { const raw = `${world || this.configService.get('FF14_DEFAULT_WORLD') || '中国'}`.trim(); - return encodeURIComponent(raw); + return raw; } - private requestJson(url: URL, method: HttpMethod) { + private resolveMarketTarget(params: { + dataCenter?: string; + region?: string; + world?: string; + }) { + return resolveQqbotFf14MarketTarget({ + dataCenter: params.dataCenter, + fallback: this.normalizeWorld(params.world), + region: params.region, + world: params.world, + }); + } + + private normalizeXivapiLanguage(language?: string) { + const value = `${language || 'en'}`.trim().toLowerCase(); + return ['en', 'ja', 'de', 'fr'].includes(value) ? value : 'en'; + } + + private resolveCnItemAlias(keyword: string) { + return this.cnItemAliases.get(keyword.trim()); + } + + private escapeXivapiValue(value: string) { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + } + + private requestJson(url: URL, method: HttpMethod, context: string) { return new Promise((resolve, reject) => { const client = url.protocol === 'http:' ? http : https; const request = client.request( @@ -236,7 +323,9 @@ export class QqbotFf14ClientService { }); response.on('end', () => { if ((response.statusCode || 500) >= 400) { - reject(new Error(`FF14 接口请求失败:${response.statusCode}`)); + reject( + new Error(`${context}失败:${response.statusCode}`), + ); return; } try { diff --git a/src/qqbot/plugins/ff14Market/qqbot-ff14-market.plugin.ts b/src/qqbot/plugins/ff14Market/qqbot-ff14-market.plugin.ts index 93cbf97..fd80d1d 100644 --- a/src/qqbot/plugins/ff14Market/qqbot-ff14-market.plugin.ts +++ b/src/qqbot/plugins/ff14Market/qqbot-ff14-market.plugin.ts @@ -60,13 +60,15 @@ export class QqbotFf14MarketPluginService { description: '查询指定服务器的 FF14 市场最低价、均价与近期挂单。', inputSchema: { properties: { + dataCenter: { description: '大区名,如陆行鸟', type: 'string' }, hq: { description: '是否只查 HQ', type: 'boolean' }, item: { description: '物品名称或 ID', type: 'string' }, itemId: { description: '物品 ID', type: 'number' }, language: { default: 'zh', type: 'string' }, - world: { description: '服务器名或大区名', type: 'string' }, + region: { description: '地区名,如中国', type: 'string' }, + world: { description: '小区/服务器名,如红玉海', type: 'string' }, }, - required: ['item', 'world'], + required: ['item'], type: 'object', }, key: 'ff14.market.price', diff --git a/src/qqbot/plugins/ff14Market/qqbot-ff14-worlds.ts b/src/qqbot/plugins/ff14Market/qqbot-ff14-worlds.ts new file mode 100644 index 0000000..5c9b943 --- /dev/null +++ b/src/qqbot/plugins/ff14Market/qqbot-ff14-worlds.ts @@ -0,0 +1,205 @@ +export type QqbotFf14DataCenter = { + name: string; + region: string; + worlds: string[]; +}; + +export type QqbotFf14MarketTarget = { + dataCenter?: string; + label: string; + region?: string; + target: string; + world?: string; +}; + +export const QQBOT_FF14_DEFAULT_REGION = '中国'; + +export const QQBOT_FF14_CHINA_DATA_CENTERS: QqbotFf14DataCenter[] = [ + { + name: '陆行鸟', + region: QQBOT_FF14_DEFAULT_REGION, + worlds: [ + '红玉海', + '神意之地', + '拉诺西亚', + '幻影群岛', + '萌芽池', + '宇宙和音', + '沃仙曦染', + '晨曦王座', + ], + }, + { + name: '莫古力', + region: QQBOT_FF14_DEFAULT_REGION, + worlds: [ + '白银乡', + '白金幻象', + '神拳痕', + '潮风亭', + '旅人栈桥', + '拂晓之间', + '龙巢神殿', + '梦羽宝境', + ], + }, + { + name: '猫小胖', + region: QQBOT_FF14_DEFAULT_REGION, + worlds: [ + '紫水栈桥', + '延夏', + '静语庄园', + '摩杜纳', + '海猫茶屋', + '柔风海湾', + '琥珀原', + ], + }, + { + name: '豆豆柴', + region: QQBOT_FF14_DEFAULT_REGION, + worlds: ['水晶塔', '银泪湖', '太阳海岸', '伊修加德', '红茶川'], + }, +]; + +export function isQqbotFf14DataCenterName(value?: string) { + const name = normalizeQqbotFf14WorldValue(value); + return QQBOT_FF14_CHINA_DATA_CENTERS.some((item) => item.name === name); +} + +export function isQqbotFf14RegionName(value?: string) { + return normalizeQqbotFf14WorldValue(value) === QQBOT_FF14_DEFAULT_REGION; +} + +export function isQqbotFf14WorldName(value?: string) { + const name = normalizeQqbotFf14WorldValue(value); + return QQBOT_FF14_CHINA_DATA_CENTERS.some((item) => + item.worlds.includes(name), + ); +} + +export function isQqbotFf14LocationName(value?: string) { + const name = normalizeQqbotFf14WorldValue(value); + const path = splitQqbotFf14WorldPath(name); + return ( + isQqbotFf14RegionName(name) || + isQqbotFf14DataCenterName(name) || + isQqbotFf14WorldName(name) || + (!!path.dataCenter && !!path.world) + ); +} + +export function splitQqbotFf14WorldPath(value?: string) { + const raw = normalizeQqbotFf14WorldValue(value); + if (!raw) return {}; + + const parts = raw + .split(/\s*(?:->|=>|>|\/|\\|:|:)\s*/) + .map((item) => item.trim()) + .filter(Boolean); + if (parts.length < 2) return {}; + + if (parts.length === 2) { + return { + dataCenter: parts[0], + world: parts[1], + }; + } + + return { + dataCenter: parts[parts.length - 2], + region: parts[0], + world: parts[parts.length - 1], + }; +} + +export function findQqbotFf14DataCenterByWorld(world?: string) { + const worldName = normalizeQqbotFf14WorldValue(world); + return QQBOT_FF14_CHINA_DATA_CENTERS.find((item) => + item.worlds.includes(worldName), + ); +} + +export function resolveQqbotFf14MarketTarget(params: { + dataCenter?: string; + fallback?: string; + region?: string; + world?: string; +}): QqbotFf14MarketTarget { + const fallback = normalizeQqbotFf14WorldValue(params.fallback); + const path = splitQqbotFf14WorldPath(params.world); + const region = normalizeQqbotFf14WorldValue(params.region || path.region); + const dataCenter = normalizeQqbotFf14WorldValue( + params.dataCenter || path.dataCenter, + ); + const rawWorld = normalizeQqbotFf14WorldValue(path.world || params.world); + const world = + dataCenter && rawWorld === QQBOT_FF14_DEFAULT_REGION ? '' : rawWorld; + const raw = + world || dataCenter || region || fallback || QQBOT_FF14_DEFAULT_REGION; + + if (region && dataCenter && (!world || world === region)) { + return { + dataCenter, + label: `${region} / ${dataCenter}`, + region, + target: dataCenter, + }; + } + + if (raw === QQBOT_FF14_DEFAULT_REGION) { + return { + label: QQBOT_FF14_DEFAULT_REGION, + region: QQBOT_FF14_DEFAULT_REGION, + target: QQBOT_FF14_DEFAULT_REGION, + }; + } + + if (dataCenter && world && world !== dataCenter) { + const matchedDataCenter = QQBOT_FF14_CHINA_DATA_CENTERS.find( + (item) => item.name === dataCenter, + ); + if (matchedDataCenter && !matchedDataCenter.worlds.includes(world)) { + throw new Error(`服务器 ${world} 不属于大区 ${dataCenter}`); + } + return { + dataCenter, + label: region + ? `${region} / ${dataCenter} / ${world}` + : `${dataCenter} / ${world}`, + region, + target: world, + world, + }; + } + + const matchedWorldDataCenter = findQqbotFf14DataCenterByWorld(raw); + if (matchedWorldDataCenter) { + return { + dataCenter: matchedWorldDataCenter.name, + label: `${matchedWorldDataCenter.region} / ${matchedWorldDataCenter.name} / ${raw}`, + region: matchedWorldDataCenter.region, + target: raw, + world: raw, + }; + } + + if (isQqbotFf14DataCenterName(raw)) { + return { + dataCenter: raw, + label: `${QQBOT_FF14_DEFAULT_REGION} / ${raw}`, + region: QQBOT_FF14_DEFAULT_REGION, + target: raw, + }; + } + + return { + label: raw, + target: raw, + }; +} + +function normalizeQqbotFf14WorldValue(value?: string) { + return `${value || ''}`.trim(); +}