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

디스코드 봇 만들기#33임시 음성방 확장, 파티방 만들기! 방장 위임·강퇴·초대 전용(비공개) 방 + 전용 채팅방 자동 생성 (파이썬 예제) 🎉

한눈에 보기

#31의 임시 음성방에 파티 전용 채팅방, 방장 자동·수동 위임, 강퇴, 초대 전용(비공개) 방, 재시작해도 동작하는 버튼 패널을 얹어 파티방 봇을 완성해요. 채널 권한 덮어쓰기(set_permissions)를 켜고 지우는 방법이 핵심입니다.

  • Python 3.10+ · discord.py 2.7.1 · python-dotenv 1.x 기준 — 봇 역할에 권한 관리(Manage Roles)가 꼭 있어야 해요
  • 채팅방은 @everyone view_channel=False, 들어오면 True·나가면 overwrite=None으로 지금 파티에 있는 사람에게만 보여요
  • 강퇴는 connect=False로 먼저 막고 move_to(None), 비공개는 @everyone connect=False + 지금 있는 사람에게 connect=True
  • persistent view 3종 세트(timeout=None · 고정 custom_id · setup_hook의 add_view)로 재시작 후에도 패널 버튼이 동작해요
목차15개 항목

안녕하세요! Mojangcam입니다 😎

지난 #32에서는 지워진 메시지 내용과 누가 지웠는지까지 남기는 서버 로그(감사 로그) 채널을 만들었어요. 이번 편은 #31에서 예고했던 임시 음성방 확장편이에요. #31의 "들어오면 생기고 나가면 사라지는 음성방"에 아래 기능을 얹어서 파티방으로 키워볼게요.

  • 🎉 로비에 들어가면 음성채널 + 그 방 사람만 보이는 전용 채팅방이 같이 생겨요
  • 👑 방장 위임 — 방장이 나가면 남은 사람에게 자동으로 넘어가고, 명령어로 직접 넘길 수도 있어요
  • 👢 강퇴 — 내보내고 다시 못 들어오게 막아요
  • 🔒 초대 전용(비공개) 방 — /파티 비공개로 잠그고 /파티 초대로 부른 사람만 들어와요
  • 🎛️ 채팅방 맨 위에 버튼 패널(비공개 전환·위임·강퇴)이 붙고, 봇을 재시작해도 버튼이 살아있어요

왜 채팅방까지 따로 만들까요? 🧠 음성채널에도 내장 채팅이 있긴 한데, 그 채널을 볼 수 있는 사람이면 누구나 읽을 수 있어요. 공개 파티방이면 서버 전체가 우리 파티 채팅을 보는 셈이죠. 이번 편의 채팅방은 지금 파티에 들어와 있는 사람에게만 보였다가 나가면 사라지는 진짜 전용 공간이에요. 게다가 방장 조작 패널을 붙여둘 자리도 생기고요.

예시 화면: 로비 입장 직후 "🎉 파티방" 카테고리 아래 "Mojangcam의 파티" 음성채널과 "💬mojangcam-파티" 채팅방이 나란히 생긴 채널 목록


📌 준비물

  • Python 3.10 이상 — dict[str, dict] 표기와 최신 타입 힌트를 써요
  • discord.py 2.7.1 (2026-09-26 기준 PyPI 최신) — UserSelect는 2.1부터, 버튼 패널의 persistent view는 2.0부터예요
  • python-dotenv 1.x
  • 디스코드 개발자 포털에서 만든 봇 + 서버 초대 완료 (권한: 채널 관리, 권한 관리(Manage Roles), 멤버 이동, 멤버 음소거, 메시지 보내기, 링크 첨부)
터미널
pip install -U discord.py python-dotenv

프로젝트 폴더에 .env 파일을 만들고 토큰을 넣어주세요. 토큰을 코드에 직접 쓰면 깃허브에 올리다 유출되기 딱 좋아요 😱

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

💡 .env는 꼭 .gitignore에 추가해두세요. (echo .env >> .gitignore)

⚠️ 개발자 포털 → Bot 탭에서 Server Members Intent를 켜주세요(guild.get_member로 멤버를 찾는 데 필요해요). 음성 상태(voice_states)는 특권 인텐트가 아니라서 코드에서만 켜면 됩니다. 그리고 이번 편은 채널 권한을 계속 고치기 때문에 봇 역할에 권한 관리(Manage Roles) 가 꼭 있어야 해요. #31에서는 채널 관리만으로 됐지만, set_permissions()는 이 권한이 없으면 403이 나요.

🧩 파티방은 이렇게 생겼어요

구성 역할 누가 볼 수 있나
🔊 OO의 파티 (음성채널) 실제로 모이는 방. 비공개면 @everyone 접속 차단 공개: 모두 / 비공개: 초대받은 사람 + 이미 있던 사람
💬 💬oo-파티 (텍스트채널) 파티 전용 채팅 + 맨 위에 방장 패널 지금 음성방에 있는 사람만 (나가면 안 보임)
🎛️ 패널 메시지 🔒 비공개↔공개 / 👑 위임 / 👢 강퇴 버튼 방장만 누를 수 있음

데이터는 #31처럼 JSON 한 파일이에요. 다만 값이 방장 ID 하나에서 딕셔너리로 커졌어요.

JSON
{
  "1420000000000000001": {
    "guild": 1400000000000000000,
    "owner": 1234567890,
    "text": 1420000000000000002,
    "private": false
  }
}

📌 1단계 - 기본 세팅과 조회 함수

Python
# discord.py 2.7.1 / Python 3.10+ / python-dotenv 1.x
import os
import json
import asyncio

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

load_dotenv()
token = os.getenv("token")

LOBBY_CHANNEL_NAME = "➕ 파티방 만들기"   # 여기 들어오면 새 파티방 생성
CATEGORY_NAME = "🎉 파티방"              # 파티방(음성+채팅)이 생길 카테고리
DATA_FILE = "party_rooms.json"

intents = discord.Intents.default()
intents.voice_states = True      # 음성 입퇴장 감지
intents.members = True           # 멤버 조회(get_member)·표시 이름

# {음성채널ID(str): {"guild": 서버ID, "owner": 방장ID, "text": 채팅채널ID, "private": bool}}
rooms: dict[str, dict] = {}
lock = asyncio.Lock()

OWNER_PERMS = dict(manage_channels=True, move_members=True, mute_members=True)


def save():
    with open(DATA_FILE, "w", encoding="utf-8") as f:
        json.dump(rooms, f, ensure_ascii=False, indent=2)


def load():
    global rooms
    if os.path.exists(DATA_FILE):
        with open(DATA_FILE, encoding="utf-8") as f:
            rooms = json.load(f)


def get_room(member: discord.Member):
    """멤버가 지금 들어가 있는 파티방을 (음성채널, 정보)로 돌려줘요. 없으면 (None, None)"""
    vs = member.voice
    if vs and vs.channel and str(vs.channel.id) in rooms:
        return vs.channel, rooms[str(vs.channel.id)]
    return None, None


def room_by_text(channel_id: int):
    """채팅 채널 ID로 파티방을 찾아요 (패널 버튼용)"""
    for vc_id, info in rooms.items():
        if info["text"] == channel_id:
            return vc_id, info
    return None, None


def text_of(vc: discord.VoiceChannel):
    return vc.guild.get_channel(rooms[str(vc.id)]["text"])

하나씩 뜯어볼게요 💡

  • OWNER_PERMS : 방장에게 주는 채널 권한 세 개를 딕셔너리로 빼뒀어요. 방을 만들 때도, 방장을 넘길 때도 같은 걸 쓰니까 한 곳에서 관리하는 거예요. discord.PermissionOverwrite(**OWNER_PERMS)처럼 풀어서 넣습니다.
  • rooms의 "guild" : #31에는 없던 값이에요. 봇을 재시작했을 때 음성채널이 이미 지워져 있으면 bot.get_channel()로는 서버를 못 찾아서, 짝꿍 채팅방을 지울 수가 없거든요. 그래서 서버 ID를 같이 저장해요.
  • get_room / room_by_text : 슬래시 명령어는 "명령어 친 사람이 있는 음성방"으로, 패널 버튼은 "버튼이 눌린 채팅방"으로 파티방을 찾아요. 입구가 두 개라 조회 함수도 두 개예요.

📌 2단계 - 파티방 만들기·없애기

Python
async def create_room(member: discord.Member):
    guild = member.guild
    category = discord.utils.get(guild.categories, name=CATEGORY_NAME)
    if category is None:
        category = await guild.create_category(CATEGORY_NAME)

    vc = await guild.create_voice_channel(
        f"{member.display_name}의 파티",
        category=category,
        overwrites={member: discord.PermissionOverwrite(**OWNER_PERMS)},
        reason=f"{member} 파티방 생성",
    )
    tc = await guild.create_text_channel(
        f"💬{member.display_name}-파티",
        category=category,
        overwrites={
            guild.default_role: discord.PermissionOverwrite(view_channel=False),
            guild.me: discord.PermissionOverwrite(view_channel=True, send_messages=True),
            member: discord.PermissionOverwrite(view_channel=True),
        },
        reason=f"{member} 파티방 채팅 생성",
    )
    async with lock:
        rooms[str(vc.id)] = {"guild": guild.id, "owner": member.id, "text": tc.id, "private": False}
        save()

    try:
        await member.move_to(vc)
    except discord.HTTPException:          # 방 만드는 사이에 로비에서 나가버림
        await delete_room(guild, str(vc.id))
        return

    await tc.send(embed=panel_embed(vc, member), view=PartyPanel())


async def delete_room(guild: discord.Guild, vc_id: str):
    info = rooms.get(vc_id)
    async with lock:
        rooms.pop(vc_id, None)
        save()
    ids = [int(vc_id)] + ([info["text"]] if info else [])
    for cid in ids:
        ch = guild.get_channel(cid)
        if ch is not None:
            try:
                await ch.delete(reason="파티방 종료")
            except discord.NotFound:
                pass


def panel_embed(vc: discord.VoiceChannel, owner: discord.Member) -> discord.Embed:
    e = discord.Embed(
        title=f"🎉 {vc.name}",
        description="방장은 아래 버튼이나 `/파티` 명령어로 방을 관리할 수 있어요.\n"
                    "이 채팅방은 **지금 파티에 있는 사람만** 볼 수 있어요.",
        color=discord.Color.blurple(),
    )
    e.add_field(name="방장", value=owner.mention)
    e.add_field(name="상태", value="🔓 공개")
    e.add_field(name="명령어", value="`/파티 초대` `/파티 강퇴` `/파티 위임` `/파티 비공개` `/파티 공개`", inline=False)
    return e

하나씩 뜯어볼게요 💡

  • 채팅방 overwrites 세 줄 : @everyone은 view_channel=False로 숨기고, 방장에게만 True를 줘요. 가운데 guild.me(봇 자신)도 꼭 넣어야 해요. 안 그러면 봇이 만든 채널인데 봇이 못 보는 웃픈 상황이 생겨서 패널을 못 보내요. (봇 역할이 관리자면 없어도 되지만, 습관을 들이는 게 좋아요.)
  • 텍스트 채널 이름 : 디스코드가 텍스트 채널 이름을 소문자·공백은 하이픈으로 바꿔요. 한글과 이모지는 그대로 남아서 💬mojangcam-파티처럼 보여요.
  • move_to 실패 처리 : 채널 두 개를 만드는 데 1~2초가 걸리는데, 그 사이에 사용자가 로비에서 나가버리면 move_to가 HTTPException을 던져요(#31 자주 나는 에러 3번). 그럴 땐 방금 만든 빈 방을 바로 지워요.
  • delete_room이 두 채널을 같이 지움 : rooms에서 먼저 지우고(그래야 삭제 이벤트가 또 돌아도 무시돼요) 음성·채팅 채널을 차례로 지워요. 누가 손으로 먼저 지웠을 수도 있어서 NotFound는 그냥 넘겨요.

📌 3단계 - 방장 위임·강퇴·비공개 함수

슬래시 명령어와 버튼이 같은 일을 하니까, 실제 동작은 함수로 빼두고 둘 다 이걸 부르게 할 거예요.

Python
async def transfer_owner(vc: discord.VoiceChannel, new_owner: discord.Member):
    info = rooms[str(vc.id)]
    old = vc.guild.get_member(info["owner"])
    if old is not None:
        await vc.set_permissions(old, manage_channels=None, move_members=None, mute_members=None)
    await vc.set_permissions(new_owner, **OWNER_PERMS)
    async with lock:
        info["owner"] = new_owner.id
        save()
    tc = text_of(vc)
    if tc:
        await tc.send(f"👑 이제 {new_owner.mention}님이 방장이에요!")


async def kick_member(vc: discord.VoiceChannel, member: discord.Member, reason: str):
    await vc.set_permissions(member, connect=False, reason=reason)   # 먼저 못 들어오게 막고
    await member.move_to(None, reason=reason)                         # 음성에서 내보내기


async def set_private(vc: discord.VoiceChannel):
    for m in vc.members:                                   # 지금 있는 사람은 나갔다 와도 OK
        await vc.set_permissions(m, connect=True)
    await vc.set_permissions(vc.guild.default_role, connect=False)
    async with lock:
        rooms[str(vc.id)]["private"] = True
        save()


async def set_public(vc: discord.VoiceChannel):
    await vc.set_permissions(vc.guild.default_role, connect=None)
    async with lock:
        rooms[str(vc.id)]["private"] = False
        save()

하나씩 뜯어볼게요 💡

  • set_permissions(대상, 권한=None) : 키워드 인자로 넘기면 그 대상의 기존 덮어쓰기(overwrite)에서 그 권한만 고쳐요. 옛 방장의 관리 권한 세 개만 None(서버 기본값으로 되돌림)으로 지우고, 혹시 초대받아 받은 connect=True는 그대로 남기려는 거예요. 반대로 overwrite=None으로 넘기면 그 대상의 덮어쓰기가 통째로 삭제돼요.
  • 강퇴 순서가 중요해요 : move_to(None)이 먼저면 내보내자마자 다시 들어올 수 있어요. connect=False로 문을 잠근 뒤 내보내야 진짜 강퇴가 됩니다. move_to(None)은 "음성에서 끊기"예요.
  • set_private의 for문 : @everyone의 connect를 끄면 새로 못 들어올 뿐, 이미 있는 사람은 안 튕겨요. 그런데 잠깐 나갔다 오면 못 들어오죠. 그래서 지금 있는 사람들에게 미리 connect=True를 줘요. 다시 열 땐 connect=None으로 서버 기본값으로 되돌리는 게 포인트! True로 넣으면 서버 설정을 무시하게 돼요.

📌 4단계 - 핵심! on_voice_state_update 세 갈래

Python
class PartyBot(commands.Bot):
    async def setup_hook(self):
        self.add_view(PartyPanel())        # 재시작 후에도 패널 버튼이 살아있게 등록
        self.tree.add_command(party)
        await self.tree.sync()


bot = PartyBot(command_prefix="!", intents=intents)


@bot.event
async def on_ready():
    load()
    for vc_id in list(rooms):
        guild = bot.get_guild(rooms[vc_id]["guild"])
        vc = guild.get_channel(int(vc_id)) if guild else None
        if guild and (vc is None or len(vc.members) == 0):
            await delete_room(guild, vc_id)
    print(f"✅ 로그인: {bot.user} / 파티방 {len(rooms)}개 복구")


@bot.event
async def on_voice_state_update(member: discord.Member,
                                before: discord.VoiceState,
                                after: discord.VoiceState):
    if member.bot or before.channel == after.channel:   # 음소거 등 채널 이동이 아닌 변화는 무시
        return

    # 1) 로비 입장 → 파티방 생성
    if after.channel and after.channel.name == LOBBY_CHANNEL_NAME:
        await create_room(member)

    # 2) 파티방 입장 → 채팅방 보이게
    if after.channel and str(after.channel.id) in rooms:
        tc = text_of(after.channel)
        if tc:
            await tc.set_permissions(member, view_channel=True)

    # 3) 파티방 퇴장 → 채팅방 숨기기 / 빈 방 삭제 / 방장 자동 위임
    if before.channel and str(before.channel.id) in rooms:
        vc = before.channel
        if len(vc.members) == 0:
            await delete_room(vc.guild, str(vc.id))
            return
        tc = text_of(vc)
        if tc:
            await tc.set_permissions(member, overwrite=None)
        if rooms[str(vc.id)]["owner"] == member.id:
            await transfer_owner(vc, vc.members[0])

하나씩 뜯어볼게요 💡

  • setup_hook : commands.Bot을 상속해서 만든 메서드인데, 봇이 로그인 직후·이벤트 받기 전에 딱 한 번 실행돼요. 여기서 add_view(PartyPanel())를 하면 봇이 재시작돼도 예전에 보낸 패널의 버튼이 계속 동작해요(6단계에서 자세히). on_ready는 재접속 때마다 여러 번 불릴 수 있어서 이런 등록 작업은 setup_hook이 정석이에요.
  • before.channel == after.channel : 마이크 음소거·화면 공유만 해도 이 이벤트가 불려요. 채널이 안 바뀐 경우를 맨 위에서 걸러내면 뒤의 세 갈래가 훨씬 단순해져요.
  • 2)와 3)이 동시에 참일 수 있어요 : A 파티방에서 B 파티방으로 바로 옮기면 before는 A, after는 B예요. 그래서 elif가 아니라 독립된 if로 두 갈래를 다 돌립니다. B 채팅방은 보이게 하고, A 채팅방은 숨기는 게 맞으니까요.
  • 방장 자동 위임 : 방장이 나갔는데 사람이 남아 있으면 vc.members[0](남은 사람 중 첫 번째)에게 넘겨요. #31 보너스 팁에서 한 줄로 예고했던 그 기능이에요. 이게 없으면 방장이 나간 방은 아무도 잠그거나 강퇴할 수 없는 "주인 없는 방"이 돼요.

예시 화면: A가 나가자 채팅방에 "👑 이제 B님이 방장이에요!" 메시지가 뜬 화면

📌 5단계 - 방장 전용 검사와 /파티 명령어 그룹

#31에서는 명령어마다 if not is_owner(...)를 반복했죠. 이번엔 #12 쿨다운·권한 편에서 본 app_commands.check로 데코레이터 한 줄로 줄여볼게요.

Python
class NotInRoom(app_commands.CheckFailure):
    pass


class NotOwner(app_commands.CheckFailure):
    pass


def owner_only():
    async def predicate(interaction: discord.Interaction) -> bool:
        vc, info = get_room(interaction.user)
        if vc is None:
            raise NotInRoom()
        if info["owner"] != interaction.user.id:
            raise NotOwner()
        return True
    return app_commands.check(predicate)


party = app_commands.Group(name="파티", description="파티방 관리 (방장 전용)")


@party.command(name="초대", description="비공개 파티에 들어올 수 있게 초대해요")
@app_commands.describe(멤버="초대할 사람")
@owner_only()
async def invite(interaction: discord.Interaction, 멤버: discord.Member):
    vc, _ = get_room(interaction.user)
    await vc.set_permissions(멤버, connect=True, reason=f"{interaction.user} 초대")
    tc = text_of(vc)
    if tc:
        await tc.set_permissions(멤버, view_channel=True)
    try:
        await 멤버.send(f"🎉 **{interaction.user.display_name}**님이 파티에 초대했어요! → {vc.jump_url}")
    except discord.Forbidden:                     # DM을 막아둔 사람
        pass
    await interaction.response.send_message(f"✉️ {멤버.mention}님을 초대했어요!", ephemeral=True)


@party.command(name="강퇴", description="파티에서 내보내고 다시 못 들어오게 해요")
@app_commands.describe(멤버="내보낼 사람", 사유="감사 로그에 남을 사유")
@owner_only()
async def kick(interaction: discord.Interaction, 멤버: discord.Member, 사유: str = "사유 없음"):
    vc, _ = get_room(interaction.user)
    if 멤버 == interaction.user or 멤버 not in vc.members:
        return await interaction.response.send_message("❌ 지금 파티에 있는 다른 사람만 내보낼 수 있어요.", ephemeral=True)
    await kick_member(vc, 멤버, f"파티방 강퇴({interaction.user}): {사유}")
    await interaction.response.send_message(f"👢 {멤버.mention}님을 내보냈어요.", ephemeral=True)


@party.command(name="위임", description="방장을 다른 사람에게 넘겨요")
@app_commands.describe(멤버="새 방장 (지금 파티에 있는 사람)")
@owner_only()
async def transfer(interaction: discord.Interaction, 멤버: discord.Member):
    vc, _ = get_room(interaction.user)
    if 멤버 == interaction.user or 멤버 not in vc.members:
        return await interaction.response.send_message("❌ 지금 파티에 있는 다른 사람에게만 넘길 수 있어요.", ephemeral=True)
    await transfer_owner(vc, 멤버)
    await interaction.response.send_message(f"👑 {멤버.mention}님에게 방장을 넘겼어요.", ephemeral=True)


@party.command(name="비공개", description="초대 전용 파티로 바꿔요 (지금 있는 사람은 그대로)")
@owner_only()
async def private(interaction: discord.Interaction):
    vc, _ = get_room(interaction.user)
    await set_private(vc)
    await interaction.response.send_message("🔒 초대 전용 파티가 됐어요. `/파티 초대`로 사람을 부르세요!", ephemeral=True)


@party.command(name="공개", description="누구나 들어올 수 있는 파티로 바꿔요")
@owner_only()
async def public(interaction: discord.Interaction):
    vc, _ = get_room(interaction.user)
    await set_public(vc)
    await interaction.response.send_message("🔓 다시 공개 파티가 됐어요!", ephemeral=True)

하나씩 뜯어볼게요 💡

  • owner_only() : 검사 함수(predicate)가 False를 돌려주는 대신 직접 만든 예외를 던져요. 그러면 7단계의 tree.error에서 "방 밖이에요"와 "방장이 아니에요"를 구분해서 안내할 수 있어요. app_commands.CheckFailure를 상속해야 에러 핸들러로 곱게 넘어갑니다.
  • 멤버: discord.Member : 인자 타입을 멤버로 두면 디스코드가 사람 선택 UI를 띄워줘요. 멤버 not in vc.members로 "지금 방에 있는 사람"만 받도록 한 번 더 걸러요.
  • vc.jump_url : 초대 DM에 채널 바로가기 링크를 넣었어요. DM에서는 #채널멘션이 제대로 안 보여서 링크가 안전해요. DM을 막아둔 사람에게는 Forbidden이 나니까 조용히 넘깁니다.
  • /파티 초대는 채팅방도 미리 열어줘요 : 초대받은 사람은 음성에 들어오기 전에도 파티 채팅방이 보여서 "지금 갈게요!" 같은 얘기를 미리 할 수 있어요. (나갔다가 다시 들어올 땐 4단계 2)가 알아서 다시 열어줘요.)

📌 6단계 - 채팅방 위 버튼 패널 (재시작해도 살아있는 버튼)

명령어를 외우기 귀찮은 방장을 위해 #5 버튼과 #6 드롭다운을 조합한 패널을 붙여요. 여기서 새로 배울 건 두 가지, UserSelect와 persistent view예요.

Python
class PickMember(discord.ui.View):
    """버튼을 누르면 뜨는 '사람 고르기' 메뉴 (본인에게만 보임)"""

    def __init__(self, vc: discord.VoiceChannel, action: str):
        super().__init__(timeout=60)
        self.vc, self.action = vc, action

    @discord.ui.select(cls=discord.ui.UserSelect, placeholder="파티에 있는 사람을 골라주세요")
    async def pick(self, interaction: discord.Interaction, select: discord.ui.UserSelect):
        member = interaction.guild.get_member(select.values[0].id)
        if member is None or member == interaction.user or member not in self.vc.members:
            return await interaction.response.send_message("❌ 지금 파티에 있는 다른 사람만 고를 수 있어요.", ephemeral=True)
        if self.action == "transfer":
            await transfer_owner(self.vc, member)
            text = f"👑 {member.mention}님에게 방장을 넘겼어요."
        else:
            await kick_member(self.vc, member, f"파티방 강퇴({interaction.user}): 패널")
            text = f"👢 {member.mention}님을 내보냈어요."
        await interaction.response.edit_message(content=text, view=None)


class PartyPanel(discord.ui.View):
    """파티 채팅방 맨 위에 붙는 방장 조작 패널 — timeout=None + custom_id 로 재시작 후에도 동작"""

    def __init__(self):
        super().__init__(timeout=None)

    async def owner_room(self, interaction: discord.Interaction):
        vc_id, info = room_by_text(interaction.channel.id)
        vc = interaction.guild.get_channel(int(vc_id)) if vc_id else None
        if vc is None:
            await interaction.response.send_message("❌ 이미 끝난 파티예요.", ephemeral=True)
            return None
        if info["owner"] != interaction.user.id:
            await interaction.response.send_message("❌ 방장만 누를 수 있어요.", ephemeral=True)
            return None
        return vc

    @discord.ui.button(label="비공개 ↔ 공개", emoji="🔒", style=discord.ButtonStyle.secondary, custom_id="party:toggle")
    async def toggle(self, interaction: discord.Interaction, button: discord.ui.Button):
        vc = await self.owner_room(interaction)
        if vc is None:
            return
        if rooms[str(vc.id)]["private"]:
            await set_public(vc)
            await interaction.response.send_message("🔓 누구나 들어올 수 있는 파티가 됐어요.")
        else:
            await set_private(vc)
            await interaction.response.send_message("🔒 초대 전용 파티가 됐어요. `/파티 초대`로 사람을 부르세요!")

    @discord.ui.button(label="방장 위임", emoji="👑", style=discord.ButtonStyle.primary, custom_id="party:transfer")
    async def transfer_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
        vc = await self.owner_room(interaction)
        if vc is None:
            return
        await interaction.response.send_message("새 방장을 골라주세요.", view=PickMember(vc, "transfer"), ephemeral=True)

    @discord.ui.button(label="강퇴", emoji="👢", style=discord.ButtonStyle.danger, custom_id="party:kick")
    async def kick_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
        vc = await self.owner_room(interaction)
        if vc is None:
            return
        await interaction.response.send_message("내보낼 사람을 골라주세요.", view=PickMember(vc, "kick"), ephemeral=True)

하나씩 뜯어볼게요 💡

  • discord.ui.select(cls=discord.ui.UserSelect) : #6에서 쓴 일반 드롭다운은 항목을 우리가 채워야 했죠. UserSelect(2.1+)는 디스코드가 알아서 서버 멤버 검색창을 띄워줘요. 고른 결과는 select.values에 Member 또는 User 리스트로 들어오는데, 서버에 없는 사람이면 User라서 guild.get_member(...id)로 한 번 더 바꿔서 None이면 걸러요.
  • persistent view 3종 세트 : ① timeout=None ② 버튼마다 고정 custom_id ③ 4단계 setup_hook의 add_view(). 이 셋이 갖춰지면 봇을 껐다 켜도 옛날 패널의 버튼이 계속 동작해요. 하나라도 빠지면 재시작 뒤에 "상호작용에 실패했습니다"가 떠요. 반면 PickMember는 그때그때 새로 띄우는 메뉴라 timeout=60으로 두고 60초 지나면 사라지게 뒀어요.
  • 패널이 방을 찾는 법 : 패널 메시지는 채널 ID 같은 정보를 안 갖고 있어요. 대신 "버튼이 눌린 채팅방"이 어느 파티방의 것인지 room_by_text()로 찾아요. 그래서 custom_id를 party:toggle처럼 모든 방이 똑같이 써도 되고, 재시작 후에도 add_view 한 번으로 전부 커버됩니다.
  • edit_message(view=None) : 사람을 고르고 나면 드롭다운을 지우고 결과 문장만 남겨요. 안 지우면 같은 메뉴로 두 번 세 번 강퇴할 수 있어요.

예시 화면: 파티 채팅방 맨 위 패널 임베드와 🔒 비공개↔공개 / 👑 방장 위임 / 👢 강퇴 버튼 3개, 강퇴 버튼을 누르자 뜬 멤버 선택 드롭다운(본인에게만 보임)

📌 7단계 - 관리자 설정 명령어와 에러 처리

Python
@bot.tree.command(name="파티설정", description="[관리자] 로비 채널과 카테고리를 만들어요")
@app_commands.checks.has_permissions(administrator=True)
async def setup(interaction: discord.Interaction):
    guild = interaction.guild
    category = discord.utils.get(guild.categories, name=CATEGORY_NAME)
    if category is None:
        category = await guild.create_category(CATEGORY_NAME)
    lobby = discord.utils.get(guild.voice_channels, name=LOBBY_CHANNEL_NAME)
    if lobby is None:
        lobby = await guild.create_voice_channel(LOBBY_CHANNEL_NAME, category=category)
    await interaction.response.send_message(
        f"✅ 준비 완료! {lobby.mention}에 들어가면 파티방(음성+채팅)이 자동으로 생겨요.", ephemeral=True)


@bot.tree.error
async def on_app_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
    if isinstance(error, NotInRoom):
        msg = "❌ 파티방(임시 음성방) 안에서만 쓸 수 있어요."
    elif isinstance(error, NotOwner):
        msg = "❌ 방장만 쓸 수 있는 명령어예요."
    elif isinstance(error, app_commands.MissingPermissions):
        msg = "❌ 관리자만 쓸 수 있는 명령어예요."
    else:
        raise error
    await interaction.response.send_message(msg, ephemeral=True)


bot.run(token)
  • tree.error에서 예외 종류별 안내 : 5단계에서 만든 NotInRoom·NotOwner가 여기로 와요. 안 잡으면 사용자에겐 "상호작용에 실패했습니다"만 떠서 뭘 잘못했는지 몰라요. #25 에러 핸들링 패턴 그대로예요.
  • 모르는 에러는 raise error로 다시 던져서 콘솔에 남겨요. 조용히 삼키면 디버깅이 지옥이 돼요.

📌 전체 코드 (복붙 OK)

party_bot.py로 저장하고 python party_bot.py로 실행하세요. 1~7단계를 순서대로 합친 것과 똑같아요.

Python
# discord.py 2.7.1 / Python 3.10+ / python-dotenv 1.x
import os
import json
import asyncio

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

load_dotenv()
token = os.getenv("token")

# ── 설정 ──────────────────────────────────────────────
LOBBY_CHANNEL_NAME = "➕ 파티방 만들기"   # 여기 들어오면 새 파티방 생성
CATEGORY_NAME = "🎉 파티방"              # 파티방(음성+채팅)이 생길 카테고리
DATA_FILE = "party_rooms.json"

intents = discord.Intents.default()
intents.voice_states = True      # 음성 입퇴장 감지
intents.members = True           # 멤버 조회(get_member)·표시 이름

# {음성채널ID(str): {"guild": 서버ID, "owner": 방장ID, "text": 채팅채널ID, "private": bool}}
rooms: dict[str, dict] = {}
lock = asyncio.Lock()

OWNER_PERMS = dict(manage_channels=True, move_members=True, mute_members=True)


def save():
    with open(DATA_FILE, "w", encoding="utf-8") as f:
        json.dump(rooms, f, ensure_ascii=False, indent=2)


def load():
    global rooms
    if os.path.exists(DATA_FILE):
        with open(DATA_FILE, encoding="utf-8") as f:
            rooms = json.load(f)


def get_room(member: discord.Member):
    """멤버가 지금 들어가 있는 파티방을 (음성채널, 정보)로 돌려줘요. 없으면 (None, None)"""
    vs = member.voice
    if vs and vs.channel and str(vs.channel.id) in rooms:
        return vs.channel, rooms[str(vs.channel.id)]
    return None, None


def room_by_text(channel_id: int):
    """채팅 채널 ID로 파티방을 찾아요 (패널 버튼용)"""
    for vc_id, info in rooms.items():
        if info["text"] == channel_id:
            return vc_id, info
    return None, None


def text_of(vc: discord.VoiceChannel):
    return vc.guild.get_channel(rooms[str(vc.id)]["text"])


# ── 파티방 만들기·없애기 ────────────────────────────────
async def create_room(member: discord.Member):
    guild = member.guild
    category = discord.utils.get(guild.categories, name=CATEGORY_NAME)
    if category is None:
        category = await guild.create_category(CATEGORY_NAME)

    vc = await guild.create_voice_channel(
        f"{member.display_name}의 파티",
        category=category,
        overwrites={member: discord.PermissionOverwrite(**OWNER_PERMS)},
        reason=f"{member} 파티방 생성",
    )
    tc = await guild.create_text_channel(
        f"💬{member.display_name}-파티",
        category=category,
        overwrites={
            guild.default_role: discord.PermissionOverwrite(view_channel=False),
            guild.me: discord.PermissionOverwrite(view_channel=True, send_messages=True),
            member: discord.PermissionOverwrite(view_channel=True),
        },
        reason=f"{member} 파티방 채팅 생성",
    )
    async with lock:
        rooms[str(vc.id)] = {"guild": guild.id, "owner": member.id, "text": tc.id, "private": False}
        save()

    try:
        await member.move_to(vc)
    except discord.HTTPException:          # 방 만드는 사이에 로비에서 나가버림
        await delete_room(guild, str(vc.id))
        return

    await tc.send(embed=panel_embed(vc, member), view=PartyPanel())


async def delete_room(guild: discord.Guild, vc_id: str):
    info = rooms.get(vc_id)
    async with lock:
        rooms.pop(vc_id, None)
        save()
    ids = [int(vc_id)] + ([info["text"]] if info else [])
    for cid in ids:
        ch = guild.get_channel(cid)
        if ch is not None:
            try:
                await ch.delete(reason="파티방 종료")
            except discord.NotFound:
                pass


def panel_embed(vc: discord.VoiceChannel, owner: discord.Member) -> discord.Embed:
    e = discord.Embed(
        title=f"🎉 {vc.name}",
        description="방장은 아래 버튼이나 `/파티` 명령어로 방을 관리할 수 있어요.\n"
                    "이 채팅방은 **지금 파티에 있는 사람만** 볼 수 있어요.",
        color=discord.Color.blurple(),
    )
    e.add_field(name="방장", value=owner.mention)
    e.add_field(name="상태", value="🔓 공개")
    e.add_field(name="명령어", value="`/파티 초대` `/파티 강퇴` `/파티 위임` `/파티 비공개` `/파티 공개`", inline=False)
    return e


# ── 방장 위임·강퇴·비공개 (명령어와 버튼이 같이 쓰는 함수) ────
async def transfer_owner(vc: discord.VoiceChannel, new_owner: discord.Member):
    info = rooms[str(vc.id)]
    old = vc.guild.get_member(info["owner"])
    if old is not None:
        await vc.set_permissions(old, manage_channels=None, move_members=None, mute_members=None)
    await vc.set_permissions(new_owner, **OWNER_PERMS)
    async with lock:
        info["owner"] = new_owner.id
        save()
    tc = text_of(vc)
    if tc:
        await tc.send(f"👑 이제 {new_owner.mention}님이 방장이에요!")


async def kick_member(vc: discord.VoiceChannel, member: discord.Member, reason: str):
    await vc.set_permissions(member, connect=False, reason=reason)   # 먼저 못 들어오게 막고
    await member.move_to(None, reason=reason)                         # 음성에서 내보내기


async def set_private(vc: discord.VoiceChannel):
    for m in vc.members:                                   # 지금 있는 사람은 나갔다 와도 OK
        await vc.set_permissions(m, connect=True)
    await vc.set_permissions(vc.guild.default_role, connect=False)
    async with lock:
        rooms[str(vc.id)]["private"] = True
        save()


async def set_public(vc: discord.VoiceChannel):
    await vc.set_permissions(vc.guild.default_role, connect=None)
    async with lock:
        rooms[str(vc.id)]["private"] = False
        save()


# ── 봇 본체 + 음성 입퇴장 ─────────────────────────────────
class PartyBot(commands.Bot):
    async def setup_hook(self):
        self.add_view(PartyPanel())        # 재시작 후에도 패널 버튼이 살아있게 등록
        self.tree.add_command(party)
        await self.tree.sync()


bot = PartyBot(command_prefix="!", intents=intents)


@bot.event
async def on_ready():
    load()
    for vc_id in list(rooms):
        guild = bot.get_guild(rooms[vc_id]["guild"])
        vc = guild.get_channel(int(vc_id)) if guild else None
        if guild and (vc is None or len(vc.members) == 0):
            await delete_room(guild, vc_id)
    print(f"✅ 로그인: {bot.user} / 파티방 {len(rooms)}개 복구")


@bot.event
async def on_voice_state_update(member: discord.Member,
                                before: discord.VoiceState,
                                after: discord.VoiceState):
    if member.bot or before.channel == after.channel:   # 음소거 등 채널 이동이 아닌 변화는 무시
        return

    # 1) 로비 입장 → 파티방 생성
    if after.channel and after.channel.name == LOBBY_CHANNEL_NAME:
        await create_room(member)

    # 2) 파티방 입장 → 채팅방 보이게
    if after.channel and str(after.channel.id) in rooms:
        tc = text_of(after.channel)
        if tc:
            await tc.set_permissions(member, view_channel=True)

    # 3) 파티방 퇴장 → 채팅방 숨기기 / 빈 방 삭제 / 방장 자동 위임
    if before.channel and str(before.channel.id) in rooms:
        vc = before.channel
        if len(vc.members) == 0:
            await delete_room(vc.guild, str(vc.id))
            return
        tc = text_of(vc)
        if tc:
            await tc.set_permissions(member, overwrite=None)
        if rooms[str(vc.id)]["owner"] == member.id:
            await transfer_owner(vc, vc.members[0])


# ── 방장 전용 검사 + /파티 명령어 그룹 ────────────────────
class NotInRoom(app_commands.CheckFailure):
    pass


class NotOwner(app_commands.CheckFailure):
    pass


def owner_only():
    async def predicate(interaction: discord.Interaction) -> bool:
        vc, info = get_room(interaction.user)
        if vc is None:
            raise NotInRoom()
        if info["owner"] != interaction.user.id:
            raise NotOwner()
        return True
    return app_commands.check(predicate)


party = app_commands.Group(name="파티", description="파티방 관리 (방장 전용)")


@party.command(name="초대", description="비공개 파티에 들어올 수 있게 초대해요")
@app_commands.describe(멤버="초대할 사람")
@owner_only()
async def invite(interaction: discord.Interaction, 멤버: discord.Member):
    vc, _ = get_room(interaction.user)
    await vc.set_permissions(멤버, connect=True, reason=f"{interaction.user} 초대")
    tc = text_of(vc)
    if tc:
        await tc.set_permissions(멤버, view_channel=True)
    try:
        await 멤버.send(f"🎉 **{interaction.user.display_name}**님이 파티에 초대했어요! → {vc.jump_url}")
    except discord.Forbidden:                     # DM을 막아둔 사람
        pass
    await interaction.response.send_message(f"✉️ {멤버.mention}님을 초대했어요!", ephemeral=True)


@party.command(name="강퇴", description="파티에서 내보내고 다시 못 들어오게 해요")
@app_commands.describe(멤버="내보낼 사람", 사유="감사 로그에 남을 사유")
@owner_only()
async def kick(interaction: discord.Interaction, 멤버: discord.Member, 사유: str = "사유 없음"):
    vc, _ = get_room(interaction.user)
    if 멤버 == interaction.user or 멤버 not in vc.members:
        return await interaction.response.send_message("❌ 지금 파티에 있는 다른 사람만 내보낼 수 있어요.", ephemeral=True)
    await kick_member(vc, 멤버, f"파티방 강퇴({interaction.user}): {사유}")
    await interaction.response.send_message(f"👢 {멤버.mention}님을 내보냈어요.", ephemeral=True)


@party.command(name="위임", description="방장을 다른 사람에게 넘겨요")
@app_commands.describe(멤버="새 방장 (지금 파티에 있는 사람)")
@owner_only()
async def transfer(interaction: discord.Interaction, 멤버: discord.Member):
    vc, _ = get_room(interaction.user)
    if 멤버 == interaction.user or 멤버 not in vc.members:
        return await interaction.response.send_message("❌ 지금 파티에 있는 다른 사람에게만 넘길 수 있어요.", ephemeral=True)
    await transfer_owner(vc, 멤버)
    await interaction.response.send_message(f"👑 {멤버.mention}님에게 방장을 넘겼어요.", ephemeral=True)


@party.command(name="비공개", description="초대 전용 파티로 바꿔요 (지금 있는 사람은 그대로)")
@owner_only()
async def private(interaction: discord.Interaction):
    vc, _ = get_room(interaction.user)
    await set_private(vc)
    await interaction.response.send_message("🔒 초대 전용 파티가 됐어요. `/파티 초대`로 사람을 부르세요!", ephemeral=True)


@party.command(name="공개", description="누구나 들어올 수 있는 파티로 바꿔요")
@owner_only()
async def public(interaction: discord.Interaction):
    vc, _ = get_room(interaction.user)
    await set_public(vc)
    await interaction.response.send_message("🔓 다시 공개 파티가 됐어요!", ephemeral=True)


# ── 채팅방 위 버튼 패널 ───────────────────────────────────
class PickMember(discord.ui.View):
    """버튼을 누르면 뜨는 '사람 고르기' 메뉴 (본인에게만 보임)"""

    def __init__(self, vc: discord.VoiceChannel, action: str):
        super().__init__(timeout=60)
        self.vc, self.action = vc, action

    @discord.ui.select(cls=discord.ui.UserSelect, placeholder="파티에 있는 사람을 골라주세요")
    async def pick(self, interaction: discord.Interaction, select: discord.ui.UserSelect):
        member = interaction.guild.get_member(select.values[0].id)
        if member is None or member == interaction.user or member not in self.vc.members:
            return await interaction.response.send_message("❌ 지금 파티에 있는 다른 사람만 고를 수 있어요.", ephemeral=True)
        if self.action == "transfer":
            await transfer_owner(self.vc, member)
            text = f"👑 {member.mention}님에게 방장을 넘겼어요."
        else:
            await kick_member(self.vc, member, f"파티방 강퇴({interaction.user}): 패널")
            text = f"👢 {member.mention}님을 내보냈어요."
        await interaction.response.edit_message(content=text, view=None)


class PartyPanel(discord.ui.View):
    """파티 채팅방 맨 위에 붙는 방장 조작 패널 — timeout=None + custom_id 로 재시작 후에도 동작"""

    def __init__(self):
        super().__init__(timeout=None)

    async def owner_room(self, interaction: discord.Interaction):
        vc_id, info = room_by_text(interaction.channel.id)
        vc = interaction.guild.get_channel(int(vc_id)) if vc_id else None
        if vc is None:
            await interaction.response.send_message("❌ 이미 끝난 파티예요.", ephemeral=True)
            return None
        if info["owner"] != interaction.user.id:
            await interaction.response.send_message("❌ 방장만 누를 수 있어요.", ephemeral=True)
            return None
        return vc

    @discord.ui.button(label="비공개 ↔ 공개", emoji="🔒", style=discord.ButtonStyle.secondary, custom_id="party:toggle")
    async def toggle(self, interaction: discord.Interaction, button: discord.ui.Button):
        vc = await self.owner_room(interaction)
        if vc is None:
            return
        if rooms[str(vc.id)]["private"]:
            await set_public(vc)
            await interaction.response.send_message("🔓 누구나 들어올 수 있는 파티가 됐어요.")
        else:
            await set_private(vc)
            await interaction.response.send_message("🔒 초대 전용 파티가 됐어요. `/파티 초대`로 사람을 부르세요!")

    @discord.ui.button(label="방장 위임", emoji="👑", style=discord.ButtonStyle.primary, custom_id="party:transfer")
    async def transfer_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
        vc = await self.owner_room(interaction)
        if vc is None:
            return
        await interaction.response.send_message("새 방장을 골라주세요.", view=PickMember(vc, "transfer"), ephemeral=True)

    @discord.ui.button(label="강퇴", emoji="👢", style=discord.ButtonStyle.danger, custom_id="party:kick")
    async def kick_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
        vc = await self.owner_room(interaction)
        if vc is None:
            return
        await interaction.response.send_message("내보낼 사람을 골라주세요.", view=PickMember(vc, "kick"), ephemeral=True)


# ── 관리자용 세팅 + 에러 처리 ─────────────────────────────
@bot.tree.command(name="파티설정", description="[관리자] 로비 채널과 카테고리를 만들어요")
@app_commands.checks.has_permissions(administrator=True)
async def setup(interaction: discord.Interaction):
    guild = interaction.guild
    category = discord.utils.get(guild.categories, name=CATEGORY_NAME)
    if category is None:
        category = await guild.create_category(CATEGORY_NAME)
    lobby = discord.utils.get(guild.voice_channels, name=LOBBY_CHANNEL_NAME)
    if lobby is None:
        lobby = await guild.create_voice_channel(LOBBY_CHANNEL_NAME, category=category)
    await interaction.response.send_message(
        f"✅ 준비 완료! {lobby.mention}에 들어가면 파티방(음성+채팅)이 자동으로 생겨요.", ephemeral=True)


@bot.tree.error
async def on_app_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
    if isinstance(error, NotInRoom):
        msg = "❌ 파티방(임시 음성방) 안에서만 쓸 수 있어요."
    elif isinstance(error, NotOwner):
        msg = "❌ 방장만 쓸 수 있는 명령어예요."
    elif isinstance(error, app_commands.MissingPermissions):
        msg = "❌ 관리자만 쓸 수 있는 명령어예요."
    else:
        raise error
    await interaction.response.send_message(msg, ephemeral=True)


bot.run(token)

💡 #31의 /음성방 이름·/음성방 인원 명령어도 그대로 쓰고 싶다면, @voice.command 대신 @party.command로 데코레이터만 바꾸고 is_owner 검사를 @owner_only()로 교체해서 붙여 넣으면 돼요.

📌 실행 결과

  1. python party_bot.py 실행 → 콘솔에 ✅ 로그인: 봇이름 / 파티방 0개 복구
  2. 관리자가 /파티설정 → "🎉 파티방" 카테고리와 "➕ 파티방 만들기" 로비가 생겨요.
  3. 로비에 들어가면 "내닉네임의 파티" 음성방 + "💬내닉네임-파티" 채팅방이 같이 생기고 자동으로 이동돼요. 채팅방 맨 위엔 버튼 3개짜리 패널이 붙어 있고요.
  4. 친구가 음성방에 들어오면 그 사람 화면에도 채팅방이 스르륵 나타나고, 나가면 사라져요. 다른 멤버 눈에는 채팅방이 애초에 안 보입니다.
  5. 🔒 버튼(또는 /파티 비공개)을 누르면 @everyone 접속이 막히고, /파티 초대 멤버:@친구로 부른 사람만 들어올 수 있어요. 초대받은 사람은 DM으로 바로가기 링크를 받아요.
  6. 👢 강퇴 버튼 → 드롭다운에서 사람 선택 → 그 사람은 음성에서 튕기고 다시 못 들어와요.
  7. 방장이 나가면 채팅방에 "👑 이제 OO님이 방장이에요!"가 뜨고, 모두 나가면 음성방·채팅방이 둘 다 사라져요. 봇을 재시작해도 남아있던 패널 버튼이 그대로 동작합니다.

[이미지: 비공개로 바꾼 뒤 초대받지 않은 멤버가 음성방에 들어가려 할 때 뜨는 "이 채널에 연결할 권한이 없습니다" 안내]

💡 보너스 - 이렇게 확장해보세요

  • 방 이름에 상태 표시 : 비공개가 되면 🔒를 이름 앞에 붙이고 싶죠? 다만 채널 이름 변경은 채널당 10분에 2번으로 제한이 빡빡해서 잠금·해제를 반복하면 429가 나요. 이름 대신 패널 임베드의 "상태" 필드를 edit하는 쪽을 추천해요.
  • 파티 모집글 자동 게시 : create_room 끝에서 모집 채널에 "🎮 OO님이 파티를 열었어요 → 바로가기" 임베드를 보내면 #30 투표봇처럼 참여를 유도할 수 있어요.
  • 파티 활동 로그 : #32 로그봇의 send_log()에 강퇴·위임 이벤트를 흘려보내면 "누가 누구를 왜 강퇴했는지"가 서버 로그에 남아요. kick_member의 reason이 그대로 감사 로그에도 찍힙니다.
  • JSON → aiosqlite : 서버가 여러 개고 파티가 수십 개씩 열린다면 #23 aiosqlite 편처럼 DB로 옮기세요. rooms 딕셔너리를 읽고 쓰는 곳이 save()/load()와 몇 군데뿐이라 바꾸기 쉬워요.

📌 자주 나는 에러

  1. discord.errors.Forbidden: 403 Forbidden (error code: 50013): Missing Permissions — set_permissions에서 → 원인: 봇 역할에 권한 관리(Manage Roles) 가 없음. 채널 권한 덮어쓰기는 채널 관리가 아니라 이 권한이 필요해요 → 해결: 서버 설정 → 역할 → 봇 역할에 권한 관리 체크. 그리고 봇은 자기가 가진 권한만 남에게 줄 수 있으니 멤버 이동·음소거도 봇에게 있어야 해요.
  2. 패널을 보냈는데 채팅방이 텅 비어 있어요 / Forbidden (tc.send) → 원인: 채팅방 overwrites에 guild.me를 빼먹어서 봇 자신이 채널을 못 봄 → 해결: 2단계처럼 guild.me: PermissionOverwrite(view_channel=True, send_messages=True) 추가.
  3. 봇 재시작 후 패널 버튼을 누르면 "상호작용에 실패했습니다" → 원인: persistent view 3종 세트 중 하나가 빠짐 — timeout=None, 고정 custom_id, setup_hook의 add_view() → 해결: 셋 다 확인. on_ready에 add_view를 넣으면 재접속 때마다 중복 등록되니 setup_hook에 두세요.
  4. TypeError: ... got an unexpected keyword argument 'cls' → 원인: discord.py 2.0 이하라서 ui.select(cls=UserSelect)가 없음(2.1부터) → 해결: pip install -U discord.py로 2.7.1로 올리기.
  5. 강퇴했는데 바로 다시 들어와요 → 원인: move_to(None)을 먼저 하고 connect=False를 나중에 함, 또는 강퇴 대상이 관리자라서 채널 권한 덮어쓰기가 안 먹음 → 해결: 3단계 순서(막고 → 내보내기) 확인. 관리자는 채널 권한으로 막을 수 없는 게 디스코드 규칙이에요.
  6. AttributeError: 'User' object has no attribute 'voice' → 원인: 명령어를 DM에서 쳐서 interaction.user가 Member가 아닌 User로 들어옴 → 해결: 명령어 그룹에 @app_commands.guild_only()를 붙이거나, owner_only 안에서 interaction.guild is None이면 NotInRoom을 던지게 하기.
  7. 방장이 나갔는데 위임 메시지가 안 떠요 → 원인: 방장이 나감과 동시에 방이 비어서 delete_room으로 먼저 빠짐(정상), 또는 intents.members를 안 켜서 get_member가 None → 해결: 남은 사람이 있는데도 안 뜨면 포털 Server Members Intent와 intents.members = True를 확인.

🙌 마무리

오늘은 #31의 임시 음성방에 전용 채팅방, 방장 위임(자동+수동), 강퇴, 초대 전용 방, 재시작해도 살아있는 버튼 패널을 얹어서 진짜 쓸 만한 파티방 봇을 완성했어요. 정리하면 이래요.

  • 채팅방은 @everyone view_channel=False + 입장할 때 True, 나갈 때 overwrite=None으로 있는 사람에게만 보이게 한다
  • 강퇴는 connect=False로 막은 다음 move_to(None), 비공개는 @everyone connect=False + 지금 있는 사람에게 connect=True
  • 방장 권한은 set_permissions(대상, 권한=None)으로 그 권한만 지우고 새 방장에게 넘긴다
  • persistent view 3종 세트(timeout=None · 고정 custom_id · setup_hook의 add_view)와 UserSelect로 명령어 없이도 조작하는 패널

다음 편에서는 시리즈에서 오래 기다리셨던 Docker로 디스코드 봇 배포하기 — Dockerfile·docker compose·.env 주입·재시작 정책까지, 봇을 컨테이너에 담아 24시간 돌리는 법을 다뤄볼게요. 기대해주세요 🙌

여러분 서버 파티방엔 어떤 기능이 더 있으면 좋을까요? 댓글로 알려주시면 보너스에 추가해볼게요 😎


📚 디스코드 봇 만들기 시리즈 (최근 편)

연재 · 32편 중 32번째

디스코드 봇 만들기

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