디스코드 봇 만들기#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 한 줄에 이걸 다 우겨넣으면 파싱 지옥이 열려요 😅
표로 저장할 때가 됐어요.
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 파일을 계속 열게 돼서 느려집니다.
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단계 - "내가 맡을게요" 버튼 만들기
이제 핵심이에요. 스태프가 버튼을 누르면 담당자가 되는 기능입니다.
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()를 쓴 이유가 이겁니다.
배정을 푸는 버튼도 같은 요령이에요.
@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가 필요합니다.
먼저 우선순위 정의부터요.
# 키: (표시 이름, 이모지, 임베드 색, 정렬 순서)
PRIORITIES = {
"urgent": ("긴급", "🔴", 0xE74C3C, 0),
"high": ("높음", "🟠", 0xE67E22, 1),
"normal": ("보통", "🟡", 0xF1C40F, 2),
"low": ("낮음", "🟢", 0x2ECC71, 3),
}@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에 두고, 화면은 임베드로 보여주는 구조로 짠 거예요
임베드를 그리는 함수는 이렇게 생겼어요.
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단계 - /티켓목록 으로 한눈에 보기
티켓이 쌓이면 채널 목록만 봐서는 뭐가 급한지 몰라요. 스태프용 요약 명령어를 만듭니다.
@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를 정리하는 이벤트도 하나 달아둘게요.
@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)
# 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 # 첫 실행 때 자동으로 생겨요📌 실행 결과
- 봇을 켜면 콘솔에
준비 완료 — DB와 영구 View 등록 끝이 뜨고, 옆에tickets.db파일이 생겨요 - 아무 채널에서
/티켓패널을 치면 📮 문의하기 버튼이 설치됩니다 - 일반 멤버가 버튼을 누르면
티켓-이름채널이 생기고, 맨 위에 노란색(보통) 안내판이 고정돼요 - 스태프가 🙋 내가 맡을게요 를 누르면 담당자 칸이 바뀌고, 채널에 "OO님이 맡았어요" 가 뜹니다
- 다른 스태프가 같은 버튼을 누르면 본인에게만 "이미 OO님이 맡고 있어요" 가 보여요
- 드롭다운에서 🔴 긴급 을 고르면 임베드가 빨간색으로 바뀝니다
/티켓목록을 치면 긴급 → 높음 → 보통 → 낮음 순으로 정렬된 목록이 나와요

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