내가 discord.py
의 기능을 온전히 활용하지 못한 탓도 있겠지만,
discord.js
는 보다 작성하기 용이하고 유지관리도 쉬울 것이라는 생각이 들었다.
해 보니까 진작 이거로 했어야지 싶다. 물론 예전엔 js가 머리에 접수가 안 돼서 도망친 것이지만...
일단 discord.js
에서 제공하는 스타팅 가이드와 문서로 돌아가는 방식을 파악했다.
문서가 최신화가 잘 되는 듯 해 만족스럽다.
그리고 dicord.js
로 개발한, 어느정도 틀이 잡혀있는 템플릿 봇을 구했다.
discord.py
는 정말 핑퐁 하나만 달랑 있는 봇에서 기능을 다 올린 것이기도 하고, 계획이 없었기 때문에 중구난방이었다.
마치 언리얼 엔진과 유니티 엔진의 차이와 유사하게 느껴진다.
여하튼 템플릿 봇의 코드를 뜯어보며 테스트 용으로 몇 가지 명령어를 작성했다.
1. stock
기존 Python 봇에 있었던 기능을 이식하고 몇가지 조정이 들어갔다.
조정점은 다음과 같다.
- 부가 정보 제거
- 가장 필요한 가격 정보를 중심으로 최대한 쳐냈다. 차트도 포함하지 않는다. - 1분간 지속적으로 갱신
- 단순히 한번만 던져주고 말 수도 있지만, 그래도 갱신되는 게 보기 좋지 않나 싶었다. - 코드 로직 개선
- Python 코드보단 확실히 간결해졌다.
const { CommandCategory } = require("@src/structures");
const { STOCK } = require("@root/config.js");
const { EmbedBuilder, ApplicationCommandOptionType } = require("discord.js");
const yahooFinance = require('yahoo-finance2').default;
/**
* Define the stock command module.
* @type {import("@structures/Command")}
*/
module.exports = {
name: "stock", // Command name
description: "Print stock data for the given symbol.", // Command description
category: "STOCK", // Command category
botPermissions: ["EmbedLinks"], // Bot permissions required for the command
command: {
enabled: false, // Whether the command is enabled for traditional message usage
usage: "[command] [symbol]", // Command usage information
},
slashCommand: {
enabled: true, // Whether the command is enabled for slash command usage
options: [
{
name: "symbol",
description: "Symbol of the stock",
required: false,
type: ApplicationCommandOptionType.String,
},
],
},
명령어의 메타데이터를 결정하는 부분.
Prefix
로도 활용할 수 있지만 일단 슬래시 커맨드로만 동작할 수 있게 했다.
// Handler function for traditional message usage
async messageRun(message, args) {
// Retrieve the stock symbol from the command arguments or default to 'NVDA'
const symbol = args[0] || 'NVDA';
// Fetch stock data and send the result as an embed
const response = await getResultEmbed(symbol);
if (response) {
await message.channel.send(response);
} else {
await message.channel.send("Failed to fetch stock data. Please try again later.");
}
},
// Handler function for slash command usage
async interactionRun(interaction) {
// Retrieve the stock symbol from the slash command options or default to 'NVDA'
let symbol = interaction.options.getString("symbol") || 'NVDA';
// Send initial response with stock data
let response = await getResultEmbed(symbol);
if (!response) {
await interaction.followUp("Failed to fetch stock data. Please try again later.");
return;
}
await interaction.followUp({ embeds: [response] });
// Check if the market is closed before setting up updates
const state = response.data.fields.find(field => field.name === "Market State").value.split(' ')[0];
if (state === "Closed" || state === "Post") {
// If the market is closed, do not set up the interval for updates
return;
}
// Set up periodic updates for the stock data
let updateCount = 0;
const totalUpdates = STOCK.MAX_REFRESH_TIME / STOCK.REFRESH_INTERVAL;
// Update the response every REFRESH_INTERVAL milliseconds
const interval = setInterval(async () => {
updateCount++; // Increment the update count
// Fetch new data
response = await getResultEmbed(symbol, updateCount, totalUpdates);
if (response) {
// Edit the original reply with the new data
await interaction.editReply({ embeds: [response] }).catch(console.error);
}
// If we've reached the total number of updates, clear the interval
if (updateCount >= totalUpdates) {
clearInterval(interval);
}
}, STOCK.REFRESH_INTERVAL);
}
};
핸들러들.
interactionRun()
이 슬래시 커맨드로 명령어 실행 시 호출되는 핸들러 함수다.
- 함수가 호출되면
getResultEmbed()
를 호출하여 결과를 받아온다- 만약 마켓이 닫혀있다면 갱신하지 않는다
- 프리장이거나 개장 시간이라면 1분간 5초 간격으로 총 12번 갱신
동작의 흐름은 위와 같다.
10번도 아니고 12번인 이유는 그냥 1분이 60초니까 60000을 넣었고,
매초 갱신은 좀 그렇고 10초도 기니 5초로 하자고 해서 12번이 됐다.
실제로 정보를 얻고 임베드를 작성하는 부분은 아래와 같다.
/**
* Helper function to fetch stock data and create an embed.
* @param {string} symbol - The stock symbol to fetch data for.
* @param {number} updateCount - The current update count.
* @param {number} totalUpdates - The total number of updates.
* @returns {Promise<EmbedBuilder|null>} - A Promise resolving to an EmbedBuilder or null if fetching fails.
*/
async function getResultEmbed(symbol, updateCount = 0, totalUpdates) {
try {
// Fetch stock data using the Yahoo Finance API
const quoteSummary = await yahooFinance.quoteSummary(symbol, { modules: ["price"] });
const results = quoteSummary.price;
// Determine the market state and emojis for open/closed status
const state = getState(results);
const isMarketOpen = state === "Regular Market";
const isPreMarket = state === "Pre Market";
const isPostMarket = state === "Post Market";
const openStatusEmoji = isMarketOpen ? ':green_circle:' : (state === isPreMarket) ? ':orange_circle:' : ':red_circle:';
// Create an embed with stock data
const embed = new EmbedBuilder()
.setTitle(`${results.longName} / [${results.symbol}]`)
.setURL(`https://finance.yahoo.com/quote/${results.symbol}`)
.setThumbnail(CommandCategory.STOCK?.image)
.addFields(
{ name: "Market State", value: `${state} ${openStatusEmoji}`, inline: false },
{ name: ' ', value: ' ', inline: false },
{ name: ' ', value: ' ', inline: false },
{ name: "Price", value: `${results.currencySymbol}${results.regularMarketPrice.toFixed(2)}`, inline: true },
{ name: "Change", value: `${results.regularMarketChange.toFixed(2)} (${(results.regularMarketChangePercent * 100).toFixed(2)}%)`, inline: true },
{ name: ' ', value: ' ', inline: false },
)
.setColor(getEmbedColor(results))
.setFooter({ text: `Data from Yahoo Finance. #Update ${updateCount}/${totalUpdates || '∞'}.` })
.setTimestamp(Date.now());
// Add preMarket and postMarket fields if applicable
if (isPreMarket) {
embed.addFields(
{ name: "Pre - Price", value: `${results.currencySymbol}${results.preMarketPrice.toFixed(2)}`, inline: true },
{ name: "Pre - Change", value: `${results.preMarketChange.toFixed(2)} (${(results.preMarketChangePercent * 100).toFixed(2)}%)`, inline: true },
{ name: ' ', value: ' ', inline: false },
);
} else if (isPostMarket) {
embed.addFields(
{ name: "Post - Price", value: `${results.currencySymbol}${results.postMarketPrice.toFixed(2)}`, inline: true },
{ name: "Post - Change", value: `${results.postMarketChange.toFixed(2)} (${(results.postMarketChangePercent * 100).toFixed(2)}%)`, inline: true },
{ name: ' ', value: ' ', inline: false },
);
}
// Add additional fields to the embed
embed.addFields(
{ name: "Day High", value: `${results.currencySymbol}${results.regularMarketDayHigh.toFixed(2)}`, inline: true },
{ name: "Day Low", value: `${results.currencySymbol}${results.regularMarketDayLow.toFixed(2)}`, inline: true },
{ name: "Volume", value: results.regularMarketVolume.toLocaleString(), inline: true },
)
// Return the created embed
return embed;
} catch (error) {
// Log an error if fetching stock data fails
console.error(`Failed to fetch stock data for symbol: ${symbol}`, error);
return null;
}
}
/**
* Helper function to determine the market state based on Yahoo Finance results.
* @param {object} results - Yahoo Finance results object.
* @returns {string} - Market state string.
*/
function getState(results) {
switch (results.marketState) {
case 'PREPRE':
case 'POST':
case 'CLOSED':
return "Post Market";
case 'PRE':
return "Pre Market";
case 'REGULAR':
return "Regular Market";
default:
return "Unknown";
}
}
/**
* Helper function to determine the embed color based on stock change.
* @param {object} results - Yahoo Finance results object.
* @returns {string} - Embed color.
*/
function getEmbedColor(results) {
if (results.regularMarketChange > 0) {
return STOCK.UPWARD_EMBED;
} else if (results.regularMarketChange < 0) {
return STOCK.DOWNWARD_EMBED;
} else {
return STOCK.DEFAULT_EMBED;
}
}
- 해당 Ticker에 대한
price
정보를 가져온다
-Ticker
입력이 없다면NVDA
로 간주 - 위 정보를 바탕으로 기본적인
Embed
작성 - 개장 여부를 판단하여 추가 정보
Embed
에 추가 Embed
가 완성되면return
이라는 매우 심플한 흐름을 가진다.
field
에 value
를 채우는 것이 어째 좀 지저분 한 것 같기도 하고...
여하튼 실행 결과는 아래와 같다.
필요한 건 다 보여준다. 사실 가격만 보여주면 그만이 아닌가.
2. etf
stock
에 사용한 코드를 그대로 갖다가 조금만 고치면 된다.
목적은 여전히 미리 지정해 둔 ETF 목록에 대한 가격 정보를 가져와 한방에 출력하는 것.
차이점이라면 병렬 처리 퍼포먼스를 확보하기 위해 Promise
를 사용한 것.
async function getResultEmbed(updateCount = 0, totalUpdates = STOCK.MAX_REFRESH_TIME / STOCK.REFRESH_INTERVAL) {
// Fetch temporary stock data to determine market state
const quoteSummarytmp = await yahooFinance.quoteSummary("NVDA", { modules: ["price"] });
const resultstmp = quoteSummarytmp.price;
const state = getState(resultstmp);
const isMarketOpen = state === "Regular Market";
const isPreMarket = state === "Pre Market";
const isPostMarket = state === "Post Market";
const openStatusEmoji = isMarketOpen ? ':green_circle:' : (state === isPreMarket) ? ':orange_circle:' : ':red_circle:';
const embed = new EmbedBuilder()
.setColor(STOCK.DEFAULT_EMBED)
.setTitle('ETF Lists')
.setThumbnail(CommandCategory["STOCK"]?.image)
.setFooter({ text: `Data from Yahoo Finance. # Update ${updateCount}/${totalUpdates}.` })
.setTimestamp(Date.now())
.addFields(
{ name: "Market State", value: `${state} ${openStatusEmoji}`, inline: false },
{ name: ' ', value: ' ', inline: false },
{ name: ' ', value: ' ', inline: false },
);
// Create an array of promises for each ETF symbol
const promises = etfs.map(symbol => yahooFinance.quoteSummary(symbol, { modules: ["price"] }).catch(error => {
console.error(`Failed to fetch data for ${symbol}: ${error}`);
return null; // Return null if there's an error
}));
// Use Promise.all to wait for all promises to resolve
const results = await Promise.all(promises);
// Process the results
results.forEach((quoteSummary, index) => {
if (quoteSummary) {
const results = quoteSummary.price;
// Only add data to the embed if the market state is regular, pre, or post
if (isMarketOpen || isPreMarket || isPostMarket) {
let priceInfo = isMarketOpen ? results.regularMarketPrice : isPreMarket ? results.preMarketPrice : results.postMarketPrice;
let changeInfo = isMarketOpen ? results.regularMarketChange : isPreMarket ? results.preMarketChange : results.postMarketChange;
let changePercentInfo = isMarketOpen ? results.regularMarketChangePercent : isPreMarket ? results.preMarketChangePercent : results.postMarketChangePercent;
let upDownEmoji = changeInfo > 0 ? '<:yangbonghoro:1162456430360662018>' : changeInfo < 0 ? '<:sale:1162457546532073623>' : '';
embed.addFields(
{ name: `${results.symbol}`, value: `${results.currencySymbol}${priceInfo.toFixed(2)}`, inline: true },
{ name: "Change", value: `${changeInfo.toFixed(2)} (${(changePercentInfo * 100).toFixed(2)}%) ${upDownEmoji}`, inline: true },
{ name: ' ', value: ' ', inline: false },
);
}
} else {
// If the result is null, it means there was an error fetching the data for this symbol
embed.addFields(
{ name: `${etfs[index]}`, value: `Failed to fetch data`, inline: false }
);
}
});
return embed;
}
7개의 종목의 가격 데이터를 5초마다 가져와야 하기 때문에 병렬로 데이터를 가져오게끔 할 필요가 있었다.
확실히 하나씩 처리할 때 보다 효율적으로 동작했다.
실행 결과는 아래와 같다.
종목 목록은 차후에 서버 별로 지정할 수 있게 개선할 수도 있을 것이다.
그러기 위한 DB 사용이기도 하고.
대시보드에서도 관리할 수 있게 하면 좋을 것 같다.
3. exchange
Python에서 구현한 명령어와도 차이점이 있다.
- 환율 정보는 이제 구글에서 가져온다
-currency-converter-lt
라는 패키지의 코드를 보니 구글에서 스크래핑을 해오더라 - 자주 사용하는 화폐에 대한 선택지 제공
- 선택 메뉴를 띄워 일일이 통화 코드를 입력하지 않아도 되게끔 했다
해당 패키지에서 정보를 가져오는 부분이 이렇다.
return new Promise((resolve, reject) => {
request(`https://www.google.com/search?q=${this.currencyAmount}+${this.currencyFrom}+to+${this.currencyTo}+&hl=en`, function(error, response, body) {
if (error) {
return reject(error);
} else {
resolve(body);
}
});
그냥 검색해서 보는 것과 똑같다.
사실상 Google Finance에서 제공되는 데이터와 완전 동일할 것으로 생각되기 때문에,
정확하고 리얼타임으로 갱신 된다는 점에서 메리트가 있다.
const choices = ["USD", "KRW", "EUR", "GBP", "JPY", "CAD", "CHF", "HKD", "TWD", "AUD", "NZD", "INR", "BRL", "PLN", "RUB", "TRY", "CNY"];
/**
* @type {import("@structures/Command")}
*/
module.exports = {
name: "exchange",
description: "Shows the exchange rate for a given currency.",
category: "CURRENCY",
botPermissions: ["EmbedLinks"],
command: {
enabled: false,
usage: "[command]",
},
slashCommand: {
enabled: true,
options: [
{
name: "from",
description: "The currency you want to convert (From) / Default : USD",
required: false,
type: ApplicationCommandOptionType.String,
choices: choices.map((choice) => ({ name: CURRENCIES[choice], value: choice })),
},
{
name: "to",
description: "The currency you want to convert (To) / Default : KRW",
required: false,
type: ApplicationCommandOptionType.String,
choices: choices.map((choice) => ({ name: CURRENCIES[choice], value: choice })),
},
{
name: "amount",
description: "The amount of currency. / Default : 1.0",
required: false,
type: ApplicationCommandOptionType.Integer,
minValue: 0,
},
],
},
선택지를 주기 위해 옵션에 choices
라는 것이 생겼다.
이 외엔 특별한 것이 없다.
async interactionRun(interaction) {
try {
const from = interaction.options.getString("from") || "USD";
const to = interaction.options.getString("to") || "KRW";
const amount = interaction.options.getInteger("amount") || 1;
const res = await getRate(from, to, amount);
if (!res) {
await interaction.followUp("Failed to fetch rate data. Please try again later.");
return;
}
await interaction.followUp(res);
}
catch (err) {
console.debug(err)
}
}
async function getRate(from, to, amount) {
let cc = new CurrencyConverter({from:`${from}`, to:`${to}`});
cc.amount(amount);
const res = await cc.convert();
embed = new EmbedBuilder()
.setTitle(`Exchange rate from ${from} to ${to}`)
.setThumbnail(CommandCategory["CURRENCY"].image)
.setColor(EMBED_COLORS.BOT_EMBED)
.setFooter({text: `Data from Google.`})
.setTimestamp(Date.now())
.addFields(
{ name: 'From', value: `${amount.toLocaleString({ maximumFractionDigits: 2 })} ${from}`},
{ name: 'To', value: `${res.toLocaleString({ maximumFractionDigits: 2 })} ${to}`}
);
return { embeds: [embed] };
}
아무 입력을 받지 않을 때는 1USD를 KRW로 변환하도록 했다.
이제 나머지는 단순히 수치를 던져주고 결과만 받아먹고 리턴해주면 끝.
소수점은 2자리로 제한하기 위해 maximumFractionDigits: 2
옵션을 줬다.
실제로 명령어를 사용할 때 이런 식이다.
설명도 알아볼 만 하고,
눈으로 보고 고를 수 있기 때문에 사용하기는 훨씬 편해졌다.
이렇게 고르고 입력한 결과는 아래와 같다.
잘 나오니 됐다.
기존 Python 봇의 기능 이식이 다 끝나는 대로 실제 배포에 들어갈 것 같다.
세미콜론이 있는 것도 반갑고...
JS가 꽤 맘에 드는 것 같다.
'Study > Javascript' 카테고리의 다른 글
티스토리 인라인 코드 태그 래핑 스크립트 (0) | 2024.07.11 |
---|