Discord.js란 무엇인가?

팀스피크라는 구닥다리 프로그램에 이어 게이머들을 위한 그룹 회화용 프로그램인 Discord가 2015년 5월 13일에 출시되었다. Discord.js는 Javascript 언어를 이용하여 Discord API에 접속하는 프레임워크이다. 더 자세히 이야기하자면, Node를 위한 모듈이다.

Bot

많은 사람들이 위 모듈을 이용하여 봇(Bot)을 제작한다. 초창기 Discord 봇 개발자들은 보통 음악 봇을 많이 제작했다. 지금도 대다수가 마찬가지겠지만 그래도 최근에는 창의적이며 유용한 봇들이 많이 생겨났다.

나는 음악 봇을 제작하기로 마음 먹었다. 이미 훌륭한 음악 봇들이 시중에 많지만, 나는 유튜브에 있는 음악 뿐만 아니라 로컬 파일에 있는 음악도 재생하고 싶어서 따로 만들게 되었다.

준비물

  • BOT 토큰 및 BOT 클라이언트 ID
  • Node
  • 테스팅용 디스코드 서버
    • 봇에게 권한을 줘야하기 때문에 자신에게 서버 권한이 있어야 한다.

Discord.js API Documentation

링크

Documentation을 읽지 못한다면 개발자는 포기해야 한다. 아무것도 못하기 때문이다.


너무 상세한 설명은 하지 않겠다. 그리고 내가 짠 코드가 정석이라고 말할 수도 없다.

Let's Start

GitHub: hjs0410hc/DJSLocalPlayerBot

index.js

const { REST, Routes, Client, GatewayIntentBits, Message } = require('discord.js');
const client = new Client({ intents: [GatewayIntentBits.Guilds,
                                      GatewayIntentBits.GuildVoiceStates,
                                      GatewayIntentBits.GuildMembers,
                                      GatewayIntentBits.GuildPresences] });
const {Commands,CommandList} = require('./commands');
require('dotenv').config();


const rest = new REST({ version: '10' }).setToken(process.env.BOT_TOKEN);

(async () => {
  try {
    console.log('명령어 목록 갱신 시도');
    await rest.put(Routes.applicationCommands(process.env.BOT_CLIENT_ID), { body: CommandList });

    console.log('명령어 목록이 갱신됨');
  } catch (error) {
    console.error(error);
  }
})();

client.on('ready', () => {
  console.log(`${client.user.tag}로 로그인되었습니다.`);
});

client.on('interactionCreate', async interaction => {
    if (!interaction.isChatInputCommand()) return;
    Commands.get(interaction.commandName)(interaction);
});

client.login(process.env.BOT_TOKEN);

위 코드는 Discord.js를 이용하여 봇이 Discord 서버에 로그인하는 일련의 과정이다.

Client 객체를 생성하는데에 있어 intents를 설정해주는 것을 확인할 수 있다. 이는 봇이 무엇을 할 것인지 미리 알리는 역할로, 저것이 없으면 행동이 제한된다. 마치 허락을 받는 부분이다.

봇의 커맨드의 경우는 commands 폴더에 빼두었다.

이제 REST라는 객체를 이용하는데, Discord의 REST API를 활용할 때 사용되는 인터페이스 역할이다. Bot의 명령어 목록을 갱신시키기 위해서는 Discord의 REST API를 활용해야만 한다. 버전과 bot 토큰을 설정해주었다.

REST 및 Routes는 현재 discord.js/rest로 분리되었다. 참조

다음, 명령어 목록 갱신을 시도하는 비동기 람다 함수를 만들어서 즉시 호출해주었다. 한 번 쓸 함수이기 때문에 굳이 이름을 지어주지 않았다.

이제 Node의 정수, event-driven architecture가 보인다. .on(event, eventhandler) 으로 이벤트가 발생하면 함수를 실행한다. 이 event들은 이 문서에서 Events를 찾으면 목록을 확인할 수 있다.

ready 이벤트를 받아서 콘솔로 우리에게 로그인되었음을 확인해주고, interactionCreate라는 이벤트를 받아서 실제 유저가 봇의 명령(interaction)을 내린(create) 경우 해당하는 커맨드를 실행한다.

on()은 이벤트 핸들러를 달아주는 함수이므로 실제 로그인 전 실행되어야만 할 것이다. 따라서 마지막에 client.login(BOT_TOKEN)이 있다.

Commands

commands/index.js

const {MusicCommandList,MusicCommands} = require('./music.js')
const {UtilCommandList,UtilCommands} = require('./util.js')


const Commands = new Map([...MusicCommands,...UtilCommands]);
const CommandList = [
    ...MusicCommandList,
    ...UtilCommandList
]

module.exports = {Commands,CommandList};

봇이 수행하는 명령어는 계속 추가될 것이며 문자열로 찾아야 할 것이다. 따라서 Map을 사용했다. 이러지 않는다면 계속 하드코딩으로 명령어를 찾아야만 했을 것이다.

commands/music.js

음악 재생을 담당하는 부분이다.

const MusicCommandList = [
    {
        name:'join',
        description:'사용자가 들어 있는 음성 채널에 진입'
      },
      {
        name:'play',
        description:'음악을 재생합니다.',
        options:[
            {
                type:4,
                name:'idx',
                description:'음악 순번을 입력하세요.',
                required:false,
                min_value:0
            }
        ]
      },
      {
        name:'pause',
        description:'음악 일시정지'
      },
      {
        name:'resume',
        description:'음악 재개'
      },
      {
        name: 'leave',
        description: '음성 채널 나가기'
      },
      {
        name: 'volume',
        description: '현재 볼륨을 표시하거나 볼륨을 설정',
        options:[
            {
                type:4,
                name:'vol',
                description:'원하는 볼륨 값을 입력하세요.',
                required:false,
                min_value:0,
                max_value:100
            }
        ]
      },
      {
        name: 'list',
        description: '음악 목록 표시'
      },
      {
        name:'add',
        description:'재생목록에 음악 추가 또는 경로 변경',
        options:[
            {
                type:4,
                name:'idx',
                description:'순번을 입력하세요. (/list 로 확인)',
                required:true,
                min_value:0
            }
        ]
      },
      {
        name: 'queue',
        description: '재생목록 표시'
      },
      {
        name: 'next',
        description: '다음 곡 재생하기'
      },
      {
        name:'ytplay',
        description: '유튜브 음악 재생하기',
        options:[
            {
                type:3,
                name:'url',
                description:'유튜브 URL을 입력하세요.',
                required:true
            }
        ]
      }
];

위 형식은 내가 임의로 만든 형식이 아니다. index.js에서 REST API를 호출하여 봇의 명령어 목록을 갱신한다고 했다. 따라서 API의 형식에 맞게 명령어 목록을 작성해야 한다. 그건 여기에 있다.

이렇게 파일마다 명령어 목록을 만들어 놓으면, commands/index.js에서 이들을 CommandList에 취합하여 (...(spread operator)를 이용) REST API에 보내는 것이다.

const MusicCommands = new Map();

MusicCommands.set('join',async(interaction)=>{
    if(!interaction.member.voice.channel){
        return await interaction.reply('❌ 아무런 음성채널에 들어가 있지 않습니다.')
    }
    const voiceConnection = joinVoiceChannel({
        channelId:interaction.member.voice.channelId,
        guildId:interaction.guild.id,
        adapterCreator:interaction.guild.voiceAdapterCreator,
    })
    await interaction.reply('ℹ 음성 채널 진입: '+interaction.member.voice.channel.name)
    
})

module.exports = { MusicCommandList , MusicCommands };

실제 명령어의 구현을 Map에 담기로 작정했었다. set 명령을 이용하여 'join'이라는 문자열 key에 비동기 함수를 value로 넣었다.

그리고 이를 module.exports에 넣어 commands/index.js에서 Commands라는 Map 객체에 취합할 수 있도록 한다.

이제 discord.js를 제대로 활용하는 시간이다.

client.on('interactionCreate', async interaction => {
    if (!interaction.isChatInputCommand()) return;
    Commands.get(interaction.commandName)(interaction);
});

이 부분이 기억나는가? interactionCreate라는 이벤트가 발생할 경우 discord.js는 발생한 interaction을 개발자가 처리할 수 있도록 콜백함수에 parameter로 전달해준다. 따라서 람다 함수를 만들어 콜백함수를 제작해 주었다.
함수 내부를 잘 보자. interaction의 validity를 검사하고, 실패 시 early-return 한다.
그리고, 모든 커맨드의 구현이 취합된 Map 객체 Commands에 get()을 하여 함수를 불러오고, interaction을 parameter로 넘긴다.

이때 우린 interaction이 어떻게 생겨먹었는 지 알아야 써먹을 수 있겠다. 이곳에 있다.

문서의 Client->Events->interactionCreate를 보면 BaseInteraction이 발생함을 확인할 수 있다. 문제는 이거다. 실제 발생되는 Interaction은 ChatInputCommandInteraction이다. 봇의 interaction이 발생하는 경우에 따라 발생하는 객체가 다른 모양이다. 따라서 우리는 ChatInputCommandInteraction의 문서를 봐야 한다.

commandName이 떡하니 있는 것을 볼 수 있다. 이를 Map의 search-key로 넘겨줬던 것.


MusicCommands.set('join',async(interaction)=>{
    if(!interaction.member.voice.channel){
        return await interaction.reply('❌ 아무런 음성채널에 들어가 있지 않습니다.')
    }
    const voiceConnection = joinVoiceChannel({
        channelId:interaction.member.voice.channelId,
        guildId:interaction.guild.id,
        adapterCreator:interaction.guild.voiceAdapterCreator,
    })
    await interaction.reply('ℹ 음성 채널 진입: '+interaction.member.voice.channel.name)
    
})

명령어 구현을 다시 보자. 음악을 재생하기 위해선 일단 음성 채널에 들어갈 필요가 있다.
음성 채널을 마구잡이로 고를 순 없으니 현재 interaction을 발생시킨 유저가 들어가 있는 음성 채널(interaction.member.voice.channel)을 찾는다. 없으면 역시 early-return으로 돌아간다. interaction.reply를 이용하여 명령어에 답변을 해주는 모습이다. (이건 다른 사람에게 보이지 않는다.)

voiceConnection을 만들 시간이다.
joinVoiceChannel(channelId,guildId,adapterCreator)라는 함수를 사용했다. 이는 @discord.js/voice에 존재한다.

옵션은 CreateVoiceConnectionOptions와 JoinVoiceChannelOptions 구조를 참조했다.

channel은 음성 채널을 의미하고, guild는 Discord 서버를 의미한다. 필요한 정보들은 전부 interaction 안에 딸려나오므로 갖다 쓰면 되겠다.

비로소 봇이 음성 채널에 진입하고 답변을 보냈다.


이 글은 봇을 완성하기 위함이 아니라 Discord.js라는 Node 모듈을 사용하는 기록을 남기는 데에 그 목적이 있다. 따라서 본문은 여기까지이다.

느낀 점을 좀 이야기하겠다.

개발자로 활동하면 수 많은 라이브러리와 모듈과 접하게 될 것이라고 생각한다. 공식 문서를 읽을 수 있는 능력이 있다면 실무에서도 큰 도움이 될 수 있을 것이라 생각한다.

그러나 Discord.js를 만지면서 많은 의문이 들 수 밖에 없었다. 이거 어떻게 다 찾는건가? 나는 생각했다. 역시 다른 사람의 도움을 받는 것이 중요하다. 구글이나 스택오버플로우 등 먼저 검색을 하고 그 다음 세부적인 것을 공식 문서에서 찾는, 이 순서가 중요하다고 느꼈다. 일단 시작은 해야 될 것 아닌가.

처음부터 모든 것을 아는 사람은 전지전능한 신이지 그것은 개발자로써 남아 있을 존재가 아니다. 나는 보통의 인간. 기록을 헤쳐가며 나아가는 탐험가... 먼저 탐험했던 선구자의 뒤를 따라 언젠가 나도 선구자가 될 수 있지 않을까, 그렇게 생각한다.

이러한 작은 발돋움들이 나의 성장을 촉진할 것이라 믿어 의심치 않는다.

즐거운 한가위 되시길 바랍니다.

~完~

profile
THXX FOR EVERYTHING

0개의 댓글

Powered by GraphCDN, the GraphQL CDN