Skip to content

Commit 1c0d9e6

Browse files
committed
feat(discord-bot): add materia price query command
- implement `/materia` slash command for ff14 materia price lookup - query prices and sales velocity from universalis API with batch processing - add rate limiting with 1150ms delay between batches (20 items per batch) - display materia grouped by color series (red, blue, green, purple, yellow) - install `delay` and `await-to-js` dependencies for async flow control - register command in Discord bot application commands
1 parent 132afe8 commit 1c0d9e6

4 files changed

Lines changed: 249 additions & 68 deletions

File tree

@apps/discord-bot/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
"version": "0.1.0",
66
"dependencies": {
77
"@types/lodash-es": "4.17.12",
8+
"await-to-js": "3.0.0",
89
"chinese-conv": "4.0.0",
910
"dedent": "1.7.1",
11+
"delay": "7.0.0",
1012
"discord.js": "14.25.1",
1113
"dotenv": "17.2.3",
1214
"lodash-es": "4.17.22",
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import delay from 'delay'
2+
import {
3+
type ChatInputCommandInteraction,
4+
SlashCommandBuilder,
5+
} from 'discord.js'
6+
import { chunk, keyBy } from 'lodash-es'
7+
import type { z } from 'zod'
8+
import { itemId } from '~/(constants)/itemId'
9+
import { universalisApp } from '~/(services)/universalis/universalisApp'
10+
import { universalisTypes } from '~/(services)/universalis/universalisTypes'
11+
12+
type AggregatedItemResult = z.infer<
13+
typeof universalisTypes.aggregatedItems
14+
>['results'][number]
15+
16+
export const materiaCommand = {
17+
command: new SlashCommandBuilder()
18+
.setName('materia')
19+
.setDescription('魔晶石查詢'),
20+
callback: async function handleMateriaCommand(
21+
interaction: ChatInputCommandInteraction,
22+
): Promise<void> {
23+
const MATERIA_CONFIG = [
24+
// 🔴 紅色系列(戰鬥特職)
25+
{ emoji: '🔴', name: '武略魔晶石', attr: '爆擊' },
26+
{ emoji: '🔴', name: '神眼魔晶石', attr: '直擊' },
27+
{ emoji: '🔴', name: '雄略魔晶石', attr: '信念' },
28+
29+
// 🔵 藍色系列(生產系特職)
30+
{ emoji: '🔵', name: '巨匠魔晶石', attr: '加工精度' },
31+
{ emoji: '🔵', name: '名匠魔晶石', attr: '作業精度' },
32+
{ emoji: '🔵', name: '魔匠魔晶石', attr: 'CP' },
33+
34+
// 🟢 綠色系列(採集系特職)
35+
{ emoji: '🟢', name: '達識魔晶石', attr: '獲得力' },
36+
{ emoji: '🟢', name: '博識魔晶石', attr: '鑑別力' },
37+
{ emoji: '🟢', name: '器識魔晶石', attr: 'GP' },
38+
39+
// 🟣 紫色系列(戰鬥特職)
40+
{ emoji: '🟣', name: '戰技魔晶石', attr: '技能速度' },
41+
{ emoji: '🟣', name: '詠唱魔晶石', attr: '詠唱速度' },
42+
43+
// 🟡 黃色系列(坦克/治療特職)
44+
{ emoji: '🟡', name: '剛柔魔晶石', attr: '堅韌' },
45+
{ emoji: '🟡', name: '信力魔晶石', attr: '信仰' },
46+
]
47+
48+
const TYPE_NAMES = [
49+
'壹型',
50+
'貳型',
51+
'參型',
52+
'肆型',
53+
'伍型',
54+
'陸型',
55+
'柒型',
56+
'捌型',
57+
'玖型',
58+
'拾型',
59+
'拾壹型',
60+
'拾貳型',
61+
]
62+
63+
const result = await interaction.reply('🔍 查詢所有魔晶石物價ing...')
64+
65+
// 收集所有魔晶石物品(保持排序)
66+
const materiaItems = MATERIA_CONFIG.flatMap((materia) =>
67+
TYPE_NAMES.map((typeName, typeIndex) => {
68+
const fullName = `${materia.name}${typeName}`
69+
const materiaId = itemId.get(fullName)
70+
return {
71+
...materia,
72+
typeName,
73+
typeNumber: typeIndex + 1,
74+
fullName,
75+
id: materiaId,
76+
}
77+
}),
78+
).filter((item) => item.id !== undefined)
79+
80+
const allMateriaIds = materiaItems.map((item) => item.id!)
81+
82+
// 分批查詢價格(每批 20 個,延遲 1150ms)
83+
const BATCH_SIZE = 20
84+
const priceBatches = chunk(allMateriaIds, BATCH_SIZE)
85+
const allPriceResults: AggregatedItemResult[] = []
86+
87+
for (let batchIndex = 0; batchIndex < priceBatches.length; batchIndex++) {
88+
await result.edit(
89+
`🔍 查詢價格中 (${batchIndex + 1}/${priceBatches.length})...`,
90+
)
91+
92+
const { data: priceData } = await universalisApp.findManyItemsPrice(
93+
priceBatches[batchIndex]!,
94+
)
95+
if (priceData?.results) {
96+
allPriceResults.push(...priceData.results)
97+
}
98+
99+
if (batchIndex < priceBatches.length - 1) {
100+
await delay(1150)
101+
}
102+
}
103+
104+
const priceMap = keyBy(allPriceResults, 'itemId')
105+
106+
// 分批查詢銷量(每批 20 個,延遲 1150ms)
107+
await result.edit('🔍 查詢銷量資料中...')
108+
109+
const saleVelocityMap: Record<number, number> = {}
110+
111+
for (let batchIndex = 0; batchIndex < priceBatches.length; batchIndex++) {
112+
const { data: saleData } = await universalisApp.findItemSaleHistory(
113+
priceBatches[batchIndex]!,
114+
)
115+
116+
if (saleData) {
117+
saleVelocityMap[saleData.itemID] = saleData.nqSaleVelocity || 0
118+
}
119+
120+
if (batchIndex < priceBatches.length - 1) {
121+
await delay(1150)
122+
}
123+
}
124+
125+
// 格式化輸出 - 按顏色分組
126+
await result.edit('🔍 正在總結...')
127+
128+
const colorGroups = [
129+
{ emoji: '🔴', name: '紅色系列(戰鬥特職)' },
130+
{ emoji: '🔵', name: '藍色系列(生產系特職)' },
131+
{ emoji: '🟢', name: '綠色系列(採集系特職)' },
132+
{ emoji: '🟣', name: '紫色系列(戰鬥特職)' },
133+
{ emoji: '🟡', name: '黃色系列(坦克/治療特職)' },
134+
]
135+
136+
for (let groupIndex = 0; groupIndex < colorGroups.length; groupIndex++) {
137+
const group = colorGroups[groupIndex]!
138+
const output = [`## ${group.emoji} ${group.name}\n`]
139+
140+
const groupItems = materiaItems.filter(
141+
(item) => item.emoji === group.emoji,
142+
)
143+
144+
for (const item of groupItems) {
145+
if (!item.id) continue
146+
147+
const price = priceMap[item.id]?.nq.averageSalePrice.region?.price || 0
148+
const velocity = saleVelocityMap[item.id] || 0
149+
150+
/**
151+
* FIXME: 銷量總是返回 0 的問題。
152+
*/
153+
output.push(
154+
`${item.emoji} ${item.fullName} (${item.typeNumber}${item.attr}) / 平均 ${Math.round(price).toLocaleString('en-US')}g / 銷量 ${Math.round(velocity)} 件`,
155+
)
156+
}
157+
158+
if (groupIndex === 0) {
159+
await result.edit(output.join('\n'))
160+
} else {
161+
await interaction.followUp(output.join('\n'))
162+
}
163+
}
164+
},
165+
}

@apps/discord-bot/src/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { REST, Routes } from 'discord.js'
22
import { itemCommand } from '~/(features)/commands/itemCommand'
3+
import { materiaCommand } from '~/(features)/commands/materiaCommand'
34
import { discordBot } from '~/(services)/bot/discordBot.ts'
45
import { envVar } from '~/envVar.ts'
56

@@ -12,7 +13,10 @@ discordBot.on('ready', async (client) => {
1213

1314
// Register slash commands
1415
const rest = new REST({ version: '10' }).setToken(envVar.DISCORD_BOT_TOKEN)
15-
const commands = [itemCommand.command.toJSON()]
16+
const commands = [
17+
itemCommand.command.toJSON(),
18+
materiaCommand.command.toJSON(),
19+
]
1620

1721
try {
1822
await rest.put(Routes.applicationCommands(client.user.id), {
@@ -30,6 +34,10 @@ discordBot.on('interactionCreate', async (interaction) => {
3034
if (interaction.commandName === 'item') {
3135
await itemCommand.callback(interaction)
3236
}
37+
38+
if (interaction.commandName === 'materia') {
39+
await materiaCommand.callback(interaction)
40+
}
3341
})
3442

3543
discordBot.login(envVar.DISCORD_BOT_TOKEN)

0 commit comments

Comments
 (0)