잡다한 개발/디스코드 봇

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

오잎 클로버 2023. 6. 7. 16:08
728x90

해당 글은 대구소프트웨어마이스터고등학교에 재학 중인 3학년의 글입니다. 학교 축제인 "대소고 E-SPORTS"에 사용될 디스코드 봇의 소스코드를 주로 다룰 것이며, 이전 글과 이어집니다.

데이터베이스 선정 기준

이전 글에서 언급하였듯 mongoDB를 데이터베이스로 사용하고 있습니다. RDBMS가 아닌 NoSQL인 mongoDB를 택한 이유는 비동기 친화적이며, 대량의 데이터를 처리해야 하는 애플리케이션에 높은 확장성을 제공하기 때문에 택하였습니다.

 

MongoDB를 소스코드에서 접근한 방법

우선 node 생태계에서는 monogoose라는 라이브러리를 지원해주고 있기에 손쉽게 MongoDB에 접근할 수 있습니다.

편리한 접근을 위해서는 model을 객체로 하여 접근하는 것인데, 이 역시 매우 쉽게 지원하고 있습니다.

하지만, MongoDB의 장점이자 단점인 유연성으로 인해 구조 결정이 계속해서 미루어질 수 있는 점을 보안하고자 MongoDB에는 따로 존재하지 않은 Schema를 선언하여 해당 구조에 맞춰져서 접근할 수 있도록 하였습니다.

 

소스코드

import mongoose, { Model } from "mongoose";

interface GameSchema {
    name: string,
    open: boolean,  // is game betting is stop?
    finish: boolean // is game is over(finish)
}

interface GameModel extends Model<GameSchema> {}

/**
 * Definition `game` schema to bet.
 * There's only one field, `name` (a.k.a gamename)
 */
const gameSchema = new mongoose.Schema<GameSchema, GameModel>({
    name: { type: String, required: true, unique: true },
    open: { type: Boolean, default: true },
    finish: { type: Boolean, default: false }
});

const Game = mongoose.model<GameSchema, GameModel>("Game", gameSchema);

export { Game };

인터페이스로 Schema(구조)를 선언하고 이를 기반으로 모델을 구현하였습니다. 이로써 구조를 쉽게 변경하지 못하기에 데이터 구조 결정을 늦출 수 없게 되었습니다.

 

MongoDB 최초 연결

import mongoose from "mongoose";

export function connect(username: string, password: string, 
        hostname: string, option?: string) {
    const url = createUrl(`${username}:${password}`, `${hostname}/?${option}`);
    return mongoose.connect(url, {
        dbName: "gamble",
    })
    .then(() => console.log("🍃 connected to mongodb"))
    .catch(() => console.error("error to connect mongodb"));
}

function createUrl(authenticate: string, hostOption: string): string {
    return `mongodb+srv://${authenticate}@${hostOption}`;
}

MongoDB에 최초 연결하는 방법은 위 코드와 같이 접속하는 방법을 택하였습니다.

 

명령어 인터페이스

이전 글에서 언급했듯이 여러 명령어 클래스들을 편리하게 관리하기 위해서는 인터페이스가 필요하다고 느꼈기에 인터페이스를 다음과 같이 설계하였습니다.

import { CacheType, CommandInteraction } from "discord.js";

export interface ICommand {
    cmd: boolean;
    name(): string;
    description(): string;
    options(): any[];
    permission(): bigint | undefined;
    action(interation: CommandInteraction<CacheType>): Promise<void>;
}

각각의 property 및 메소드를 설명하자면,

  • cmd: 명령어 등록 여부
  • name(): 명령어 이름
  • description(): 명령어 설명
  • options(): 명령어 옵션
  • permission(): 명령어 사용 권한
  • action(): 명령어 메인 메소드

명령어 자식 클래스

자식 클래스는 ICommand라는 인터페이스를 구현하여 동적 로딩을 통한 명령어 등록을 할 수 있으며, 이를 기반으로 손쉽게 명령어 처리를 할 수 있어야 합니다. 여기서 유의할 점은 discord 공식문서를 잘 지켜서 개발만 한다면 아무런 문제 없이 개발된다는 장점이 있습니다.

 

예시, Ping 명령어

import { CacheType, CommandInteraction } from "discord.js";
import { ICommand } from "./ICommand";

export class Ping implements ICommand {
    cmd: boolean = true;
    name(): string {
        return "ping";
    }
    
    description(): string {
        return "현재 서버와의 연결까지 걸리는 딜레이를 조회합니다.";
    }

    options(): any[] {
        return [];
    }

    permission(): bigint | undefined {
        return undefined;
    }

    async action(interation: CommandInteraction<CacheType>): Promise<void> {
        if (interation.commandName !== this.name()) {
            return;
        }

        const user = interation.user;
        await interation.deferReply();
        const reply = await interation.fetchReply();
        const ping  = reply.createdTimestamp - interation.createdTimestamp;
        await interation.editReply(
            `Pong! ${user.username}이/가 채널에 접근하기 까지 걸린 시간: ${ping}ms`
        );
    }
}

핑 명령어는 아무런 옵션이 필요 없으며, 권한 역시 설정할 필요 없이 누구나 사용할 수 있는 명령어이기에 따로 설정하지 않았습니다. 

 

명령어 실제 처리

위 코드들과 같은 명령어 클래스들은 실제 discord와 연결되어있지 않다면 죽은 코드나 다름없습니다.

그렇기에 실제 discord와 연결되어 있어야 의미가 비로소 생깁니다.

이전 글에는 다루지 않았지만, discord 봇 반응은 다음과 같이 구현되어 있습니다.

import { Client } from "discord.js";
import { ICommand } from "../commands/ICommand";
import { register } from "./playerRegister";

export class InteractionRegister {

    private _client: Client;

    constructor(client: Client) {
        this._client = client;
    }

    addReadyInteraction() {
        this._client.on("ready", () => {
            console.log(`Logged in as ${this._client.user?.tag}`);
        });
    }

    addSlashCommands(commands: ICommand[]) {
        this._client.on("interactionCreate", async interation => {
            if (!interation.isChatInputCommand()) {
                return;
            }

            await register(interation.user.id);
            commands.map(async (cmd) => {
                await cmd.action(interation);
            });
        });
    }
}

명령어 반응에 반응하며, 앞서 등록한 모든 명령어 인스턴스 리스트를 순회하며 하나하나 실행합니다.

만일 어느 하나라도 반응하지 않을 경우, 예외가 발생합니다.

(하지만 reflect을 통해 등록하였기에 이 경우, error가 발생했을 때 외에는 발생하지 않습니다.)

 

 

main(index.ts)에 mongoDB 연결 코드 추가

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";
import { connect } from "./modules/database";

config();   // config '.env'

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

const USERNAME = process.env.DATABASE_USERNAME || "";
const PASSWORD = process.env.DATABASE_PASSWORD || "";
const HOSTNAME = process.env.DATABASE_HOSTNAME || "";
const OPTIONS  = process.env.DATABASE_OPTION   || "";

// connect and define documents
connect(USERNAME, PASSWORD, HOSTNAME, OPTIONS);

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);

이렇듯 굉장히 단순하고 편리하게 디스코드 봇을 구현할 수 있습니다.

 

마지막으로...

현재 진행 중인 jackpot(discord-bot)은 아직 테스트 단계를 거치고 있으며, 테스트가 모두 거쳐진 이후에는 배포할 예정입니다. 그리고 학교 축제 기간 동안 사용되다가 축제 종료와 함께 프로젝트를 종료할 계획입니다.

소스코드는 깃허브에 공개되어 있습니다.

 

GitHub - iqpizza6349/jackpot

Contribute to iqpizza6349/jackpot development by creating an account on GitHub.

github.com

개발 기간

  • 2023.05.28 ~ 2023.06.06
  • 약 63시간

테스트 기간 (예정)

  • 2023.06.12 ~ 2023.06.23
  • 약 13일간

 

 

이상입니다.