Study/Javascript

[Node.js] Python으로부터 넘어오다

BVM 2024. 1. 27. 18:21

내가 discord.py의 기능을 온전히 활용하지 못한 탓도 있겠지만,

discord.js는 보다 작성하기 용이하고 유지관리도 쉬울 것이라는 생각이 들었다.

해 보니까 진작 이거로 했어야지 싶다. 물론 예전엔 js가 머리에 접수가 안 돼서 도망친 것이지만...

 

일단 discord.js에서 제공하는 스타팅 가이드와 문서로 돌아가는 방식을 파악했다.

문서가 최신화가 잘 되는 듯 해 만족스럽다.

그리고 dicord.js로 개발한, 어느정도 틀이 잡혀있는 템플릿 봇을 구했다.

discord.py는 정말 핑퐁 하나만 달랑 있는 봇에서 기능을 다 올린 것이기도 하고, 계획이 없었기 때문에 중구난방이었다.

마치 언리얼 엔진과 유니티 엔진의 차이와 유사하게 느껴진다.

 

여하튼 템플릿 봇의 코드를 뜯어보며 테스트 용으로 몇 가지 명령어를 작성했다.

 

1. stock

기존 Python 봇에 있었던 기능을 이식하고 몇가지 조정이 들어갔다.

조정점은 다음과 같다.

  1. 부가 정보 제거
     - 가장 필요한 가격 정보를 중심으로 최대한 쳐냈다. 차트도 포함하지 않는다.
  2. 1분간 지속적으로 갱신
     - 단순히 한번만 던져주고 말 수도 있지만, 그래도 갱신되는 게 보기 좋지 않나 싶었다.
  3. 코드 로직 개선
     - 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()이 슬래시 커맨드로 명령어 실행 시 호출되는 핸들러 함수다.

  1. 함수가 호출되면
  2. getResultEmbed()를 호출하여 결과를 받아온다
  3. 만약 마켓이 닫혀있다면 갱신하지 않는다
  4. 프리장이거나 개장 시간이라면 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;
  }
}

 

  1. 해당 Ticker에 대한 price 정보를 가져온다
     - Ticker 입력이 없다면 NVDA로 간주
  2. 위 정보를 바탕으로 기본적인 Embed 작성
  3. 개장 여부를 판단하여 추가 정보 Embed에 추가
  4. Embed가 완성되면 return

이라는 매우 심플한 흐름을 가진다.

fieldvalue를 채우는 것이 어째 좀 지저분 한 것 같기도 하고...

 

여하튼 실행 결과는 아래와 같다.

 

 

필요한 건 다 보여준다. 사실 가격만 보여주면 그만이 아닌가.

 

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에서 구현한 명령어와도 차이점이 있다.

 

  1. 환율 정보는 이제 구글에서 가져온다
     - currency-converter-lt 라는 패키지의 코드를 보니 구글에서 스크래핑을 해오더라
  2. 자주 사용하는 화폐에 대한 선택지 제공
     - 선택 메뉴를 띄워 일일이 통화 코드를 입력하지 않아도 되게끔 했다

 

해당 패키지에서 정보를 가져오는 부분이 이렇다.

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가 꽤 맘에 드는 것 같다.