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

디스코드 봇 만들기#27티켓 담당자 배정·우선순위 만들기! 스태프가 여러 명이어도 안 꼬이게 (파이썬 예제)

목차10개 항목

안녕하세요! Mojangcam입니다 😎

지난 #26편에서는 버튼 하나로 1:1 상담 채널이 자동으로 생기는 티켓 시스템을 만들었어요. 그런데 이걸 실제 서버에 붙여보면 하루 만에 문제가 하나 튀어나옵니다.

"이 티켓 누가 보고 있는 거예요?"

스태프가 세 명인데 아무도 안 맡거나, 반대로 세 명이 동시에 답장하거나 둘 중 하나예요. 티켓이 열 개쯤 쌓이면 뭐부터 봐야 하는지도 모르겠고요.

오늘은 여기에 담당자 배정(Claim)과 우선순위(Priority)를 붙여서, 스태프가 여러 명이어도 안 꼬이는 구조로 바꿔볼게요. #26편에서 채널 topic에 우겨넣었던 정보도 #23편에서 배운 aiosqlite DB로 제대로 옮깁니다.

이번 편에서 배울 것

  • 티켓 정보를 채널 topic → aiosqlite 테이블로 옮기기
  • "내가 맡을게요" 버튼 — 누른 사람이 담당자로 박히고, 임베드가 즉시 갱신
  • ⭐ 두 명이 동시에 누르면? — SQL 한 줄로 막는 경합(race condition) 처리
  • 우선순위 드롭다운 — 긴급/높음/보통/낮음에 따라 임베드 색이 바뀌게
  • /티켓목록 — 안 맡은 티켓을 우선순위 순으로 한눈에 보기
  • 채널 이름을 우선순위에 맞춰 바꾸면 왜 봇이 느려지는지 (레이트 리밋 함정)

📌 준비물

  • Python 3.10 이상 (버전 확인: python --version) — python-dotenv 1.2.x가 3.10부터예요
  • discord.py 2.7.1 — 영구 View와 Select를 쓰기 때문에 2.x가 꼭 필요해요
  • aiosqlite 0.22.x — 비동기 SQLite 드라이버
  • python-dotenv 1.2.x — 토큰을 .env에서 불러오기
  • #26편에서 만든 티켓 봇 코드와 스태프 역할
터미널
pip install -U discord.py aiosqlite python-dotenv

봇 파일 옆에 .env 파일을 만들고 토큰을 넣어주세요.

텍스트
token=여기에_봇_토큰_붙여넣기

🔒 .env는 반드시 .gitignore에 넣으세요. 토큰이 깃허브에 올라가면 봇이 그대로 남의 손에 넘어갑니다. 오늘부터는 tickets.db도 같이 넣어두는 걸 추천해요 (문의 내용이 들어 있으니까요).

텍스트
.env
tickets.db
__pycache__/

📌 1단계 - 티켓 정보를 DB로 옮기기

#26편에서는 채널 topic에 ticket-owner:1234... 를 심어서 주인을 기억했어요. 정보가 하나일 땐 이게 제일 간단하죠. 그런데 오늘은 담당자·우선순위·열린 시각까지 붙습니다. topic 한 줄에 이걸 다 우겨넣으면 파싱 지옥이 열려요 😅

표로 저장할 때가 됐어요.

Python
import aiosqlite
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent
DB_PATH = BASE_DIR / "tickets.db"

CREATE_SQL = """
CREATE TABLE IF NOT EXISTS tickets (
    channel_id  INTEGER PRIMARY KEY,
    guild_id    INTEGER NOT NULL,
    owner_id    INTEGER NOT NULL,
    staff_id    INTEGER,
    priority    TEXT    NOT NULL DEFAULT 'normal',
    reason      TEXT    NOT NULL DEFAULT '',
    created_at  TEXT    NOT NULL DEFAULT (datetime('now', 'localtime')),
    closed      INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_tickets_open ON tickets (guild_id, closed);
"""

하나씩 뜯어볼게요 💡

  • channel_id INTEGER PRIMARY KEY : 티켓 = 채널 하나니까 채널 ID가 곧 기본키예요. 별도 id 컬럼을 만들 필요가 없습니다. 디스코드 ID는 18~19자리라 크지만 SQLite의 INTEGER는 8바이트(약 9경)까지 담아서 문제없어요
  • staff_id INTEGER (NOT NULL 없음) : 아무도 안 맡은 상태를 NULL로 둡니다. "미배정"을 0이나 빈 문자열로 표현하면 나중에 조건문이 지저분해져요
  • priority TEXT DEFAULT 'normal' : 표시 이름("보통") 말고 영문 키를 저장하세요. 나중에 이모지나 라벨을 바꿔도 DB는 안 건드려도 됩니다
  • datetime('now', 'localtime') : SQLite의 now는 UTC라 그냥 쓰면 9시간 어긋나요. 'localtime'을 붙여야 한국 시각으로 들어갑니다
  • closed : 티켓을 지워버리면 통계를 못 내요. 실제 삭제 대신 1로 표시(soft delete) 해두면 나중에 "이번 달 처리 건수" 같은 걸 뽑을 수 있어요
  • CREATE INDEX : /티켓목록이 매번 "이 서버에서 안 닫힌 것"을 찾으니까 그 조건에 인덱스를 걸어둡니다

커넥션은 봇이 켜질 때 한 번 열고 계속 재사용해요. 요청마다 여닫으면 SQLite 파일을 계속 열게 돼서 느려집니다.

Python
class TicketBot(commands.Bot):
    def __init__(self):
        intents = discord.Intents.default()
        super().__init__(command_prefix="!", intents=intents)
        self.db: aiosqlite.Connection | None = None

    async def setup_hook(self):
        self.db = await aiosqlite.connect(DB_PATH)
        self.db.row_factory = aiosqlite.Row      # row["컬럼명"] 으로 꺼내기
        await self.db.executescript(CREATE_SQL)
        await self.db.commit()

        self.add_view(TicketOpenView())          # 영구 View 재등록 (#26편 참고)
        self.add_view(TicketControlView())
        await self.tree.sync()

    async def close(self):
        if self.db is not None:
            await self.db.close()
        await super().close()

💡 row_factory = aiosqlite.Row 는 꼭 켜두세요. 이걸 안 하면 결과가 튜플로 와서 row[3] 처럼 숫자로 꺼내야 하는데, 컬럼 하나만 추가해도 코드가 통째로 어긋납니다.

close() 오버라이드도 중요해요. Ctrl+C로 봇을 끌 때 DB를 안 닫으면 커밋되지 않은 내용이 날아갈 수 있어요. #25편에서 잡은 로깅·종료 처리와 같은 맥락입니다.

📌 2단계 - "내가 맡을게요" 버튼 만들기

이제 핵심이에요. 스태프가 버튼을 누르면 담당자가 되는 기능입니다.

Python
STAFF_ROLE_NAME = "스태프"

def is_staff(member: discord.Member) -> bool:
    return discord.utils.get(member.roles, name=STAFF_ROLE_NAME) is not None

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

    @discord.ui.button(
        label="내가 맡을게요",
        style=discord.ButtonStyle.primary,
        emoji="🙋",
        custom_id="ticket:claim",
        row=1,
    )
    async def claim(self, interaction: discord.Interaction, button: discord.ui.Button):
        if not is_staff(interaction.user):
            await interaction.response.send_message(
                "이 버튼은 스태프만 누를 수 있어요.", ephemeral=True
            )
            return

        # ⭐ 핵심: "아직 담당자가 없을 때만" 업데이트
        cursor = await bot.db.execute(
            "UPDATE tickets SET staff_id = ? WHERE channel_id = ? AND staff_id IS NULL",
            (interaction.user.id, interaction.channel_id),
        )
        await bot.db.commit()

        if cursor.rowcount == 0:
            row = await fetch_ticket(interaction.channel_id)
            if row is None:
                await interaction.response.send_message(
                    "DB에 없는 티켓이에요. 채널을 다시 만들어주세요.", ephemeral=True
                )
            else:
                await interaction.response.send_message(
                    f"이미 <@{row['staff_id']}> 님이 맡고 있어요!", ephemeral=True
                )
            return

        row = await fetch_ticket(interaction.channel_id)
        await interaction.response.edit_message(
            embed=build_ticket_embed(row, interaction.guild), view=self
        )
        await interaction.followup.send(
            f"🙋 {interaction.user.mention} 님이 이 티켓을 맡았어요!"
        )

하나씩 뜯어볼게요 💡

  • custom_id="ticket:claim" : 영구 View의 필수 조건이에요. 봇을 재시작해도 이 ID로 버튼을 다시 찾아냅니다. #26편처럼 기능:동작 꼴로 통일해두면 나중에 안 헷갈려요
  • row=1 : 3단계에서 만들 드롭다운을 row=0에 놓을 거라, 버튼은 아래 줄로 내립니다. View는 0~4번, 총 5줄까지 쓸 수 있어요
  • ⭐ WHERE ... AND staff_id IS NULL : 오늘 글에서 제일 중요한 한 줄이에요. "먼저 조회해서 비어 있으면 → 업데이트"로 짜면, 두 스태프가 같은 순간에 누를 때 둘 다 조회에서 NULL을 보고 둘 다 덮어씁니다. 조건을 UPDATE 문 자체에 넣으면 먼저 도착한 하나만 성공해요
  • cursor.rowcount : 실제로 바뀐 행 수예요. 0이면 "이미 누가 맡았다"는 뜻이죠. 이 값으로 분기하면 조회를 한 번 아낄 수 있어요
  • ephemeral=True : 거절 메시지는 누른 사람에게만 보여요. 채널이 "권한 없어요" 안내로 도배되지 않습니다
  • interaction.response.edit_message() : 새 메시지를 보내는 게 아니라 버튼이 달린 그 임베드를 그 자리에서 고쳐요. 티켓 채널 맨 위 안내판이 항상 최신 상태로 유지됩니다

⚠️ 한 인터랙션에 response.xxx() 는 딱 한 번만 부를 수 있어요. 위 코드에서 edit_message 다음에 축하 메시지를 보낼 땐 interaction.followup.send() 를 쓴 이유가 이겁니다.

배정을 푸는 버튼도 같은 요령이에요.

Python
@discord.ui.button(
    label="배정 해제",
    style=discord.ButtonStyle.secondary,
    custom_id="ticket:release",
    row=1,
)
async def release(self, interaction: discord.Interaction, button: discord.ui.Button):
    row = await fetch_ticket(interaction.channel_id)
    if row is None or row["staff_id"] is None:
        await interaction.response.send_message("아직 담당자가 없어요.", ephemeral=True)
        return
    # 본인이거나 서버 관리자만 해제 가능
    if row["staff_id"] != interaction.user.id and not interaction.user.guild_permissions.manage_guild:
        await interaction.response.send_message(
            "본인이 맡은 티켓만 해제할 수 있어요.", ephemeral=True
        )
        return

    await bot.db.execute(
        "UPDATE tickets SET staff_id = NULL WHERE channel_id = ?",
        (interaction.channel_id,),
    )
    await bot.db.commit()
    row = await fetch_ticket(interaction.channel_id)
    await interaction.response.edit_message(
        embed=build_ticket_embed(row, interaction.guild), view=self
    )

📌 3단계 - 우선순위 드롭다운 만들기

#6편에서 배운 드롭다운(Select Menu)을 여기서 써먹어요. 다만 이번엔 영구 View 안에 들어가는 드롭다운이라 custom_id가 필요합니다.

먼저 우선순위 정의부터요.

Python
# 키: (표시 이름, 이모지, 임베드 색, 정렬 순서)
PRIORITIES = {
    "urgent": ("긴급", "🔴", 0xE74C3C, 0),
    "high":   ("높음", "🟠", 0xE67E22, 1),
    "normal": ("보통", "🟡", 0xF1C40F, 2),
    "low":    ("낮음", "🟢", 0x2ECC71, 3),
}
Python
@discord.ui.select(
    custom_id="ticket:priority",
    placeholder="우선순위를 골라주세요",
    min_values=1,
    max_values=1,
    row=0,
    options=[
        discord.SelectOption(label="긴급", value="urgent", emoji="🔴", description="지금 바로 봐야 해요"),
        discord.SelectOption(label="높음", value="high", emoji="🟠", description="오늘 안에 처리"),
        discord.SelectOption(label="보통", value="normal", emoji="🟡", description="기본값"),
        discord.SelectOption(label="낮음", value="low", emoji="🟢", description="천천히 봐도 되는 문의"),
    ],
)
async def change_priority(self, interaction: discord.Interaction, select: discord.ui.Select):
    if not is_staff(interaction.user):
        await interaction.response.send_message(
            "우선순위는 스태프만 바꿀 수 있어요.", ephemeral=True
        )
        return

    choice = select.values[0]
    await bot.db.execute(
        "UPDATE tickets SET priority = ? WHERE channel_id = ?",
        (choice, interaction.channel_id),
    )
    await bot.db.commit()

    row = await fetch_ticket(interaction.channel_id)
    await interaction.response.edit_message(
        embed=build_ticket_embed(row, interaction.guild), view=self
    )

하나씩 뜯어볼게요 💡

  • select.values 는 항상 리스트예요. max_values=1이라도 select.values[0]으로 꺼내야 합니다. 여기서 select.values를 그대로 DB에 넣으려다 막히는 분이 정말 많아요
  • value에 영문 키를 넣고 label에 한글을 넣었어요. 사용자에게 보이는 건 label, DB에 저장되는 건 value입니다
  • description : 드롭다운 항목 아래 작은 설명이에요. 스태프끼리 "긴급"의 기준을 맞추는 데 은근히 큰 역할을 합니다
  • row=0 : 드롭다운은 한 줄을 통째로 차지해요. 버튼과 같은 줄에 못 넣습니다
  • ⚠️ 드롭다운은 선택 상태를 기억하지 않아요. 영구 View는 봇이 재시작하면 "뭘 골랐었는지"를 잊어버려요. 그래서 진짜 상태는 DB에 두고, 화면은 임베드로 보여주는 구조로 짠 거예요

임베드를 그리는 함수는 이렇게 생겼어요.

Python
def build_ticket_embed(row, guild: discord.Guild) -> discord.Embed:
    label, emoji, color, _ = PRIORITIES[row["priority"]]
    staff_id = row["staff_id"]

    embed = discord.Embed(
        title=f"{emoji} 문의 티켓",
        description=row["reason"] or "(문의 내용이 없어요)",
        color=color,
    )
    embed.add_field(name="문의자", value=f"<@{row['owner_id']}>")
    embed.add_field(
        name="담당자",
        value=f"<@{staff_id}>" if staff_id else "아직 없어요 🙋",
    )
    embed.add_field(name="우선순위", value=f"{emoji} {label}")
    embed.set_footer(text=f"열린 시각 · {row['created_at']}")
    return embed

💡 guild.get_member() 대신 <@아이디> 멘션 문자열을 썼어요. 멤버 캐시에 없어도(재시작 직후에 흔해요) 디스코드가 알아서 이름을 그려줍니다. 이러면 members 인텐트를 안 켜도 돼요.

📌 4단계 - /티켓목록 으로 한눈에 보기

티켓이 쌓이면 채널 목록만 봐서는 뭐가 급한지 몰라요. 스태프용 요약 명령어를 만듭니다.

Python
@bot.tree.command(name="티켓목록", description="열려 있는 티켓을 우선순위 순으로 보여줘요")
async def ticket_list(interaction: discord.Interaction):
    if not is_staff(interaction.user):
        await interaction.response.send_message("스태프만 쓸 수 있어요.", ephemeral=True)
        return

    async with bot.db.execute(
        """
        SELECT * FROM tickets
        WHERE guild_id = ? AND closed = 0
        ORDER BY CASE priority
                     WHEN 'urgent' THEN 0
                     WHEN 'high'   THEN 1
                     WHEN 'normal' THEN 2
                     ELSE 3
                 END,
                 created_at
        LIMIT 20
        """,
        (interaction.guild_id,),
    ) as cursor:
        rows = await cursor.fetchall()

    if not rows:
        await interaction.response.send_message("열려 있는 티켓이 없어요! 🎉", ephemeral=True)
        return

    embed = discord.Embed(title="📋 열려 있는 티켓", color=0x5865F2)
    unassigned = 0
    for row in rows:
        label, emoji, _, _ = PRIORITIES[row["priority"]]
        staff = f"<@{row['staff_id']}>" if row["staff_id"] else "**미배정**"
        if row["staff_id"] is None:
            unassigned += 1
        embed.add_field(
            name=f"{emoji} {label}",
            value=f"<#{row['channel_id']}> · 담당 {staff} · {row['created_at'][5:16]}",
            inline=False,
        )
    embed.set_footer(text=f"전체 {len(rows)}건 · 미배정 {unassigned}건")
    await interaction.response.send_message(embed=embed, ephemeral=True)

하나씩 뜯어볼게요 💡

  • ORDER BY CASE priority ... : 우선순위를 문자열로 저장했으니 그냥 정렬하면 high → low → normal → urgent 처럼 알파벳 순으로 나와요 😅 CASE로 순서를 직접 매겨줍니다. 파이썬에서 sorted() 로 해도 되지만, DB가 정렬하게 두는 게 훨씬 빨라요
  • LIMIT 20 : ⚠️ 임베드는 필드를 25개까지만 넣을 수 있어요. 이 제한을 넘기면 400 Bad Request가 납니다. 넉넉하게 20으로 잘랐어요
  • <#채널ID> : 채널 멘션 문법이에요. 디스코드에서 클릭하면 그 채널로 바로 이동합니다
  • row['created_at'][5:16] : 2026-09-06 14:30:11 에서 09-06 14:30 만 잘라 쓴 거예요. 목록에선 연도·초까지 필요 없죠
  • ephemeral=True : 목록은 나만 보면 되니까요. 채널이 지저분해지지 않아요

채널이 사람 손으로 지워지는 경우도 있으니, DB를 정리하는 이벤트도 하나 달아둘게요.

Python
@bot.event
async def on_guild_channel_delete(channel: discord.abc.GuildChannel):
    await bot.db.execute(
        "UPDATE tickets SET closed = 1 WHERE channel_id = ?", (channel.id,)
    )
    await bot.db.commit()

📌 전체 코드 (복붙 OK)

Python
# ticket_bot.py - 디스코드 봇 만들기 #27
# discord.py 2.7.1 / aiosqlite 0.22.x / python-dotenv 1.x / Python 3.10+

import datetime
import os
from pathlib import Path

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

BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".env")
token = os.getenv("token")

DB_PATH = BASE_DIR / "tickets.db"
TICKET_CATEGORY_NAME = "📮 문의 티켓"
STAFF_ROLE_NAME = "스태프"

# 키: (표시 이름, 이모지, 임베드 색, 정렬 순서)
PRIORITIES = {
    "urgent": ("긴급", "🔴", 0xE74C3C, 0),
    "high":   ("높음", "🟠", 0xE67E22, 1),
    "normal": ("보통", "🟡", 0xF1C40F, 2),
    "low":    ("낮음", "🟢", 0x2ECC71, 3),
}

CREATE_SQL = """
CREATE TABLE IF NOT EXISTS tickets (
    channel_id  INTEGER PRIMARY KEY,
    guild_id    INTEGER NOT NULL,
    owner_id    INTEGER NOT NULL,
    staff_id    INTEGER,
    priority    TEXT    NOT NULL DEFAULT 'normal',
    reason      TEXT    NOT NULL DEFAULT '',
    created_at  TEXT    NOT NULL DEFAULT (datetime('now', 'localtime')),
    closed      INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_tickets_open ON tickets (guild_id, closed);
"""

# ─────────────────────────────── 봇 ───────────────────────────────
class TicketBot(commands.Bot):
    def __init__(self):
        intents = discord.Intents.default()
        super().__init__(command_prefix="!", intents=intents)
        self.db: aiosqlite.Connection | None = None

    async def setup_hook(self):
        self.db = await aiosqlite.connect(DB_PATH)
        self.db.row_factory = aiosqlite.Row
        await self.db.executescript(CREATE_SQL)
        await self.db.commit()

        self.add_view(TicketOpenView())
        self.add_view(TicketControlView())
        await self.tree.sync()
        print("준비 완료 — DB와 영구 View 등록 끝")

    async def close(self):
        if self.db is not None:
            await self.db.close()
        await super().close()

bot = TicketBot()

# ────────────────────────────── 도우미 ─────────────────────────────
def is_staff(member: discord.Member) -> bool:
    return discord.utils.get(member.roles, name=STAFF_ROLE_NAME) is not None

async def fetch_ticket(channel_id: int):
    async with bot.db.execute(
        "SELECT * FROM tickets WHERE channel_id = ?", (channel_id,)
    ) as cursor:
        return await cursor.fetchone()

def build_ticket_embed(row, guild: discord.Guild) -> discord.Embed:
    label, emoji, color, _ = PRIORITIES[row["priority"]]
    staff_id = row["staff_id"]

    embed = discord.Embed(
        title=f"{emoji} 문의 티켓",
        description=row["reason"] or "(문의 내용이 없어요)",
        color=color,
    )
    embed.add_field(name="문의자", value=f"<@{row['owner_id']}>")
    embed.add_field(name="담당자", value=f"<@{staff_id}>" if staff_id else "아직 없어요 🙋")
    embed.add_field(name="우선순위", value=f"{emoji} {label}")
    embed.set_footer(text=f"열린 시각 · {row['created_at']}")
    return embed

# ────────────────────── 티켓 열기 (#26편 요약본) ──────────────────────
class TicketOpenView(discord.ui.View):
    def __init__(self):
        super().__init__(timeout=None)

    @discord.ui.button(
        label="문의하기", style=discord.ButtonStyle.success,
        emoji="📮", custom_id="ticket:open",
    )
    async def open_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
        guild = interaction.guild
        async with bot.db.execute(
            "SELECT channel_id FROM tickets WHERE guild_id = ? AND owner_id = ? AND closed = 0",
            (guild.id, interaction.user.id),
        ) as cursor:
            exists = await cursor.fetchone()
        if exists:
            await interaction.response.send_message(
                f"이미 열려 있는 티켓이 있어요! <#{exists['channel_id']}>", ephemeral=True
            )
            return

        await interaction.response.defer(ephemeral=True, thinking=True)

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

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

        channel = await guild.create_text_channel(
            name=f"티켓-{interaction.user.name}",
            category=category,
            overwrites=overwrites,
        )

        await bot.db.execute(
            "INSERT INTO tickets (channel_id, guild_id, owner_id, reason) VALUES (?, ?, ?, ?)",
            (channel.id, guild.id, interaction.user.id, "문의 내용을 적어주세요."),
        )
        await bot.db.commit()

        row = await fetch_ticket(channel.id)
        message = await channel.send(
            content=interaction.user.mention,
            embed=build_ticket_embed(row, guild),
            view=TicketControlView(),
        )
        await message.pin()
        await interaction.followup.send(f"티켓을 만들었어요! {channel.mention}", ephemeral=True)

# ───────────────────── 담당자·우선순위 컨트롤 패널 ─────────────────────
class TicketControlView(discord.ui.View):
    def __init__(self):
        super().__init__(timeout=None)

    @discord.ui.select(
        custom_id="ticket:priority",
        placeholder="우선순위를 골라주세요",
        min_values=1,
        max_values=1,
        row=0,
        options=[
            discord.SelectOption(label="긴급", value="urgent", emoji="🔴", description="지금 바로 봐야 해요"),
            discord.SelectOption(label="높음", value="high", emoji="🟠", description="오늘 안에 처리"),
            discord.SelectOption(label="보통", value="normal", emoji="🟡", description="기본값"),
            discord.SelectOption(label="낮음", value="low", emoji="🟢", description="천천히 봐도 되는 문의"),
        ],
    )
    async def change_priority(self, interaction: discord.Interaction, select: discord.ui.Select):
        if not is_staff(interaction.user):
            await interaction.response.send_message("우선순위는 스태프만 바꿀 수 있어요.", ephemeral=True)
            return

        await bot.db.execute(
            "UPDATE tickets SET priority = ? WHERE channel_id = ?",
            (select.values[0], interaction.channel_id),
        )
        await bot.db.commit()

        row = await fetch_ticket(interaction.channel_id)
        await interaction.response.edit_message(
            embed=build_ticket_embed(row, interaction.guild), view=self
        )

    @discord.ui.button(
        label="내가 맡을게요", style=discord.ButtonStyle.primary,
        emoji="🙋", custom_id="ticket:claim", row=1,
    )
    async def claim(self, interaction: discord.Interaction, button: discord.ui.Button):
        if not is_staff(interaction.user):
            await interaction.response.send_message("이 버튼은 스태프만 누를 수 있어요.", ephemeral=True)
            return

        cursor = await bot.db.execute(
            "UPDATE tickets SET staff_id = ? WHERE channel_id = ? AND staff_id IS NULL",
            (interaction.user.id, interaction.channel_id),
        )
        await bot.db.commit()

        if cursor.rowcount == 0:
            row = await fetch_ticket(interaction.channel_id)
            if row is None:
                await interaction.response.send_message(
                    "DB에 없는 티켓이에요. 채널을 다시 만들어주세요.", ephemeral=True
                )
            else:
                await interaction.response.send_message(
                    f"이미 <@{row['staff_id']}> 님이 맡고 있어요!", ephemeral=True
                )
            return

        row = await fetch_ticket(interaction.channel_id)
        await interaction.response.edit_message(
            embed=build_ticket_embed(row, interaction.guild), view=self
        )
        await interaction.followup.send(f"🙋 {interaction.user.mention} 님이 이 티켓을 맡았어요!")

    @discord.ui.button(
        label="배정 해제", style=discord.ButtonStyle.secondary,
        custom_id="ticket:release", row=1,
    )
    async def release(self, interaction: discord.Interaction, button: discord.ui.Button):
        row = await fetch_ticket(interaction.channel_id)
        if row is None or row["staff_id"] is None:
            await interaction.response.send_message("아직 담당자가 없어요.", ephemeral=True)
            return
        if row["staff_id"] != interaction.user.id and not interaction.user.guild_permissions.manage_guild:
            await interaction.response.send_message("본인이 맡은 티켓만 해제할 수 있어요.", ephemeral=True)
            return

        await bot.db.execute(
            "UPDATE tickets SET staff_id = NULL WHERE channel_id = ?", (interaction.channel_id,)
        )
        await bot.db.commit()

        row = await fetch_ticket(interaction.channel_id)
        await interaction.response.edit_message(
            embed=build_ticket_embed(row, interaction.guild), view=self
        )

    @discord.ui.button(
        label="티켓 닫기", style=discord.ButtonStyle.danger,
        emoji="🔒", custom_id="ticket:close", row=1,
    )
    async def close_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
        row = await fetch_ticket(interaction.channel_id)
        if row is None:
            await interaction.response.send_message("티켓 채널이 아니에요.", ephemeral=True)
            return
        if not is_staff(interaction.user) and interaction.user.id != row["owner_id"]:
            await interaction.response.send_message("닫을 권한이 없어요.", ephemeral=True)
            return

        await bot.db.execute(
            "UPDATE tickets SET closed = 1 WHERE channel_id = ?", (interaction.channel_id,)
        )
        await bot.db.commit()
        await interaction.response.send_message("티켓을 닫을게요. 5초 뒤 채널이 사라져요 🔒")
        await discord.utils.sleep_until(
            discord.utils.utcnow() + datetime.timedelta(seconds=5)
        )
        await interaction.channel.delete(reason="티켓 종료")

# ───────────────────────────── 명령어 ─────────────────────────────
@bot.tree.command(name="티켓패널", description="문의하기 버튼을 이 채널에 설치해요")
async def ticket_panel(interaction: discord.Interaction):
    if not interaction.user.guild_permissions.manage_guild:
        await interaction.response.send_message("서버 관리 권한이 필요해요.", ephemeral=True)
        return
    embed = discord.Embed(
        title="📮 문의하기",
        description="아래 버튼을 누르면 스태프와 1:1로 대화할 채널이 만들어져요.",
        color=0x5865F2,
    )
    await interaction.channel.send(embed=embed, view=TicketOpenView())
    await interaction.response.send_message("설치했어요!", ephemeral=True)

@bot.tree.command(name="티켓목록", description="열려 있는 티켓을 우선순위 순으로 보여줘요")
async def ticket_list(interaction: discord.Interaction):
    if not is_staff(interaction.user):
        await interaction.response.send_message("스태프만 쓸 수 있어요.", ephemeral=True)
        return

    async with bot.db.execute(
        """
        SELECT * FROM tickets
        WHERE guild_id = ? AND closed = 0
        ORDER BY CASE priority
                     WHEN 'urgent' THEN 0
                     WHEN 'high'   THEN 1
                     WHEN 'normal' THEN 2
                     ELSE 3
                 END,
                 created_at
        LIMIT 20
        """,
        (interaction.guild_id,),
    ) as cursor:
        rows = await cursor.fetchall()

    if not rows:
        await interaction.response.send_message("열려 있는 티켓이 없어요! 🎉", ephemeral=True)
        return

    embed = discord.Embed(title="📋 열려 있는 티켓", color=0x5865F2)
    unassigned = 0
    for row in rows:
        label, emoji, _, _ = PRIORITIES[row["priority"]]
        if row["staff_id"] is None:
            unassigned += 1
            staff = "**미배정**"
        else:
            staff = f"<@{row['staff_id']}>"
        embed.add_field(
            name=f"{emoji} {label}",
            value=f"<#{row['channel_id']}> · 담당 {staff} · {row['created_at'][5:16]}",
            inline=False,
        )
    embed.set_footer(text=f"전체 {len(rows)}건 · 미배정 {unassigned}건")
    await interaction.response.send_message(embed=embed, ephemeral=True)

@bot.event
async def on_guild_channel_delete(channel: discord.abc.GuildChannel):
    await bot.db.execute(
        "UPDATE tickets SET closed = 1 WHERE channel_id = ?", (channel.id,)
    )
    await bot.db.commit()

if __name__ == "__main__":
    if not token:
        raise SystemExit(".env 에 token 이 없어요. 확인해주세요.")
    bot.run(token)

📁 폴더는 이렇게 생겼어요.

텍스트
ticket-bot/
├─ .env            # token=...
├─ .gitignore      # .env, tickets.db
├─ ticket_bot.py
└─ tickets.db      # 첫 실행 때 자동으로 생겨요

📌 실행 결과

  1. 봇을 켜면 콘솔에 준비 완료 — DB와 영구 View 등록 끝 이 뜨고, 옆에 tickets.db 파일이 생겨요
  2. 아무 채널에서 /티켓패널 을 치면 📮 문의하기 버튼이 설치됩니다
  3. 일반 멤버가 버튼을 누르면 티켓-이름 채널이 생기고, 맨 위에 노란색(보통) 안내판이 고정돼요
  4. 스태프가 🙋 내가 맡을게요 를 누르면 담당자 칸이 바뀌고, 채널에 "OO님이 맡았어요" 가 뜹니다
  5. 다른 스태프가 같은 버튼을 누르면 본인에게만 "이미 OO님이 맡고 있어요" 가 보여요
  6. 드롭다운에서 🔴 긴급 을 고르면 임베드가 빨간색으로 바뀝니다
  7. /티켓목록 을 치면 긴급 → 높음 → 보통 → 낮음 순으로 정렬된 목록이 나와요

📌 자주 나는 에러

1. AttributeError: 'NoneType' object has no attribute 'execute'

  • 원인 : bot.db 가 아직 None 이에요. setup_hook() 보다 먼저 DB를 쓰려고 했거나, setup_hook 안에서 예외가 나서 커넥션이 안 만들어진 거예요
  • 해결 : setup_hook() 안의 executescript 에서 SQL 오타가 없는지 확인하세요. __init__ 에서는 await 를 못 쓰니 DB 연결은 반드시 setup_hook() 에서 해야 합니다

2. sqlite3.OperationalError: no such column: staff_id

  • 원인 : ⭐ 1등 함정. CREATE TABLE IF NOT EXISTS 는 테이블이 이미 있으면 아무것도 안 해요. #26편에서 만든 옛 테이블이 그대로 남아 있는 거예요
  • 해결 : 개발 중이면 tickets.db 를 지우고 다시 켜세요. 이미 운영 중이라면 컬럼을 추가합니다
SQL
ALTER TABLE tickets ADD COLUMN staff_id INTEGER;
ALTER TABLE tickets ADD COLUMN priority TEXT NOT NULL DEFAULT 'normal';

3. 버튼을 눌러도 "상호작용에 실패했습니다" 만 떠요

  • 원인 : 3초 안에 응답을 못 했거나, 봇을 재시작한 뒤 add_view() 를 안 해서 버튼이 미아가 된 거예요
  • 해결 : setup_hook() 에서 self.add_view(TicketControlView()) 를 꼭 부르고, View는 timeout=None, 모든 아이템에 custom_id 가 있는지 확인하세요. 오래 걸리는 작업이면 먼저 await interaction.response.defer() 로 시간을 벌면 됩니다

4. InteractionResponded: This interaction has already been responded to

  • 원인 : 한 인터랙션에 interaction.response.xxx() 를 두 번 불렀어요
  • 해결 : 두 번째부터는 interaction.followup.send() 를 쓰세요. 위 claim 코드가 정확히 그 패턴이에요

5. 우선순위를 바꿔도 채널 이름은 그대로예요 / 채널 이름 변경이 엄청 느려요

  • 원인 : channel.edit(name=...) 은 디스코드에서 10분에 2번으로 제한돼요. 그 이상 호출하면 discord.py가 알아서 기다리느라 봇이 멈춘 것처럼 보입니다
  • 해결 : 우선순위 표시는 채널 이름 대신 임베드 색과 /티켓목록 으로 하세요. 오늘 코드가 채널 이름을 안 건드리는 이유가 이겁니다

6. discord.errors.HTTPException: 400 Bad Request (Invalid Form Body) — 임베드 필드

  • 원인 : 임베드 필드가 25개를 넘었어요. discord.py는 이걸 미리 막아주지 않아서, 디스코드 서버가 400으로 튕겨냅니다
  • 해결 : 쿼리에 LIMIT 20 을 넣거나 페이지를 나누세요

7. 두 명이 동시에 눌렀는데 둘 다 담당자가 됐어요

  • 원인 : SELECT 로 확인하고 UPDATE 하는 2단계로 짜면 그 사이에 다른 요청이 끼어들어요
  • 해결 : 오늘처럼 UPDATE ... WHERE staff_id IS NULL 한 문장으로 처리하고 cursor.rowcount 로 성공 여부를 판단하세요

📌 마무리

오늘은 티켓을 "누가, 얼마나 급하게" 볼지까지 봇이 관리하게 만들었어요 🎯

오늘의 요약

  • 정보가 두 개 이상이면 채널 topic 말고 aiosqlite 테이블로 옮기세요
  • 미배정은 0이 아니라 NULL 로 두는 게 조건문이 깔끔해요
  • ⭐ 동시 클릭은 UPDATE ... WHERE 조건 + cursor.rowcount 로 막습니다
  • 드롭다운은 선택을 기억하지 않아요. 진짜 상태는 DB, 화면은 임베드
  • 정렬은 파이썬 말고 SQL의 ORDER BY CASE 에게 맡기기
  • 채널 이름 변경은 10분에 2번 — 상태 표시용으로 쓰면 안 돼요

다음 편에서는 지금까지 만든 조각들을 하나의 폴더로 조립해요. #19편의 Cog 구조에 온보딩·자동응답·티켓·로그를 전부 얹어서, 실제 서버에 그대로 넘길 수 있는 커뮤니티 통합 관리 봇을 완성합니다. 시리즈의 마지막 편이에요. 기대해주세요! 🙌

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

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

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

연재 · 32편 중 27번째

디스코드 봇 만들기

  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 실전 기록 블로그