1번 글에서 예고한 대로 이번 글에서는 앞서 만든 라이브러리와 discord.py를 활용한 봇을 개발에 관한 내용을 기술한다.
파이썬에 C# 라이브러리를 올리기 위해 pythonnet - Python.NET을 사용했다.
VS2022로 라이브러리를 테스트하던 중, 자꾸 에러가 나서 닷넷 버전 문제인 것 같아 VS2019에서 4.8 환경에서 빌드 후 테스트 하니 잘 동작했다. 혹시 이 글을 본 사람 중 이런 시도를 할 사람이 있다면 닷넷 4.* 버전을 사용하길 바란다...
dll 임포트는 아래와 같은 코드로 할 수 있다.
import clr
clr.AddReference("kord")
from kord import Translator
trns = Translator(TR_Cliend_Id, TR_Cliend_Secret, LD_Cliend_Id, LD_Cliend_Secret)
py 가 있는 곳에 같이 kord.dll을 두었기 때문에 단순히 "kord"로 별도 경로 지정 없이 레퍼런스를 추가할 수 있다.
class MyClient(discord.Client):
def __init__(self):
super().__init__(intents=discord.Intents.default())
self.tree = app_commands.CommandTree(self)
async def setup_hook(self):
self.tree.copy_global_to(guild=MY_GUILD)
await self.tree.sync(guild=MY_GUILD)
client = MyClient()
@client.event
async def on_ready():
print(f'Logged in as {client.user} (ID: {client.user.id})')
print('------')
위와 같은 코드로 간단히 봇을 초기화하고 실행할 수 있다.
특히 내가 주목한 부분은 슬래시 커맨드이다.
기존 봇들은 별도의 프리픽스를 가진 채팅을 봇이 감지해 동작을 수행하는 방식이었는데,
이는 모든 명령어들을 기억하고 있거나, 일람표가 없으면 사용하기 매우 불편했다.
슬래시 커맨드 기능은 디스코드 자체에서 지원하는 기능으로, 명령어를 쉽고 빠르게 찾고 사용할 수 있게 도와준다.
이전에 discord.js를 사용할 적엔, 이 기능을 봇에 올리는 것이 py와는 비교도 안되게 불편했기 때문에 불만이 많았다.
이래저래 잘 만들어진 API Wrapper에 감사하게 된다.
번역 명령어는 아래와 같이 구현했다.
@client.tree.command()
@app_commands.describe(query='변환할 한국어 문자열')
async def kd(
interaction: discord.Interaction,
query: app_commands.Range[str, 0, 30]
):
"""한국어 문자열을 무작위 공백을 포함한 번역투 문장으로 변환"""
output = trns.getDst(query)
await interaction.response.send_message(output, ephemeral=True)
@client.tree.command()
@app_commands.describe(query='변환할 한국어 문자열')
async def kdnorm(
interaction: discord.Interaction,
query: app_commands.Range[str, 0, 30]
):
"""한국어 문자열을 추가적 공백 삽입 없는 번역투 문장으로 변환"""
output = trns.getDst(query, False)
await interaction.response.send_message(output, ephemeral=True)
discord.py 뿐만 아니라 모든 디스코드 봇에게 있어 비동기 처리는 필수다.
순서대로 한 사람씩 봇을 사용한다는 보장이 없기 때문에, discord.py는 기본적으로 비동기 처리를 지원한다.
명령어는 공백을 포함하는 결과를 리턴하는 명령어와 그렇지 않은 명령어 2개를 만들었다.
아까 라이브러리에서 getDst()의 인자인 bool insert의 기본 인자로 true를 줬기 때문에 /kd는 query만 넘겨받아도 알아서 공백을 삽입한 결과를 리턴하게 된다.
실제로 봇을 구동하고 테스트해 보니 정상적으로 결과를 출력해 준다.
/kdnorm이었다면 저기에 랜덤 공백이 다 빠진 상태로 출력됐을 것이다.
사실 이거만 하면 너무 밋밋하기 때문에 예전에 discord.js로 개발하려고 했던 기능도 추가했다.
이러니 그냥 뚝딱 만들어 넣은 것 같지만 Document도 제대로 갱신되어 있지 않고 검색해도 거의 구버전 API에 대한 질문이라 영 참고가 되지 않았다.
그래도 최신 내용이 반영되어 있긴 하니 최대한 Document를 참고하면서 진행했다. 깃헙에 올라온 질문도 적극 활용했다.
내가 만드려고 한 기능은 "줄 서기"인데, 이는 디시인사이드를 봐 온 사람이라면 다 알 것이다.
A라는 사람이 갤러리에 "싸이버거 세트 1개"를 걸고 추첨을 하겠다고 선언하는 행위를 "줄을 세운다"라고 한다.
그러면 B ~ ・・・의 사람들이 그 게시글에 "줄"이라고 댓글을 남기면 추첨에 참가를 하게 되는 것이고 이를 "줄을 선다"라고 한다. 친구들끼리 자주 하기 때문에 봇으로 편하게 만들어 보고자 했다.
동작 순서는 이렇다.
- 명령어 입력
- 봇이 스레드를 생성하고 그 스레드에 임베드와 뷰 입력
- 유저들이 스레드로 가서 참가
- 마감되면 봇이 추첨하고 임베드를 수정하고 뷰 제거
@client.tree.command()
@app_commands.describe(prize='품목', hour='시간', min='분')
async def line(interaction: discord.Interaction, prize: str, hour: int, min: int):
"""줄을 세운다...!"""
# Get deadline from user
total_time = 3600 * hour + 60 * min
ts = int(time.time()) + total_time
# Create countdown Task
task = asyncio.create_task(checkOver(ts))
line이라는 명령어를 추가한다. 인자는 추첨할 "품목", 추첨까지 남은 "시간"과 "분"을 받는다.
시간을 타임스탬프 형태로 환산해서 타이머 함수에 인자로 넘겨주고 task를 생성했다.
# Initial Embed
embed = discord.Embed(title='"줄"', timestamp=datetime.datetime.now(), colour=discord.Colour.random())
embed.add_field(name='상품', value=prize, inline=True)
embed.add_field(name='마감까지의 시간', value=f"<t:{ts}:R>", inline=False)
embed.add_field(name='줄 세운 사람', value=f'<@{interaction.user.id}>')
# Create Thread under channel where command input
thrd = await interaction.channel.create_thread(name=interaction.user.display_name + '의 ' + prize + '줄', reason='"줄"', type=discord.ChannelType.public_thread)
thrdMsg = await thrd.send(embed=embed, view=view)
# Feedback to user
await interaction.response.send_message('줄 생성 완료! 생성된 스레드를 확인하세요.')
임베드를 만들고 스레드를 생성한 후 임베드와 뷰를 스레드에 보낸다. 그리고 명령어를 입력한 채널에 모두가 볼 수 있게 ehpemeral = False로 피드백한다.
# Button class
class Entry(discord.ui.View):
def __init__(self, ebdInteraction):
super().__init__()
self.value = None
self.ebdIntr = ebdInteraction
self.entryList = []
버튼을 갖고 있을 View는 Class 형태로 구현한다.
# Entry a draw
@discord.ui.button(label="줄 서기", style=discord.ButtonStyle.green, custom_id='entry')
async def doEntry(self, btnInteraction: discord.Interaction, button: discord.ui.Button):
if(self.ebdIntr.user.id != btnInteraction.user.id):
# Append user info to entryList in Tuple
if((btnInteraction.user.display_name, btnInteraction.user.id) not in self.entryList):
self.entryList.append((btnInteraction.user.display_name, btnInteraction.user.id))
await btnInteraction.response.send_message("줄 서기 완료!", ephemeral=True)
else:
await btnInteraction.response.send_message("이미 줄 스신거 같은데...", ephemeral=True)
else:
await btnInteraction.response.send_message("줄 세운 사람이 줄 스면 어쩌나?", ephemeral=True)
# Print entryList
@discord.ui.button(label="참가자 확인", style=discord.ButtonStyle.grey, custom_id='checkEntry')
async def printEntry(self, btnInteraction: discord.Interaction, button: discord.ui.Button):
tmpList = []
if(len(self.entryList) != 0):
for tmp in self.entryList:
# Get each user's display name
tmpList.append(tmp[0])
tmpStr = '\n'.join(tmpList)
# Print users' display name
await btnInteraction.response.send_message(tmpStr, ephemeral=True)
else:
await btnInteraction.response.send_message("참가자가 없어요.", ephemeral=True)
entryList는 Enty 클래스에서 갖고 있게끔 했다. 리스트는 전역변수가 아니라 각 명령어 인스턴스마다 따로 존재해야 하기 때문에, /line 코드 상에서 view 객체를 생성하여 각 인스턴스마다 별도의 리스트를 갖게끔 했다.
참가 버튼을 클릭하면
- 줄 세운 사람인지 확인
맞다면 주최자 피드백 - 아니라면 리스트에 있는지 확인
있다면 있다고 피드백 - 없다면 참가 완료
의 순서로 작업이 진행된다. 줄 세운 사람이 참가해도 안 되겠고 이미 선 사람이 중복 참가해도 곤란하다.
리스트엔 <유저의 닉네임, 유저의 고유 아이디>의 튜플 형태로 저장된다.
여담이지만 처음엔 버튼 리스너를 어떻게 만드는지 몰라서 한참을 검색했는데 그냥 버튼을 선언하고 아래에 함수를 만들면 그게 버튼 리스너였다는 사실을 알았을 때 정말 허무했다...
"아니 뭐 이런 게 다 있나?" 싶었지만 이런 방식도 나름대로 좋은 것 같기도 하다.
참가자 확인은
- 리스트가 비어있는지 확인
비어있으면 참가자가 없다고 피드백 - 하나라도 있다면 각 튜플에서 유저의 닉네임만 따와서 임시 리스트에 저장하고
- join을 통해 개행문자로 구분되는 문자열을 생성하고 이를 리턴
join을 쓰지 않고 바로 임시 문자열에 개행문자로 구분에서 넣을 수 있지만 괜히 join을 써보고 싶어서 넣었다.
굳이 join을 사용함으로써 코드 효율성은 감소됐다고 할 수 있지만, 이런 작은 프로그램에선 티가 안 난다.
# Async function that check the deadline is reached
async def checkOver(endTime):
while True:
if(int(time.time()) >= endTime):
break
# Check evrey minute.
await asyncio.sleep(60.0)
return True
마감 확인은 단순하다. 매 분마다 마감시간을 지났는지 확인하도록 했다.
# Wait for task ends
isTaskEnd = await task
if(isTaskEnd):
if(len(view.entryList) == 0):
# If entryList is empty, feedback to Thread and remove view from embed
embed.add_field(name='"주작 결과"', value='참가자가 없었어요.', inline=False)
await thrdMsg.edit(embed=embed, view=None)
else:
# Get winner and edit embed
winner = random.choice(view.entryList)
embed.add_field(name='"주작 결과"', value=f'<@{winner[1]}>', inline=False)
await thrdMsg.edit(embed=embed, view=None)
task가 종료되면 리턴값을 받아서 조건문으로 진행한다.
- 리스트가 비어있으면
참가자가 없다고 피드백 - 리스트가 비어있지 않다면
- 추첨 후 기존 임베드를 수정하여 피드백
이제 디스코드 상에서 확인해 보자.
슬래시커맨드가 정상적으로 출력된다. 명령어를 잘 몰라도 각 인자마다 설명이 있기 때문에 알기 쉽다.
줄을 세운 사람이 참가할 수 없게, 현재 참가자가 없다는 것도 제대로 확인하고 출력해 준다.
참가자가 있는 경우는 아래와 같다.
엄청 자주 쓸 기능도 아니고 추후의 확인을 위해 스레드 삭제는 사람이 직접 해야 한다.
남겨둬도 문제는 없을 것이다.
discord.js를 사용할 때의 설움을 푼 것 같아서 만족스럽다.
화려한 봇은 아니지만 나에게 필요한 기능들을 잘 소화해 준다.
괄호도 없고 세미콜론도 없는 파이썬은 앞으로도 정말로 사양하고 싶지만,
시대가 그걸 원하지 않는 것 같다.
다른 언어로 만들어진 라이브러리를 끌어와서 사용해 보는 것도 처음이었고, 비동기도 어색하지만 어떻게 돌아가게끔은 만들었다. 특히 이번엔 검색 결과의 부실함으로 인해 Documentation에 의지하고 적극 활용했기에, 문서를 보는 연습이 많이 된 것 같아 얻어가는 게 많은 시간이었다고 생각한다.
'Study > C++ & C#' 카테고리의 다른 글
[C++] 스마트 포인터 (0) | 2023.02.04 |
---|---|
[C++] 다중 포인터 (0) | 2023.01.27 |
[C++] 포인터 기초 (0) | 2023.01.26 |
[C#, Python] C# 라이브러리를 이용한 discord.py 봇 개발 (1/2) (0) | 2023.01.15 |
[C#] WPF로 만들어 본 한>중>한 번역기 (0) | 2022.12.31 |