본문 바로가기
프로그래밍

파이썬으로 디스코드 봇 만들기

by 기이한날개 2022. 2. 3.

디스코드라고 자주 쓰는 음성 채팅 프로그램이 있다.

주로 친구들과 게임을 같이 할 때 쓰는데, 서버별로 추가할 수 있는 봇 프로그램을 쉽게 찾아볼 수 있다. 기능은 다양한데, 어떤 봇은 채팅에 반응해 답장을 해주기도 하고 어떤 봇은 음성 채널에서 노래를 틀어주기도 한다.

 

주어진 기능만 사용하자니 조금 아쉽기도 하고 내가 필요한 기능만을 포함한 우리 서버만의 특별한 디스코드 봇을 만들어보고 싶다는 생각이 들었다. 찾아보니 파이썬으로도 간단하게 구현이 가능하다고 해서 도전해보게 되었다.

 

[파이썬으로 디스코드 봇 만들기]

기간: 2021.08. ~ 2021.12.(5개월)

공부 시간: 200시간 이상

체감 난이도: 어려움

 

시작

정말 아무것도 모르는 상태에서 '파이썬으로 디스코드 봇을 만들 수 있다'라는 말만 믿고 무작정 시작했다.

파이썬용 디스코드 봇 API인 discord.py로 만들라고 해서 찾아봤다.

파이썬 공부를 시작한지 6개월도 안 된 시점이었고 내가 아는 거라곤 if, else와 for 수준의 기본적인 문법뿐이었다. 그래서 사실 discord.py 문서를 읽어봐도 무슨 말인지 사실 알기가 쉽지 않았다. 하나하나 찾아보면서 공부하고 이해하는 수밖에 없었다.

 

그래서 그냥 구글링을 통해 남들이 쓴 코드를 보면서 따라했고, 이런 식으로 시작해야 한다는 걸 알 수 있었다.

import discord
from discord.ext import commands

bot = commands.Bot(command_prefix='')

# 로그인
@bot.event
async def on_ready():
    print('Logged in as')
    print(bot.user.name)
    print(bot.user.id)
    print('------')

@bot.command()
async def ping(ctx):
    await ctx.send('pong')

DISCORD_TOKEN = 'TOKEN'
bot.run(DISCORD_TOKEN)

command_prefix : 봇이 반응할 명령 접두사. 나는 따로 접두사를 채택하지 않고 그냥 특정 채팅 채널의 모든 채팅에 대해 반응하도록 해두었다.

@bot.command : 이 밑에 일정한 형식으로 명령을 하나씩 정의한다.

DISCORD_TOKEN : discord developer portal에서 내 application을 추가하고 token을 받는다.

토큰을 코드에 넣고 돌리면 되는데, 그전에 봇을 서버에 추가해주어야 한다.

 

채팅 읽어주기 기능(TTS)

가장 핵심적인 기능으로 생각했던 것이 채팅을 자동으로 읽어주는 기능이다.

사실 봇을 만들고 싶다고 생각한 이유도 TTS 기능이 필요해서였다.

가끔 마이크를 사용하지 못하는 친구들이 있어서 듣기만하고 가끔 채팅으로 답장을 했는데, 그 채팅을 확인하지 못해서 소통이 잘 안 되는 경우가 많았다. 그래서 차라리 치는 모든 채팅을 음성으로 들려주면 편하겠다고 생각했다.

 

봇을 음성채널에 들어오거나 나갈 수 있게 각각 동작을 만들어주고, gTTS 모듈을 사용해 채팅 내용을 음성으로 바꿔준 후에 봇이 음성 채널에서 파일을 재생하는 방식으로 구현했다.

from gtts import gTTS
from io import BytesIO
from FFmpegPCMAudioGTTS import FFmpegPCMAudioGTTS

# 음성채널 입장        
@bot.command()
async def join(ctx):
    try:
        author = ctx.message.author
        channel = author.voice.channel
        await channel.connect()
        await ctx.send("ㄷㄷㄷㅈ")
        print("음성채널 입장 완료")
    except Exception as e:
        print("[Join에서 에러]", e)

# 음성채널 퇴장
@bot.command()
async def leave(ctx):
    try:
        await ctx.voice_client.disconnect()
        print("음성채널 퇴장 완료")
    except Exception as e:
        print("[Leave 함수에서 에러]", e)

# TTS
@bot.command(name="0")
async def _1(ctx, *, text=None):
    try:
        mp3_fp = BytesIO()
        tts = gTTS(text=text, lang='ko')
        tts.write_to_fp(mp3_fp)
        vc = ctx.guild.voice_client
        mp3_fp.seek(0)
        vc.play(FFmpegPCMAudioGTTS(mp3_fp.read(), pipe=True), after=print("Done"))
    except Exception as e:
        print("[TTS 함수에서 에러]", e)

음성 채널에서 플레이하는데 파일을 따로 저장했다가 불러오는 방식이 아니고 바로 재생하기 위해서 stackoverflow의 비슷한 질문에서 도움을 받아 FFmpegPCMAudioGTTS라는 스크립트를 사용했다. 

https://stackoverflow.com/questions/68123040/discord-py-play-gtts-without-saving-the-audio-file

0을 명령어로 사용해서 이런식으로 채팅을 치면 봇이 음성 채널에서 읽어줬다.

gTTS라 아무래도 자연스러운 사람 목소리는 아니지만 이걸로 소통하는데 문제는 없었고 되게 만족스러웠다.

 

암호화폐 현재가

한창 비트코인을 비롯한 코인 열풍이었어서 디스코드 서버에 업비트 이용자가 많았다.

코인 현재가를 음성으로 읽어주면 재미있겠다고 생각해서 업비트 코인 현재가를 불러와서 읽어주게 만들었다.

이전에 업비트 API로 간단한 자동 매매 알고리즘을 만들어본 적도 있어서 구현하기 수월했다.

 

import requests

# 코인 현재가
@bot.command(name="가즈아")
async def _1(ctx, *, text="비트코인"):
    try:
        url = "https://api.upbit.com/v1/market/all"
        querystring = {"isDetails":"false"}
        headers = {"Accept": "application/json"}
        response = requests.request("GET", url, headers=headers, params=querystring).json()
        dic = list(filter(lambda x: x["korean_name"] == text, response))
        dic = list(filter(lambda x: x["market"][0:3] == "KRW", dic))
        ticker = dic[0]['market']
        print(ticker)
        new_url = "https://api.upbit.com/v1/ticker?markets="+ticker
        response = requests.request("GET", new_url, headers=headers, params=querystring)
        price = int(response.json()[0]['trade_price'])
        voiceline = "{}..아직 {}원인데요??".format(text, price)
        await ctx.send(voiceline)

        mp3_fp = BytesIO()
        tts = gTTS(text=voiceline, lang='ko')
        tts.write_to_fp(mp3_fp)
        vc = ctx.guild.voice_client
        mp3_fp.seek(0)
        vc.play(FFmpegPCMAudioGTTS(mp3_fp.read(), pipe=True), after=print("Done"))

    except Exception as e:
        print("[가즈아 함수에서 에러]", e)

이렇게 가즈아를 외치면 현재가를 채팅으로도 쳐주고 음성채널에서 읽어주기도 한다. 게임하는 친구가 들고 있는 코인을 물어봐서 현재가를 알려주는 재미가 있다.

 

필요한 정보 웹에서 가져오기

인터넷에서 유용할만한 정보를 바로 가져와서 보여줄 수 있는 기능을 구현했다.

기본적으로는 업비트 코인 현재가와 비슷하지만 API 대신 beatifulsoup 라이브러리를 이용했다.

from bs4 import BeautifulSoup

# 웹 스크래핑 soup 객체 생성
def create_soup(url):
    headers = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", "Accept-Language": "ko"}
    res = requests.get(url, headers=headers)
    res.raise_for_status()
    soup = BeautifulSoup(res.text, "lxml")
    return soup

# 네이버 날씨 현재 온도
@bot.command(name="날씨")
async def _1(ctx):
    url = "https://weather.naver.com/"
    soup = create_soup(url)
    temp = soup.find("strong", attrs={"class":"current"}).get_text()[5:]
    summary = soup.find("p", attrs={"class":"summary"}).get_text().strip()
    summary2 = soup.find("span", attrs={"class":"weather before_slash"}).get_text()
    summary_list = soup.find("dl", attrs={"class":"summary_list"})
    await ctx.send("현재 온도는 {0} {1}".format(temp, summary[:-len(summary2)]))
    
# 일일 코로나 확진자
@bot.command(name="확진자")
async def _1(ctx):
    url = 'http://ncov.mohw.go.kr/'
    soup = create_soup(url)
    stat = soup.find("div", attrs={'class':'occurrenceStatus'})
    text = stat.h2.span.text.strip().split(',')[0]+")"
    num = soup.find("table", attrs={'class':'ds_table'}).tbody.tr.find_all('td')[3].text.strip()
    await ctx.send(f"오늘 확진자 {num}명 {text}")

 

롤 관련 정보

디스코드 서버에서 주로 롤을 많이 해서 유용할만한 정보를 바로 찾아볼 수 있게 구현했다. 대부분 그냥 op.gg에서 가져온 것으로 앞서 날씨와 코로나 확진자 구현한 방식과 거의 같다. 

from selenium import webdriver
import chromedriver_autoinstaller

# op.gg 소환사 전적 검색
@bot.command(name="전적")
async def _1(ctx, *, text):
    if text is None:
        print("아이디가 입력되지 않았어요")
    url_base = "https://www.op.gg/summoner/userName="
    url = url_base + text
    soup = create_soup(url)

    # 연승연패
    i = 0
    recent = soup.find("div", attrs={"class":"GameItemList"}).find_all("div", attrs={"class":"GameItemWrap"})
    wl = recent[0].find("div")["data-game-result"].strip()
    for game in recent:
        # print(game.find("div")["data-game-result"])
        if wl == game.find("div")["data-game-result"].strip():
            i += 1
        else:
            break
    
    if wl == "lose":
        letter = "패"
    else:
        letter = "승"
    msg = f"[{str(i)}연{letter}중입니다]\n"

    # 모스트 챔프
    msg2 = ""
    ranked = soup.find("div", attrs={"class":"MostChampionContent"})
    champ_list = ranked.find_all("div", attrs={"class":"ChampionBox Ranked"})
    for champ in champ_list[:4]:
        champ_name = champ.find("div", attrs={"class":"ChampionInfo"}).find("div").find("a").text.strip()
        champ_kda = champ.find("div", attrs={"class":"PersonalKDA"}).find("div").find("span").text.strip().split(":")[0]
        champ_winrate = champ.find("div", attrs={"class":"Played"}).find_all("div")[0].text.strip()
        champ_game = champ.find("div", attrs={"class":"Played"}).find_all("div")[1].text.strip().split()[0]
        message = champ_name+" "+champ_kda+" "+champ_game+"판"+" "+champ_winrate
        msg2 += message + "\n"
    await ctx.send(msg+msg2)


# op.gg 라인별 N티어 챔프
# '탑', '정글', '미드', '원딜', '서폿' 1~5티어 검색
def check_tier_argument(arg: str) -> str:
    if arg[-2:] == '티어':
        try:
            num = int(arg[:-2])
            if num > 5:
                return False, '티어는 1에서 5 사이의 숫자로 입력하세요'
            else:
                return True, num
        except:
            return False, '잘못된 입력 형식입니다'
    else:
        return False, ''

def get_tier_champ(tf, arg2, line):
    if tf == True:
        num = int(arg2)
        url = "https://www.op.gg/champion/statistics"
        soup = create_soup(url)
        msg = f"[{line} {num}티어 챔프]"
        line_eng = {'탑':'TOP', '정글':'JUNGLE', '미드':'MID', '원딜':'ADC', '서폿':'SUPPORT'}
        text = line_eng[line]
        class_name = f"tabItem champion-trend-tier-{text}"
        champ_list = soup.find("tbody", attrs={"class":class_name}).find_all("tr")
        print(len(champ_list))
        for champ in champ_list:
                name = champ.find("td", attrs={"class":"champion-index-table__cell champion-index-table__cell--champion"}).a.div.text.strip()
                winrate = champ.find_all("td", attrs={"class":"champion-index-table__cell champion-index-table__cell--value"})[0].text.strip()
                pickrate = champ.find_all("td", attrs={"class":"champion-index-table__cell champion-index-table__cell--value"})[1].text.strip()
                tier = int(champ.find_all("td", attrs={"class":"champion-index-table__cell champion-index-table__cell--value"})[2].img["src"][-5:-4])
                print(type(tier), type(num))
                if tier <= num:
                    if tier == num:
                        msg += f"\n{name} {winrate} {pickrate}"
                else:
                    break
        return msg
    else:
        return arg2

@bot.command(aliases=['탑','정글','미드','원딜','서폿'])
async def _1(ctx, *, text="1티어"):
    tf, arg2 = check_tier_argument(text)
    print(ctx)
    print(ctx.invoked_with)
    msg = get_tier_champ(tf, arg2, ctx.invoked_with)
    await ctx.send(msg)

op.gg 기준 챔피언별로 가장 많이 사용하는 룬도 가져와서 png 파일로 보여주도록 했다.

사실 내가 매판 룬 찾아보기 귀찮아서 넣은 기능인데 유용하긴 하지만 selenium으로 돌리고 브라우저를 캡처한 후에 사진 파일로 보내는 형식으로 약간의 딜레이가 있는 점이 아쉬웠다.

from selenium import webdriver
import chromedriver_autoinstaller 
from io import BytesIO

# op.gg 제공 가장 많이 쓰이는 룬
@bot.command(name="룬", text=None, pos=None)
@commands.cooldown(1, 20, commands.BucketType.user)
async def _4(ctx, *, text=None):
    if text is None:
        await ctx.send("챔피언 이름을 적어주세요")
    elif text not in list(total_champ_dic.keys()):
        await ctx.send("챔피언 이름을 정확히 적어주세요")
    else:
        champ = total_champ_dic[text]
        path = chromedriver_autoinstaller.install()
        url = f'https://www.op.gg/champion/{champ}/statistics/'
        options = webdriver.ChromeOptions()
        options.add_argument('headless')
        options.add_argument('window-size=1920x1080')
        options.add_argument("disable-gpu")
        browser = webdriver.Chrome(path, options=options)
        browser.maximize_window()
        print("-----로딩중-----")
        await ctx.send("잠시만 기다려주세요..")
        try:
            browser.set_page_load_timeout(3)
            browser.get(url)
        except Exception as e:
            print('[Timeout]{e}')
        print("-----로딩완료-----")
        browser.execute_script("window.scrollTo(0, document.body.scrollHeight);")
        print("스크롤 완료")

        element = browser.find_element_by_xpath('/html/body/div[2]/div[2]/div/div[2]/div[5]/div[1]/div/div[1]/div/table/tbody[2]/tr[1]/td[1]')
        scshot = element.screenshot_as_png #image byte
        browser.quit()
        arr = BytesIO(scshot)
        await ctx.send(f'{text} 룬 <{url}>')
        await ctx.send(file=discord.File(arr, f'{champ}_rune.png'))

서버에 올리기

이 때까지만 해도 내가 파이썬으로 코드를 돌리는 시간 대에만 봇을 사용할 수 있는 치명적인 단점이 있었다. 따라서 내가 디코 서버에 없으면 사용할 수가 없었다. 검색을 해본 결과 서버에 올리면 내가 따로 켜주지 않아도 사용 가능해진다는 것을 알 수 있었다. 

 

서버라는 걸 다루는 것이 아예 처음이었기 때문에 디코 봇을 처음 만들 때 이상의 스트레스를 받았다. 모르는 것이 너무 많고 다양한 문제가 많이 발생했는데 심지어 자료도 부족해서 해결책을 찾거나 도움을 받는 것조차 쉽지 않았다. 예를 들면 chromedriver를 원래 로컬에 저장해 놓고 파일 절대 주소로 찾도록 했는데, 서버 상에서는 작동 방식이 달라 add-in을 다는 방식으로 구현해야 했다. 알고 보면 간단한 문제지만 무엇이 문제인지 모르고 뭘 모르는지도 모르는 상태에서는 정말 막막하고 난감했다.

 

Heroku라는 호스팅 사이트를 이용했는데, 튜토리얼 가이드가 친절하고 무료로 사용할 수 있는 시간이 길어서 선택했다.

https://dashboard.heroku.com/

 

Github에 코드를 올린 후에 Heroku에서 이걸 기동하는 방식을 채택했다.

코드를 올리는 repository에는 Procfile과 requirements.txt가 필요하다 그래서 추가해줬다.

한 가지 신기했던 것은 DISCORD_TOKEN 같은 개인 정보를 Github에 공개적으로 올리면 안 돼서 Heroku 같은 서버에 공개되지 않도록 처리를 해야 되는데, 실수로 Github에 올리고 코드를 공개하고 싶어 repository를 공개로 바꿔둔 적이 있다. 그랬더니 바로 디스코드로 경고 메시지가 날아왔다.

깃헙에 올린 코드를 디스코드 측에서 실시간으로 감지하고 알림을 주는 것이 정말 신기했다. 

 

마무리

이런 방식으로 우리 서버만의 디스코드 봇을 만들었고, 지금도 자잘한 기능들을 유용하게 잘 사용하고 있다. 

 

한 가지 아쉬웠던 것은 아무래도 디스코드라는 프로그램 안에서 작동하는 봇이다 보니 기능들이 제한되어 있고 확장성이 좋지 못했다는 점이다. 주어진 몇 가지 API대로 코딩하는 것 이상의 결과물을 내기는 힘들다는 생각이 들었다.

 

그래도 개인적으로 부족한 실력으로 정말 많은 시간을 쏟아부었고 실패하면서 배우는 귀중한 경험이 된 것 같다. 생각보다 실용적인 것을 만들었다는 점에서 결과물에 대해서도 상당히 만족한다. 

 

앞으로도 디코 서버 구성원들의 의견을 반영해서 유용한 기능들을 추가해 나갈 생각이다.