Profile picture

[Python] 디스코드(Discord) 문의 기능 구현하기

JaehyoJJAng2024년 09월 19일

개요

서버 멤버들이 직접 문의를 남길 수 있는 티켓 기반의 문의 기능을 구현해보는 과정을 기록해보려고 한다.


이번 글에서 구현해보려는 봇의 기능은 다음과 같다.

  • 1. 고객센터 안내 메시지: "채널권한"이라는 텍스트 채널에 이용 안내 메시지를 표시하고, 사용자가 선택할 수 있는 버튼을 제공한다.
  • 2. 문의 버튼: 사용자가 버튼을 클릭하면 개인 티켓 채널이 생성된다.
  • 3. 자동 종료 타이머: 티켓 채널에서 3분간 활동(메시지)가 없으면 자동으로 채널이 삭제된다.
  • 4. 권한 설정: 티켓 채널은 해당 사용자와 관리자만 접근이 가능해야 한다.

주요 구현 사항

1. 봇 설정 및 기본 구조

bot.py를 다음과 같이 작성한다.


bot.py

import os
import discord
import sys
import asyncio
from discord.ext import commands
from dotenv import load_dotenv

# 봇의 접두사 설정 (명령어 앞에 사용할 문자열)
intents = discord.Intents.default()
intents.messages = True
intents.message_content = True  # 메시지 내용 접근 권한
bot: commands.Bot = commands.Bot(command_prefix="/", intents=intents)


# 봇 준비 이벤트
@bot.event
async def on_ready() -> None:
    activity = discord.Activity(type=discord.ActivityType.playing, name="Game")
    await bot.change_presence(status=discord.Status.online, activity=activity)
    await bot.tree.sync()  # tree 동기화


# Cog 로드 함수
async def load_cogs() -> None:
    for filename in os.listdir("./cogs"):
        if filename.endswith(".py"):
            no_ext_filename, ext = os.path.splitext(filename)
            await bot.load_extension(
                name=f"cogs.{no_ext_filename}"
            )  # 확장자 제외하고 로드


# 봇 실행
load_dotenv(dotenv_path=".env")
_TOKEN: str = os.getenv("TOKEN")


async def main() -> None:
    async with bot:
        await load_cogs()  # cog 로드
        await bot.start(token=_TOKEN)


if __name__ == "__main__":
    # 비동기 메인 실행
    asyncio.run(main=main())

티켓 생성 같은 기능 구현 로직들은 cogs 디렉토리 하위에다가 기능에 맞는 파일이름.py형식으로 파일을 만들어 진행할거다.


2. 티켓 생성 버튼 구현

cogs 디렉토리 하위에 ticket.py 이라는 파일을 생성하고 다음과 같은 코드를 작성해주자.


cogs/ticket.py

class TicketView(View):
    def __init__(self):
        super().__init__(timeout=None)
        self.add_item(Button(label="문의", style=discord.ButtonStyle.green, custom_id="create_ticket_inquiry"))

    async def create_ticket(self, interaction: discord.Interaction):
        """
        이 메소드는 사용자가 버튼을 클릭했을 때 호출되며, 새로운 티켓 채널을 생성하고 초기 설정을 수행한다..

        1. 티켓 채널의 읽기/쓰기 권한을 설정한다.
        2. 사용자가 이미 티켓을 생성한 경우 이를 방지한다.
        3. 생성된 티켓 채널에 초기 안내 메시지를 보낸다.
        """
        guild = interaction.guild
        user = interaction.user

        if user.id in ticket_channels:
            await interaction.response.send_message("이미 티켓이 열려 있습니다!", ephemeral=True)
            return

        overwrites = {
            guild.default_role: discord.PermissionOverwrite(read_messages=False),
            user: discord.PermissionOverwrite(read_messages=True, send_messages=True),
        }

        # "고객센터" 객체를 가져온다.
        category = discord.utils.get(guild.categories, name="고객센터")
        if not category: # 만약 고객센터 카테고리가 없는 경우 새로 생성한다.
            category = await guild.create_category("고객센터")

        channel = await guild.create_text_channel(name=f"ticket-{user.name}", category=category, overwrites=overwrites)
        ticket_channels[user.id] = channel.id

        await interaction.response.send_message(f"티켓이 생성되었습니다: {channel.mention}", ephemeral=True)
        await channel.send(f"{user.mention} 님의 티켓입니다. 문제를 설명해주세요. 3분 동안 활동이 없으면 자동 종료됩니다.")
        await self.start_inactivity_timer(channel, user)

    async def start_inactivity_timer(self, channel, user):
        """
        이 메소드는 생성된 티켓 채널에서 활동을 모니터링하고, 3분 동안 사용자의 메시지가 없으면 채널을 삭제하도록 한다..

        내부적으로 check 함수를 사용하여 메시지가 다음 조건을 만족하는지 확인합니다:
        1. 메시지가 해당 티켓 채널에서 작성되었는가?
        2. 메시지 작성자가 티켓을 생성한 사용자와 동일한가?
        """
        try:
            def check(m):
                # 이 함수는 특정 조건을 확인하는 데 사용됩니다.
                # 조건:
                # 1. 메시지가 티켓 채널에서 작성되었는가?
                # 2. 메시지 작성자가 티켓을 생성한 사용자와 동일한가?
                return m.channel == channel and m.author == user

            await bot.wait_for("message", check=check, timeout=180.0)
        except asyncio.TimeoutError:
            await channel.send("활동이 없어 티켓이 종료됩니다.")
            del ticket_channels[user.id]
            await channel.delete()

Button View를 생성하는 TicketView 클래스를 작성해주었다.

메소드별 동작 배경은 주석으로 처리해두었으니 참고해보면 될 것 같다.


wait_for / def check(..)


ticket.py에서 작성된 def check(m): ..는 어떤 상황에서 사용되는 걸까?


check 함수는 위 공식 문서 예제 코드에서도 나와있듯이

discord.pywait_for 메소드에서 사용된다.

이벤트 필터링 역할을 한다고 보면 될 것 같다.


check() 함수는 조건부로 이벤트 처리할 때 유용하다.

예를 들어, 특정 사용자가 특정 채널에서 메시지를 보낼 때만 작업을 수행하도록 설정할 수 있다.

@bot.event
async def on_message(message):
    if message.content == "!start":
        await message.channel.send("숫자를 입력해주세요.")

        def check(msg):
            return msg.author == message.author and msg.channel == message.channel and msg.content.isdigit()

        try:
            response = await bot.wait_for("message", check=check, timeout=30.0)
            await message.channel.send(f"입력된 숫자는 {response.content}입니다.")
        except asyncio.TimeoutError:
            await message.channel.send("시간 초과되었습니다. 다시 시도해주세요.")

해당 예제는 사용자가 !start 명령을 입력하면, 숫자 입력을 대기하고, 숫자를 입력하지 않거나 시간이 초과되면 오류 메시지를 반환한다.



마지막으로 Cog를 등록해주기 위해서 ticket.py에 다음과 같은 코드를 추가해주자.


class Ticket(commands.Cog):
    def __init__(self, bot: commands.Bot):
        super().__init__()
        self.bot = bot

    @commands.Cog.listener()
    async def on_ready(self) -> None:
        print(f"Ticket Command is running ...")
        channel_id: int = 0000
        channel = self.bot.get_channel(channel_id)
        if channel:
            embed = discord.Embed(
                title="고객센터 안내",
                description=(
                    "- 고객센터 운영 시간은 정해져 있지 않습니다\n"
                    "- 생성된 채널은 3분 동안 아무런 문의가 없는 경우 무통보 종료됩니다.\n"
                    "- 문의와 건의가 아닌 폭언과 비아냥은 무통보 차단됩니다.\n"
                ),
                color=discord.Color.blue(),
            )
            view = TicketView(bot=self.bot)
            await channel.send(embed=embed, view=view)


# Cog 등록 함수
async def setup(bot: commands.Bot):
    await bot.add_cog(Ticket(bot=bot))

Ticket Cog가 동작할 때 channel_id에 명시된 채널로 버튼이 생성될 것이다.


Loading script...