본문 바로가기

Node.js/Discord.js

Discord.js로 메이플스토리 디스코드 봇 만들기 (4)

저번 시간에 캐릭터 정보를 불러오는 명령어를 구현해 봤다.

 

Discord.js로 메이플스토리 디스코드 봇 만들기 (3)

지난 시간에 캐릭터 명령어를 구현하다가 말았는데, 이번 시간에는 나머지 부분을 마무리해 볼 것이다. Discord.js로 메이플스토리 디스코드 봇 만들기 (2) 지난 시간까지는 discord.js와 djs-commander를

huzan2.tistory.com

명령어 테스트 사진

 

이번에는 디스코드 봇을 만들게 된 진짜 동기인 "길드원 및 길드컨텐츠 관리" 기능을 만들어 볼 것이다.

 

어쩌다 보니 부길드마스터를 맡게 되어 매주 길드원들의 길드컨텐츠 참여 여부를 확인하고, 일일히 직위를 조정하는 것이 번거로웠는데, 데이터베이스 연동을 통해 이를 좀 더 편하게 할 수 있겠다는 생각이 들어 봇 개발을 시작하게 되었다.

 

길드원 각각의 정보를 MongoDB에 저장할 것이고, mongoose라는 라이브러리를 이용할 것이다.

 


SubCommand

먼저 구현해야 할 명령어를 간단히 알아보면, 데이터베이스에 길드원 정보를 생성/수정/삭제하는 명령어와, 매주 길드컨텐츠 참여 내역을 입력하는 명령어, 해당 참여내역을 기반으로 직위를 조정할 캐릭터의 목록을 출력하는 명령어 등을 구현해야 하는데, 각각의 명령어를 개별 명령어로 만들게 되면 명령어의 종류가 쓸 데 없이 많아지고, 헷갈리기 쉽다. (ex: /길드원생성, /길드원수정, /길드원삭제 등..)

 

때문에 이번에는 SubCommand라는 것을 이용해 볼 예정인데, SubCommand란 명령어 아래에 명령어를 여러 가지 넣는다고 생각하면 된다. 예를 들어보면 간단히 이해할 수 있는데, 아래와 같은 구조의 명령어를 만든다고 생각하면 된다.

/길드원 생성, /길드원 수정, /길드원 삭제 등

 

먼저 SubCommand의 틀을 잡아줄 것인데, commands 폴더에 manageGuildMember.js 파일을 생성한 후 다음과 같은 구조로 코드를 짜주고, 봇을 실행해보았다.

const { SlashCommandBuilder } = require("discord.js");

module.exports = {
  run: () => {},
  data: new SlashCommandBuilder()
    .setName("길드원")
    .setDescription("길드원 관리 명령어")
    .addSubcommand((subcommand) =>
      subcommand.setName("생성").setDescription("길드원 정보를 생성합니다.")
    )
    .addSubcommand((subcommand) =>
      subcommand.setName("수정").setDescription("길드원 정보를 수정합니다.")
    )
    .addSubcommand((subcommand) =>
      subcommand.setName("삭제").setDescription("길드원 정보를 삭제합니다.")
    ),
};

 

성공적으로 SubCommand들이 생긴 것을 확인할 수 있다.

 

각 SubCommand들을 작동시키기 위해 run 필드에 다음과 같은 함수를 생성한 뒤 봇을 실행시키고, 명령어를 사용해보자.

  run: ({ interaction }) => {
    const subcommand = interaction.options.getSubcommand();

    if (subcommand === "생성") {
      interaction.reply("길드원 정보 생성");
    } else if (subcommand === "수정") {
      interaction.reply("길드원 정보 수정");
    } else if (subcommand === "삭제") {
      interaction.reply("길드원 정보 삭제");
    }
  }

 

정상적으로 작동한다!

 

마지막으로 각 명령어에 들어갈 option들만 입력해주고 데이터베이스 쪽으로 넘어가보도록 하자.

 

option들은 일반 명령어와 같은 방법으로 넣어주면 된다. 생성은 닉네임/직업/직위 모두 필수적으로 입력하게 하고, 삭제는 닉네임만, 수정은 닉네임을 필수로 입력하되 직업과 직위는 선택사항으로 넣어줬다.

 


MongoDB

이제 본격적으로 데이터베이스를 구축하고, 그 안에 길드원 정보를 생성해볼 것이다. 먼저 mongoDB 홈페이지에서 회원가입을 해주고 나면 다음과 같은 화면을 마주하게 될 것이다.

 

MongoDB: 애플리케이션 데이터 플랫폼

업계 최고의 최신 데이터베이스를 토대로 구축된 애플리케이션 데이터 플랫폼을 사용해 아이디어를 더욱 빠르게 실현하세요. MongoDB는 데이터를 손쉽게 처리할 수 있도록 지원합니다.

www.mongodb.com

 

초록색 Create 버튼을 눌러준 다음, 원하는 provider를 선택하고 M0 요금제를 골라 무료 서비스를 이용하도록 하자. 지역은 대한민국으로 설정해 줬다.

 

다음 화면에서 username과 password, IP Access List를 설정할 수 있는데, Password는 자동으로 생성한 후 복사해서 미리 저장해두자. 그리고 IP Access List는 해당 데이터베이스에 접근할 수 있는 IP 주소를 설정할 수 있는데, 0.0.0.0/0을 입력하면 모든 접속을 허용할 수 있다.

 

다만 보안 측면에서 그닥 추천하지는 않고, 봇을 돌리는 서버의 ip 주소를 입력해두기를 추천한다.

 

참고로 create user를 누르면 자동으로 현재 IP 주소를 Access List에 추가해준다. Add my Current IP Address를 이용해도 좋다.

 


Mongoose

이제 db 생성이 끝났으면 vsc로 돌아와서, "mongoose"라는 라이브러리를 설치해준다.

npm i mongoose

몽구스는 node.js 환경에서 mongoDB를 편하게 사용할 수 있게 도와주는 라이브러리이다.

 

설치가 완료되었다면 index.js 파일의 상단에 mongoose를 불러와주자.

const mongoose = require("mongoose");

다음으로 client.login() 부분으로 이동해서 login을 실행하기 전에 db에 연결하도록 할 것이다.

(async () => {
  try {
    await mongoose.connect();
    console.log("Connected to mongoDB");

    client.login(process.env.TOKEN);
  } catch (error) {
    console.log(`Error connecting to Db: ${error}`);
  }
})();

mongoose.connect() 안에는 접속할 주소를 적어야 하는데, 다시 mongoDB의 overview 페이지로 돌아가서 초록색 connect를 눌러주면 다음과 같은 창이 뜬다.

맨 위에 있는 Drivers를 눌러주면 아래와 같은 창이 뜰 텐데,

3번에 있는 주소를 복사한 후, 예전에 생성해 둔 .env 파일에 새로운 환경 변수를 생성해서 붙여넣어주자. 필자는 DB_URI라는 이름으로 생성했다.

 

중간에 있는 <password> 부분에는 아까 복사해 둔 password를 붙여넣으면 된다.

혹시 주소 중간에 있는 등호나 슬래시 등의 기호 때문에 값이 정상적으로 저장되지 않는 것 같다면 ""로 둘러싸 문자열로 처리해주면 된다.

await mongoose.connect(process.env.DB_URI);

위 코드처럼 연결 주소를 입력해주고 봇을 실행하면 콘솔에서 정상적으로 db에 연결된 것을 확인할 수 있다.

 


Schema와 model

이제 mongoose를 이용해서 본격적으로 db에 정보를 저장해볼 것이다. 먼저 스키마를 생성해줘야 하는데, 스키마는 데이터베이스에 저장될 각 객체의 구성을 정의하는 거라고 생각하면 이해가 쉽다. 메인 디렉토리에 models 폴더를 생성하고, 그 아래에 memberDB.js를 생성해준 후, 아래 소스와 같이 스키마를 정의해줬다.

const { Schema, model } = require("mongoose");

const memberSchema = new Schema({
  nickName: { //닉네임
    type: String,
    required: true,
    unique: true,
  },
  grade: { //직위
    type: String,
    required: true,
  },
  job: { //직업
    type: String,
    required: true,
  },
  subChar: [], //부캐 목록
  guildContents: [], //길드컨텐츠 참여내역
  warned: { //연속 길드컨텐츠 미참여 횟수
    type: Number,
    default: 0,
  },
});

module.exports = model("memberDB", memberSchema);

memberSchema라는 이름의 스키마를 생성한 후, 스키마와 mongoDB 컬렉션을 연결해주는 "memberDB"라는 model을 export해준다. 이 model을 통해 DB에 데이터를 생성하거나, 수정하고, 삭제하는 작업을 수행할 수 있다.

 

[]로 표기한 array 안에는 일반적인 정수나 문자열 등의 요소도 들어갈 수 있고, 객체도 들어갈 수 있다.

subChar에는 본캐인 경우 부캐 닉네임들을, 부캐인 경우 본캐의 닉네임을 넣어줄 예정이고, guildContents에는 아래 형식과 같이 각 주차별 길드컨텐츠 참여 여부를 object에 넣어 저장할 예정이다.

{
	"time": "n-m" //n월 m주차
    "participated": true //참여 여부
}

 


길드원 정보 생성

다시 manageGuildMember.js로 돌아와서, 파일 상단에 아까 생성한 model을 불러와주도록 하자.

const memberDB = require("../models/memberSchema");

사실 한 파일 안에 길드원 정보 생성/삭제/수정을 전부 구현하면 한 파일에서 코드의 길이가 너무 길어진다. 때문에 여러 파일에 나눠 구현하려고 했지만 꽤 오랜 시간 오류와 씨름하다가 결국 한 파일에 구현하기로 했다.

 

보통 직업이나 닉네임, 직위 중 하나씩만 변경하는 경우가 많으므로 길드원 수정 명령어는 따로 구현해주기로 하고, 이번 파일에서는 길드원 정보 생성과 삭제만 구현해줄 것이다. 일단 subCommand 목록에서 "수정"을 지워주고, 각 options들의 값을 불러와 변수에 저장해준다.

    const inputNick = interaction.options.get("닉네임").value;
    const inputJob = interaction.options.get("직업")?.value;
    const inputGrade = interaction.options.get("직위")?.value;

삭제 명령어의 경우 직업과 직위를 받지 않으니 "?"를 붙여준다. 직업과 직위를 불러오지 못할 경우 undefined가 저장될 것이다.

 

먼저 길드원 생성에 앞서 이미 존재하는 길드원 정보를 중복으로 생성하는 것을 방지하기 위해 확인 절차를 거쳐준다.

      const ifexist = await memberDB.exists({ nickName: inputNick });
      if (ifexist) {
        interaction.reply("이미 존재하는 길드원 정보입니다.");
        return;
      }

 

이제 데이터베이스에 길드원 정보를 저장할 것인데, 다음과 같은 방법으로 document를 하나씩 만들어줄 수 있다.

      try {
        const newMember = new memberDB({
          nickName: inputNick,
          grade: inputGrade,
          job: inputJob,
        });
        await newMember.save();
        interaction.reply(
          `길드원 정보를 생성했습니다. 닉네임: ${inputNick} | 직업: ${inputJob} | 직위: ${inputGrade}`
        );
      } catch (error) {
        interaction.reply(`error saving info: ${error}`);
      }

 

직접 명령어를 실행해서 확인해보면...

cloud.mongodb.com에서 collection을 직접 확인해보면 성공적으로 정보가 저장된 것을 확인할 수 있다!!

 


길드원 정보 삭제

이제 삭제 명령어를 구현해보자. 생성 때와 비슷하게 먼저 존재하지 않는 길드원 정보라면 삭제할 수 없으니 이를 확인해준다.

      const ifexist = await memberDB.exists({nickName: inputNick});
      if(!ifexist){
        interaction.reply("존재하지 않는 길드원 정보입니다.");
        return;
      }

 

삭제는 생성보다 더 간단한데, deleteOne() 또는 deleteMany()를 사용해주면 된다. 길드원 정보를 생성할 때 중복 생성을 막고 있으니 deleteOne을 이용해주도록 하겠다.

      try {
        await memberDB.deleteOne({ nickName: inputNick });
        interaction.reply(`${inputNick} 길드원의 정보를 삭제했습니다.`);
      } catch (error) {
        interaction.reply(`erorr deleting info: ${error}`);
      }

deleteOne을 사용할 때, 모든 속성의 값을 입력할 필요 없이, 찾으려고 하는 문서의 일부 정보만 입력해주면 제일 먼저 찾은 문서의 정보를 삭제한다. deleteMany를 이용하면 입력한 속성과 일치하는 모든 문서를 삭제할 수 있으니 참고하자.

 

이제 테스트를 해보면...

성공적으로 정보를 삭제한 것을 확인할 수 있다.

 


이번 시간에는 mongoDB와 discord.js를 연결하기 위해 mongoose라는 라이브러리를 이용해봤고, 정보를 생성/삭제해봤다. 다음 시간에는 길드원 정보를 수정하고 길드컨텐츠 참여 내역을 관리하는 명령어를 만들어보도록 하자.

 

+한 가지 잊은 것이 있는데, 명령어의 사용 권한을 설정하는 것도 다음 시간에 함께 다뤄보도록 할 예정이다.

 

그럼 manageGuildMember.js의 코드 전문을 첨부하고 이번 포스팅은 마무리하자.

const { SlashCommandBuilder } = require("discord.js");
const memberDB = require("../models/memberSchema");

module.exports = {
  run: async ({ interaction }) => {
    const subcommand = interaction.options.getSubcommand();
    const inputNick = interaction.options.get("닉네임").value;
    const inputJob = interaction.options.get("직업")?.value;
    const inputGrade = interaction.options.get("직위")?.value;

    if (subcommand === "생성") {
      const ifexist = await memberDB.exists({ nickName: inputNick });
      if (ifexist) {
        interaction.reply("이미 존재하는 길드원 정보입니다.");
        return;
      }
      try {
        const newMember = new memberDB({
          nickName: inputNick,
          grade: inputGrade,
          job: inputJob,
        });
        await newMember.save();
        interaction.reply(
          `길드원 정보를 생성했습니다. 닉네임: ${inputNick} | 직업: ${inputJob} | 직위: ${inputGrade}`
        );
      } catch (error) {
        interaction.reply(`error saving info: ${error}`);
      }
    } else if (subcommand === "삭제") {
      const ifexist = await memberDB.exists({ nickName: inputNick });
      if (!ifexist) {
        interaction.reply("존재하지 않는 길드원 정보입니다.");
        return;
      }
      try {
        await memberDB.deleteOne({ nickName: inputNick });
        interaction.reply(`${inputNick} 길드원의 정보를 삭제했습니다.`);
      } catch (error) {
        interaction.reply(`erorr deleting info: ${error}`);
      }
    }
  },
  data: new SlashCommandBuilder()
    .setName("길드원")
    .setDescription("길드원 관리 명령어")
    .addSubcommand((subcommand) =>
      subcommand
        .setName("생성")
        .setDescription("길드원 정보를 생성합니다.")
        .addStringOption((option) =>
          option
            .setName("닉네임")
            .setDescription("정보를 생성할 길드원의 닉네임")
            .setRequired(true)
        )
        .addStringOption((option) =>
          option
            .setName("직업")
            .setDescription("정보를 생성할 길드원의 직업")
            .setRequired(true)
        )
        .addStringOption((option) =>
          option
            .setName("직위")
            .setDescription("정보를 생성할 길드원의 직위")
            .setRequired(true)
        )
    )
    .addSubcommand((subcommand) =>
      subcommand
        .setName("삭제")
        .setDescription("길드원 정보를 삭제합니다.")
        .addStringOption((option) =>
          option
            .setName("닉네임")
            .setDescription("정보를 삭제할 길드원의 닉네임")
            .setRequired(true)
        )
    ),
};