잡다한 개발/디스코드 봇

학교 축제용 디스코드 봇 개발기(記)

오잎 클로버 2023. 6. 7. 13:33
728x90

해당 글은 대구소프트웨어마이스터고등학교에 재학 중인 3학년의 글입니다. 학교 축제인 "대소고 E-SPORTS"에 사용될 디스코드 봇의 요구사항, 코드들을 주로 다룰 예정입니다.

 

 

복잡한 요구사항에 얽매이지 않고, 주도적으로 개발하는 것을 정말 오랜만에 진행해보다 보니, 뭔가 어색합니다.

마음이 정말 평화롭게 개발하며, 천천히 개발에 대한 흥미를 다시금 되찾는 개발기(記)였습니다.

 

만들게 된 계기

학교 축제인 "대소고 E-SPORTS"를 진행함에 있어 관객들은 굉장히 큰 역할을 수행합니다. 하지만 게임 중계만으로는

관객들의 눈길을 계속해서 끌고 집중을 돕는 데에는 한계가 있습니다. 그렇기에 주체인(실명 대신, 이니셜로만 표현하겠습니다.) YBH의 부탁으로 접근성이 높은 디스코드를 활용한 게임 배팅 기능을 탑재한 봇을 구현하게 되었습니다.

저 역시 단순하고 가볍지만 만드는 데 부담이 없는 프로젝트를 진행하고, 항상 백엔드 코드만 구현하였던 것에 매너리즘을 느끼는 차에 "한 번 만들어볼까?"라는 생각으로 부탁을 들어주게 되었습니다.

 

디스코드 봇의 요구사항

봇의 요구사항은 생각보다 단순했지만, 그 목적성을 매우 뚜렷하게 드러내고 있었습니다.

관리자 명령어
/토토개장 "토토 명"
/팀추가 "팀 명"
/배팅시작
/배팅중지
/승리 "팀 명"

일반 유저 명령어
/배팅 "배팅액" (배팅액 : 10 ~ 1000)

공통 명령어
/랭킹
/help
/ping

기타 유의 사항
배팅할 때마다 잔고랑 내역출력

위 요구사항들 가운데 개인적으로 불필요하여 합치거나 필요한 명령어들은 추가하여, 다음과 같이 적용하였습니다.

/배팅 시작 -> 삭제, 대신 '/토토 개장'과 동시에 배팅 시작
/토토 정보 -> 추가, 현재 진행되고 있는 토토의 전체 배팅액, 경기팀과 같은 정보를 조회
/프로필 -> 추가, 배팅하지 않더라도 잔고와 내역을 출력

위 사항들을 위 요구사항들에 적용한 후 개발을 시작하였습니다.

 

 

개발 환경

  • node 18.16.0, pnpm 8.6.1
  • typescript ^5.0.4
  • ts-node ^10.9.1
  • mongoose
  • discord.js 14
  • Visual Studio Code
  • MongoDB 6.0.5

 

프로젝트 설정

프로젝트의 설계는 다음과 같이 간단하게 설계하였습니다.

그림의 설명을 요약하자면, 빌드를 통해 만들어진 *.js 파일들을 런타임에서 reflection을 일으켜 특정 데코레이터 혹은 인터페이스 등을 기준 삼아 명령어들을 찾아 인-메모리형 리스트(자료구조)에 넣는 구조로 이루어져 있습니다.

구조 자체는 스프링 프레임워크와 매우 유사해 보입니다. (실제로 거기서 영감을 받아 가져오긴 했습니다.)

 

js에서 reflect를 구현한 방법

typescript로도 충분히 reflect를 구현할 수 있기는 하나, 동적 로딩을 하여 인스턴스를 in-memory list에 넣어야 하기에

javascript로 구현하는 편이 훨씬 쉽게 구현할 수 있다고 판단하여 js로 구현하였습니다.

import * as path from "path";
import * as fs from "fs";
import "reflect-metadata";

export function discoverUnusedClasses(directory) {
    const classes = [];

    const files = fs.readdirSync(directory);

    for (const file of files) {
        if (path.extname(file) === ".js") {
            const dynamic = require(`../commands/${file}`);
            const module = Object.values(dynamic)[0];
            if (module && file !== "commandLoader.js") {
                const instance = new module();
                if (instance && instance.cmd) {
                    classes.push(instance);
                }
            }
        }
    }

    return classes;
}

directory 매개변수로 해당 경로의 디렉터리를 열어 ts에서 js로 변환된 파일들 가운데, commandLoader.js를 제외하여 모든

js 파일들 중 cmd(해당 property는 ICommand라는 인터페이스를 상속한 자식 클래스)가 true이고 undefined가 아닌 인스턴스를 인메모리 리스트에 넣어 반환합니다. 위 코드의 module 변수를 선언한 이유는 생성자가 없는 인터페이스를 제외하기 위해 설정한 부분입니다.

간단하게 설명하자면, commands 디렉터리에 있는 js 파일들 가운데, 생성자가 있고, cmd(ICommand property)를 가진 js의 인스턴스를 리스트에 넣는다고 요약할 수 있습니다.

더보기

commands 디렉터리로 굳이 규정하지 않고 모든 디렉터리로 정할 수도 있으나, 클래스 로딩을 최소화하고, property가 단순 cmd로 처리하고 있기에 디렉터리를 강제화시켜 모듈별 분리를 하였습니다. (물론 여전히 좋은 방법은 아닙니다.)

더보기

directory 매개변수는 약간의 하드코딩으로 이루어져 있어, 해당 경로가 아닌 경우에는 예외가 발생하니 크리티컬 한 이슈가 발생할 수 있는 메소드입니다. 만일 수정하게 된다면 해당 js 파일을 절대경로로 설정하여 처리하는 방법이 가장 적절할 것입니다.

 

인메모리와 디스코드 봇에 적용한 방법

인메모리는 사실 위 메소드에서 반환하는 방식으로 충분히 처리가 되기에 생략하고, 

디스코드 봇의 slash-command에 반영하기 위해서는 다음과 같은 규격으로 처리해야 합니다.

{
  "name": string,
  "description": string
}

명령어 옵션을 추가하고자 하는 경우, 다음과 같이 해야 합니다.

{
  "name": string,
  "description": string,
  "options": [
    {
      "name": string,
      "description": string,
      "type": integer,
    }
  ]
}

추가적인 여러 요소들은 디스코드 공식문서를 참고해 주세요.

이러한 규격으로 등록해야 하기에 이를 조금 더 자동화하기 위해 다음과 같이 구현하였습니다.

import { discoverUnusedClasses } from "../reflections/inheritorReflector";
import { ICommand } from "./ICommand";

export function findCommands(): any[] {
    return findDynamicCommands().map((v) => {
        if (v.permission() === undefined) {
            return {
                name: v.name(), 
                description: v.description(),
                options: (v.options().length === 0) ? [] : v.options()
            };
        }
        else {
            return {
                name: v.name(), 
                description: v.description(),
                options: (v.options().length === 0) ? [] : v.options(),
                default_member_permissions: Number(v.permission())
            };
        }
    });
}

export function findDynamicCommands(): ICommand[] {
    return discoverUnusedClasses(__dirname);
}

 

main(index.ts)에 적용

import { Client, GatewayIntentBits } from "discord.js";
import { config } from "dotenv";
import { InteractionRegister } from "./modules/interactions/interactionRegister";
import { CommandRegister } from "./modules/commands/commandRegister";
import { findCommands, findDynamicCommands } from "./modules/commands/commandLoader";

config();   // config '.env'

const TOKEN = process.env.BOT_TOKEN || "";
const CLIENT_ID = process.env.CLIENT_ID || "";

const client = new Client({ intents: [GatewayIntentBits.Guilds] });
const interactionManager = new InteractionRegister(client);

// dynamic find commands and register it
const dynamicCommands = findDynamicCommands();
const commandList = findCommands();
const commandRegister = new CommandRegister(TOKEN, CLIENT_ID, commandList);
commandRegister.registerCommands();

// add interactions to work in bot
interactionManager.addReadyInteraction();

// add commands to interaction action
interactionManager.addSlashCommands(dynamicCommands);

client.login(TOKEN);

 

다음 글에는...

위 내용들을 이어서 명령어 인터페이스와 각각의 명령어 클래스, mongoose를 활용한 mongodb 연결 구현을 설명할 예정입니다.

 

 

이상입니다.