카테고리: 디스코드 봇 만들기

디스코드 봇 만들기#26티켓(Ticket) 문의 시스템 만들기! 버튼 하나로 1:1 상담 채널 자동 생성 (파이썬 예제)

목차10개 항목

안녕하세요! Mojangcam입니다 😎

지난 #25편에서는 봇이 죽지 않게 에러 핸들링과 로깅 체계를 잡아봤어요. 이제 봇이 웬만해선 안 죽으니까, 이번엔 사람 손이 필요한 일을 봇이 대신 받아주게 만들어 볼게요.

오늘 만들 건 티켓(Ticket) 문의 시스템이에요. 서버에 "문의하기" 버튼 하나를 딱 붙여두면, 누가 누르는 순간 그 사람과 스태프만 볼 수 있는 1:1 채널이 자동으로 생기는 기능이죠.

왜 필요할까요? 서버가 조금만 커져도 DM으로 문의가 쏟아지는데, DM은 다른 스태프가 볼 수 없어서 인수인계가 안 돼요. 반대로 공개 채널에 문의를 받으면 개인정보나 결제 내역 같은 게 그대로 노출되고요. 티켓 시스템은 이 둘의 중간이에요. 기록은 서버에 남고, 내용은 당사자끼리만 보이는 구조라서 판매 서버·게임 길드·커뮤니티 어디든 잘 맞습니다.

📌 준비물

  • Python 3.8 이상 — discord.py 2.7.1 기준 최소 3.8이에요 (3.11 이상 권장)
  • discord.py 2.7.1 — 작성일 기준 최신 버전 (2.x 계열이면 동작해요)
  • python-dotenv 1.2.3 — 봇 토큰을 코드에 박지 않기 위해 필수
  • 봇에게 필요한 권한 : 채널 관리(Manage Channels), 메시지 보내기, 메시지 기록 보기 (역할 관리 권한은 없어도 돼요)
터미널
pip install discord.py python-dotenv

그리고 봇 파일 옆에 .env 파일을 하나 만들어 주세요.

터미널
DISCORD_TOKEN=여기에_봇_토큰_붙여넣기

💡 .env는 꼭 .gitignore에 넣어두세요. 깃허브에 토큰이 올라가면 디스코드가 자동으로 토큰을 무효화해 버립니다(그나마 다행이긴 해요 😅).

서버에는 스태프라는 이름의 역할을 하나 만들어 두면 좋아요. 이 역할을 가진 사람만 티켓 채널을 볼 수 있게 할 거예요.

📌 1단계 - 눌러도 안 없어지는 버튼 만들기 (영구 View)

티켓 버튼은 봇을 재시작해도 계속 살아 있어야 해요. 평범한 discord.ui.View는 봇이 꺼지면 버튼이 먹통이 되거든요. 그래서 영구(persistent) View 로 만듭니다.

Python
class TicketOpenView(discord.ui.View):
    def __init__(self):
        super().__init__(timeout=None)

    @discord.ui.button(
        label="문의하기",
        style=discord.ButtonStyle.primary,
        emoji="📮",
        custom_id="ticket:open",
    )
    async def open_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
        existing = discord.utils.get(
            interaction.guild.text_channels, topic=f"ticket-owner:{interaction.user.id}"
        )
        if existing is not None:
            await interaction.response.send_message(
                f"이미 열려 있는 티켓이 있어요 → {existing.mention}", ephemeral=True
            )
            return
        await interaction.response.send_modal(TicketReasonModal())

하나씩 뜯어볼게요 💡

  • timeout=None : 이게 없으면 버튼이 180초 뒤에 죽어요. 영구 View의 첫 번째 조건입니다.
  • custom_id : 영구 View의 두 번째 조건. 버튼마다 고유한 문자열 ID를 직접 정해줘야 봇이 재시작한 뒤에도 "아, 이 버튼은 그때 그 버튼이구나" 하고 알아봅니다. ticket:open처럼 기능:동작 꼴로 지으면 나중에 헷갈리지 않아요.
  • ephemeral=True : 나한테만 보이는 답장이에요. 다른 사람 채팅창은 안 더러워집니다.
  • 채널 topic으로 중복 체크 : 채널 설명(topic)에 ticket-owner:유저ID를 심어두면, DB 없이도 "이 사람 티켓이 이미 있나?"를 한 줄로 검사할 수 있어요. 티켓 수가 많아지면 #23편의 aiosqlite 방식으로 옮기는 걸 추천합니다.

📌 2단계 - 모달(Modal)로 문의 내용 먼저 받기

버튼을 누르자마자 빈 채널을 만들면, 스태프가 들어가서 "무슨 일이세요?"부터 물어봐야 해요. 비효율이죠. #7편에서 배운 모달로 용건을 먼저 받아 채널에 박아둡시다.

Python
class TicketReasonModal(discord.ui.Modal, title="문의 내용을 적어주세요"):
    reason = discord.ui.TextInput(
        label="어떤 도움이 필요하신가요?",
        style=discord.TextStyle.paragraph,
        placeholder="예) 역할이 안 붙어요 / 구매 문의드립니다",
        max_length=500,
        required=True,
    )

    async def on_submit(self, interaction: discord.Interaction):
        await create_ticket_channel(interaction, self.reason.value)
  • TextStyle.paragraph : 여러 줄 입력창이에요. 한 줄짜리는 TextStyle.short.
  • max_length=500 : 디스코드 모달 입력값은 최대 4000자인데, 500자면 요약하기 딱 좋아요.
  • on_submit : 사용자가 "전송"을 누르면 실행돼요. 여기서 바로 채널을 만들 겁니다.
  • discord.py 2.7부터는 TextInput(label=...) 을 쓰면 DeprecationWarning: label is deprecated, use discord.ui.Label instead 경고가 뜰 수 있어요. 동작에는 문제가 없으니 그대로 쓰셔도 되고, 2.7 이상만 쓰신다면 새 discord.ui.Label 방식으로 바꾸셔도 됩니다.

📌 3단계 - 권한 덮어쓰기로 비공개 채널 만들기

티켓 시스템의 핵심이자 사람들이 제일 많이 헤매는 부분이에요. PermissionOverwrite로 채널별 권한을 따로 덮어씁니다.

Python
overwrites = {
    guild.default_role: discord.PermissionOverwrite(view_channel=False),
    guild.me: discord.PermissionOverwrite(
        view_channel=True, send_messages=True, manage_channels=True
    ),
    author: discord.PermissionOverwrite(
        view_channel=True,
        send_messages=True,
        attach_files=True,
        read_message_history=True,
    ),
}
staff_role = discord.utils.get(guild.roles, name=STAFF_ROLE_NAME)
if staff_role is not None:
    overwrites[staff_role] = discord.PermissionOverwrite(
        view_channel=True, send_messages=True, read_message_history=True
    )

channel = await guild.create_text_channel(
    name=f"티켓-{author.name}",
    category=category,
    overwrites=overwrites,
    topic=f"ticket-owner:{author.id}",
    reason=f"{author} 님의 티켓 생성",
)

하나씩 뜯어볼게요 💡

  • guild.default_role : @everyone 역할이에요. 여기에 view_channel=False를 주는 게 비공개의 시작점입니다. 이거 하나면 서버 전체가 이 채널을 못 봐요.
  • guild.me : 봇 자기 자신. 자기 권한도 꼭 열어줘야 해요. 안 그러면 채널을 만들어 놓고 봇이 못 들어가는 웃픈 상황이 생깁니다.
  • author : 티켓을 연 사람. attach_files를 켜줘야 스크린샷을 첨부할 수 있어요.
  • read_message_history : 이걸 빼먹으면 채널엔 들어가지는데 이전 대화가 안 보여요. 티켓에선 치명적이니 꼭 넣어주세요.
  • topic : 아까 말한 그 표식이에요. ticket-owner:유저ID를 심어 중복 티켓을 막습니다.
  • reason : 서버 감사 로그(Audit Log)에 남는 사유예요. 나중에 "이 채널 누가 만들었지?" 추적할 때 편합니다.

📌 4단계 - 티켓 닫기 + 대화 기록 남기기

닫기 버튼도 영구 View로 만들고, 채널을 지우기 전에 대화 내용을 텍스트 파일로 말아서 DM으로 보내줍니다. 채널을 지우면 기록도 같이 날아가니까요.

Python
async def build_transcript(channel: discord.TextChannel):
    lines = []
    async for message in channel.history(limit=500, oldest_first=True):
        stamp = message.created_at.strftime("%Y-%m-%d %H:%M:%S")
        lines.append(f"[{stamp}] {message.author}: {message.content}")
    if not lines:
        return None
    data = io.BytesIO("\n".join(lines).encode("utf-8"))
    return discord.File(data, filename=f"{channel.name}.txt")
  • channel.history(oldest_first=True) : 오래된 메시지부터 읽어와요. 기본값은 최신순이라 로그가 거꾸로 저장됩니다.
  • io.BytesIO : 디스크에 파일을 만들지 않고 메모리에서 바로 첨부파일을 만들어요. 서버에 쓰레기 파일이 안 쌓입니다.
  • encode("utf-8") : 한글이 깨지지 않게 하는 필수 처리예요.

📌 전체 코드 (복붙 OK)

Python
# ticket_bot.py
# discord.py 2.7.1 / python-dotenv 1.2.3 / Python 3.8+
import os
import io
import datetime

import discord
from discord import app_commands
from discord.ext import commands
from dotenv import load_dotenv

load_dotenv()
TOKEN = os.getenv("DISCORD_TOKEN")

TICKET_CATEGORY_NAME = "📮 문의 티켓"   # 티켓 채널이 모일 카테고리 이름
STAFF_ROLE_NAME = "스태프"              # 티켓을 볼 수 있는 담당자 역할 이름

intents = discord.Intents.default()
intents.message_content = True   # 대화 기록(transcript)을 남기려면 필요해요
bot = commands.Bot(command_prefix="!", intents=intents)

class TicketReasonModal(discord.ui.Modal, title="문의 내용을 적어주세요"):
    reason = discord.ui.TextInput(
        label="어떤 도움이 필요하신가요?",
        style=discord.TextStyle.paragraph,
        placeholder="예) 역할이 안 붙어요 / 구매 문의드립니다",
        max_length=500,
        required=True,
    )

    async def on_submit(self, interaction: discord.Interaction):
        await create_ticket_channel(interaction, self.reason.value)

class TicketOpenView(discord.ui.View):
    def __init__(self):
        super().__init__(timeout=None)

    @discord.ui.button(
        label="문의하기",
        style=discord.ButtonStyle.primary,
        emoji="📮",
        custom_id="ticket:open",
    )
    async def open_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
        existing = discord.utils.get(
            interaction.guild.text_channels, topic=f"ticket-owner:{interaction.user.id}"
        )
        if existing is not None:
            await interaction.response.send_message(
                f"이미 열려 있는 티켓이 있어요 → {existing.mention}", ephemeral=True
            )
            return
        await interaction.response.send_modal(TicketReasonModal())

class TicketControlView(discord.ui.View):
    def __init__(self):
        super().__init__(timeout=None)

    @discord.ui.button(
        label="티켓 닫기",
        style=discord.ButtonStyle.danger,
        emoji="🔒",
        custom_id="ticket:close",
    )
    async def close_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
        channel = interaction.channel
        if not (channel.topic or "").startswith("ticket-owner:"):
            await interaction.response.send_message(
                "여기는 티켓 채널이 아니에요.", ephemeral=True
            )
            return

        await interaction.response.send_message("5초 뒤에 티켓을 닫을게요. 🔒")

        log_file = await build_transcript(channel)
        if log_file is not None:
            try:
                await interaction.user.send(
                    f"`{channel.name}` 티켓 대화 기록이에요.", file=log_file
                )
            except discord.Forbidden:
                pass

        await discord.utils.sleep_until(
            discord.utils.utcnow() + datetime.timedelta(seconds=5)
        )
        await channel.delete(reason=f"티켓 종료 by {interaction.user}")

async def build_transcript(channel: discord.TextChannel):
    lines = []
    async for message in channel.history(limit=500, oldest_first=True):
        stamp = message.created_at.strftime("%Y-%m-%d %H:%M:%S")
        lines.append(f"[{stamp}] {message.author}: {message.content}")
    if not lines:
        return None
    data = io.BytesIO("\n".join(lines).encode("utf-8"))
    return discord.File(data, filename=f"{channel.name}.txt")

async def create_ticket_channel(interaction: discord.Interaction, reason: str):
    guild = interaction.guild
    author = interaction.user

    category = discord.utils.get(guild.categories, name=TICKET_CATEGORY_NAME)
    if category is None:
        category = await guild.create_category(TICKET_CATEGORY_NAME)

    overwrites = {
        guild.default_role: discord.PermissionOverwrite(view_channel=False),
        guild.me: discord.PermissionOverwrite(
            view_channel=True, send_messages=True, manage_channels=True
        ),
        author: discord.PermissionOverwrite(
            view_channel=True,
            send_messages=True,
            attach_files=True,
            read_message_history=True,
        ),
    }
    staff_role = discord.utils.get(guild.roles, name=STAFF_ROLE_NAME)
    if staff_role is not None:
        overwrites[staff_role] = discord.PermissionOverwrite(
            view_channel=True, send_messages=True, read_message_history=True
        )

    channel = await guild.create_text_channel(
        name=f"티켓-{author.name}",
        category=category,
        overwrites=overwrites,
        topic=f"ticket-owner:{author.id}",
        reason=f"{author} 님의 티켓 생성",
    )

    embed = discord.Embed(
        title="📮 새 문의가 접수됐어요",
        description=reason,
        color=discord.Color.blurple(),
        timestamp=discord.utils.utcnow(),
    )
    embed.add_field(name="작성자", value=author.mention, inline=True)
    embed.set_footer(text="답변이 끝나면 아래 '티켓 닫기'를 눌러주세요")

    mention = staff_role.mention if staff_role else ""
    await channel.send(content=f"{author.mention} {mention}", embed=embed,
                       view=TicketControlView())
    await interaction.response.send_message(
        f"티켓을 만들었어요! → {channel.mention}", ephemeral=True
    )

@bot.tree.command(name="티켓패널", description="티켓 문의 버튼 패널을 설치해요")
@app_commands.checks.has_permissions(manage_guild=True)
async def ticket_panel(interaction: discord.Interaction):
    embed = discord.Embed(
        title="📮 문의 접수",
        description="아래 버튼을 누르면 **나와 스태프만 보이는** 1:1 채널이 만들어져요.",
        color=discord.Color.blurple(),
    )
    await interaction.channel.send(embed=embed, view=TicketOpenView())
    await interaction.response.send_message("패널을 설치했어요!", ephemeral=True)

@bot.event
async def setup_hook():
    bot.add_view(TicketOpenView())      # 영구 View 등록
    bot.add_view(TicketControlView())
    await bot.tree.sync()

@bot.event
async def on_ready():
    print(f"로그인 완료: {bot.user}")

if __name__ == "__main__":
    if not TOKEN:
        raise SystemExit("❌ .env 파일에 DISCORD_TOKEN이 없어요!")
    bot.run(TOKEN)

📌 실행 결과

봇을 켜고, 문의 패널을 놓고 싶은 채널에서 /티켓패널 을 실행해 보세요.

  1. 안내 임베드와 📮 문의하기 버튼이 채널에 설치돼요.
  2. 아무나 버튼을 누르면 "문의 내용을 적어주세요" 모달이 뜹니다.
  3. 제출하는 순간 📮 문의 티켓 카테고리 아래에 티켓-닉네임 채널이 생기고, 본인과 스태프만 보여요.
  4. 채널 안에는 문의 내용이 임베드로 정리돼 있고 🔒 티켓 닫기 버튼이 붙어 있습니다.
  5. 닫기를 누르면 대화 기록 .txt가 DM으로 날아오고, 5초 뒤 채널이 삭제돼요.

봇을 껐다 켜도 버튼이 그대로 동작하는지 꼭 확인해 보세요. 그게 영구 View가 제대로 붙었다는 증거입니다.

📌 자주 나는 에러

1. discord.errors.Forbidden: 403 Forbidden (error code: 50013): Missing Permissions

  • 원인 : 봇에게 채널 관리(Manage Channels) 권한이 없어요. 또는 봇 역할이 역할 목록에서 너무 아래에 있습니다.
  • 해결 : 서버 설정 → 역할에서 봇 역할에 채널 관리 를 켜고, 봇 역할을 스태프 역할보다 위로 끌어올리세요.
  • 어디서 터지나 : 서버에 📮 문의 티켓 카테고리가 없으면 create_category() 부터 막혀요. 트레이스백 마지막 줄이 guild.py ... in create_category 라면 채널이 아니라 카테고리를 만들 권한이 없는 겁니다. 카테고리·채널 생성 둘 다 채널 관리 하나로 해결돼요.

2. 봇을 재시작하면 버튼을 눌러도 아무 반응이 없어요

  • 원인 : 영구 View 등록이 빠졌어요. timeout=None + custom_id + bot.add_view() 세 가지가 모두 있어야 합니다.
  • 해결 : setup_hook() 안에서 bot.add_view(TicketOpenView()) 를 호출했는지 확인하세요. on_ready()에 넣으면 재연결 때마다 중복 등록될 수 있어 setup_hook()이 더 안전해요.

3. AttributeError: 'str' object has no attribute 'id'

  • 원인 : overwrites 딕셔너리의 키에 역할/멤버 객체가 아닌 문자열(이름)을 넣었을 때 생겨요. discord.py는 키에서 .id 를 꺼내 쓰거든요.
  • 해결 : 키는 반드시 discord.Role 또는 discord.Member 객체여야 합니다. 이름으로 찾을 땐 discord.utils.get(guild.roles, name="스태프") 로 객체를 먼저 가져오세요. 참고로 값에 Permissions 를 잘못 넣으면 TypeError: Expected PermissionOverwrite received Permissions 가 납니다.

4. AttributeError: 'NoneType' object has no attribute 'mention'

  • 원인 : 스태프 역할이 서버에 없는데 staff_role.mention 을 호출했어요.
  • 해결 : 위 전체 코드처럼 if staff_role is not None: 으로 감싸거나, 서버에 역할 이름을 정확히 스태프 로 만들어 주세요. (띄어쓰기·대소문자까지 똑같아야 해요!)

📌 마무리

오늘은 버튼 + 모달 + 권한 덮어쓰기를 조합해서 실전용 티켓 문의 시스템을 만들어 봤어요. 정리하면 이렇습니다 💬

  • 영구 View = timeout=None + custom_id + bot.add_view()
  • 비공개 채널 = @everyone을 view_channel=False로 막고, 필요한 사람만 열어주기
  • 채널 topic 을 표식으로 쓰면 DB 없이도 중복 티켓을 막을 수 있어요
  • 채널을 지우기 전엔 대화 기록을 꼭 백업하기

다음 편에서는 티켓에 담당자 배정과 우선순위를 붙여서, 스태프가 여러 명일 때도 안 꼬이게 만들어 볼게요. 기대해주세요 🙌

궁금한 점이나 막히는 부분은 댓글로 남겨주세요. 같이 풀어봐요!

📚 디스코드 봇 만들기 시리즈

전체 목록은 시리즈 총정리 로드맵 에서 한눈에 보실 수 있어요.

연재 · 32편 중 26번째

디스코드 봇 만들기

  1. #1디스코드 봇 만들기 - Slash Command 완전 정복!
  2. #2Slash Command에 옵션(Arguments) 추가하기!
  3. #3Slash Command에 서브 커맨드(Subcommand) 넣기!
  4. #4Slash Command로 임베드 전송하기!
  5. #5버튼(Button) 상호작용 구현하기!
  6. #6드롭다운(Select Menu) 메뉴 구현하기!
  7. #7모달(Modal)로 입력 받기 구현하기 (파이썬 예제)
  8. #8임베드(Embed) 이미지·필드로 꾸미기 (파이썬 예제)
  9. #9AI(Claude·ChatGPT API) 연동하기! (파이썬 예제)
  10. #10다국어(i18n) 지원하기! (파이썬 예제)
  11. #11플라스크(Flask)로 웹 대시보드 만들기! (파이썬 예제)
  12. #12명령어 쿨다운(Cooldown)·권한(Permission) 관리하기! (파이썬 예제)
  13. #13역할·채널 자동화로 서버 관리하기! (파이썬 예제)
  14. #14데이터 저장하기! JSON vs SQLite (파이썬 예제)
  15. #15슬래시 명령어 그룹(Group)으로 명령어 정리하기! (파이썬 예제)
  16. #16리마인더·알림 기능 만들기! 봇이 먼저 말 걸게 하기 (파이썬 예제)
  17. #17RAG로 문서 기반 답변봇 만들기! 우리 서버 전용 FAQ 봇 (파이썬 예제)
  18. #18투표(Poll) 기능 만들기! 다수결로 정하는 서버 규칙 (파이썬 예제)
  19. #19Cog로 코드 쪼개기! 1000줄 봇도 안 더러워지는 구조 (파이썬 예제)
  20. #20자동 환영 메시지·온보딩 만들기! 규칙 동의 버튼으로 자동 역할 부여 (파이썬 예제)
  21. #21키워드 자동응답·금지어 필터 만들기! 경고 누적으로 자동 제재까지 (파이썬 예제)
  22. #22슬래시 명령어 자동완성(Autocomplete) 만들기! 금지어를 골라서 추가·삭제 (파이썬 예제)
  23. #23aiosqlite로 비동기 DB 제대로 설계하기! JSON 탈출 (파이썬 예제)
  24. #24채널 난독화(Channel Obfuscation) 대응하기! 11/16부터 안 보이는 채널이 생겨요 (파이썬 예제)
  25. #25봇이 죽지 않게! 에러 핸들링·로깅 체계 잡기 (파이썬 예제)
  26. #26티켓(Ticket) 문의 시스템 만들기! 버튼 하나로 1:1 상담 채널 자동 생성 (파이썬 예제)지금 읽는 글
  27. #27티켓 담당자 배정·우선순위 만들기! 스태프가 여러 명이어도 안 꼬이게 (파이썬 예제)
  28. #28레벨·경험치(XP) 시스템 만들기! 채팅할수록 레벨업, 랭킹판까지 (파이썬 예제)
  29. #29화폐·상점(Economy) 시스템 만들기! 코인 벌어서 아이템 사기 (파이썬 예제)
  30. #30투표·설문(Poll) 봇 만들기! 네이티브 투표 + 버튼으로 실시간 집계까지 (파이썬 예제) 🗳️
  31. #32서버 로그·감사(Audit Log) 채널 만들기! 메시지 삭제·수정·입퇴장·추방까지 자동 기록 (파이썬 예제) 🕵️
  32. #33임시 음성방 확장, 파티방 만들기! 방장 위임·강퇴·초대 전용(비공개) 방 + 전용 채팅방 자동 생성 (파이썬 예제) 🎉
전체 32편 보기접기

글쓴이

Mojangcam

직접 코드를 짜고, 서버를 돌리고, 막힌 에러를 해결하며 배운 것들을 기록하는 개발·IT 실전 기록 블로그