로딩 중이에요... 🐣
03 Discord bot | ✅ 저자: 이유정(박사)
from dotenv import load_dotenv
# .env 파일에 정의한 환경변수를 불러오는 함수
load_dotenv()
dotenv
라이브러리를 써서 프로젝트 최상단에 있는.env
파일을 읽고,- 그 안에 저장된
DISCORD_TOKEN
같은 환경변수들을 파이썬의os.environ
에 등록해 줍니다.
import os
import re
import discord
from scraper import MusinsaAPI
# MusinsaAPI: 우리가 직접 구현한 웹 크롤러 클래스
os
: 운영체제 관련 기능을 쓰기 위한 표준 모듈 (os.getenv()
로 환경변수 꺼낼 때 사용)re
: 정규표현식 라이브러리 (re.match
/re.search
로 메시지 패턴을 찾을 때 사용)discord
: Discord 봇 API 라이브러리(discord.py
)MusinsaAPI
:scraper.py
에 정의한 웹 크롤러 클래스. Musinsa 상품 데이터를 가져오는 역할
# ─── Discord 봇 기본 설정 ─────────────────────────────────────────────────
intents = discord.Intents.default()
intents.message_content = True # 메시지 내용을 읽어오기 위한 권한 설정
client = discord.Client(intents=intents) # Discord 클라이언트 인스턴스 생성
Intents
- Discord가 어떤 종류의 이벤트(메시지, 반응, 접속 등)를 봇에 전달할지 설정하는 객체
message_content=True
를 켜야 채팅 내용(message.content
)을 읽어올 수 있어요.
Client
- Discord와 연결해 주고, 이벤트(메시지 수신·봇 준비 완료 등)를 처리할 봇 객체를 만듭니다.
# ─── Embed 메시지 생성 헬퍼 함수 (개발자 작성) ────────────────────────────────────
def build_message(item):
# discord.Embed: 디스코드에 이쁘게 보낼 임베드 메시지 객체 생성 (라이브러리 제공)
embed = discord.Embed(type="rich", title=item["name"], url=item["url"])
# set_thumbnail: 임베드에 이미지 축소판을 붙여주는 라이브러리 함수
embed.set_thumbnail(url=item["image"])
# description: Embed 객체의 본문 영역에 브랜드명 추가 (직접 작성)
embed.description = item["brand"]
# add_field: 필드를 추가해 정가/할인가를 표처럼 표시 (라이브러리 함수)
embed.add_field(name="정가", value=item["originalPrice"], inline=True)
embed.add_field(name="할인가", value=item["salePrice"], inline=True)
return embed
- 임베드(Embed): 카드 형태로 이미지·제목·필드 등을 깔끔하게 보여주는 Discord 메시지 포맷
build_message(item)
에서:Embed(...)
로 객체 만들고set_thumbnail()
으로 작은 이미지 추가description
에 브랜드명 표시add_field()
로 “정가”/“할인가”를 두 개의 칸으로 표시
- 이렇게 완성된
embed
를 돌려주면,message.channel.send(embed=embed)
만으로 이쁘게 전송됩니다.
봇 준비 완료 이벤트
@client.event
async def on_ready():
- 이 줄은 Discord 이벤트 핸들러를 등록하는 데코레이터입니다.
@client.event
는discord.Client
인스턴스(client
)에 이벤트를 연결하겠다는 뜻입니다.- 예를 들어,
on_ready
,on_message
,on_member_join
같은 이벤트 이름에 따라 봇의 동작을 정의합니다.
last_recommendations: dict[int, dict] = {}
- 디스코드 채팅방마다 추천한 상품 목록을 "기억"해두는 저장소입니다.
last_recommendations
:채팅방별 추천 기록을 저장할 변수dict[int, dict]
:숫자
(채널 ID)를 키로 하고,추천결과
(딕셔너리)를 값으로 가집니다.{}
:지금은 비어있는 상태 (아무 채팅방 정보도 없음)
@client.event
async def on_message(message):
if message.author == client.user:
return
- 디스코드 봇에서 누가 메시지를 보내면, 아래 함수를 실행해줘! 라는 데코레이터
on_message
는 디스코드에서 누군가가 채팅을 보냈을 때 실행되는 함수- 누가 "신발추천" 이라고 채팅하면
message.content
에"신발추천"
이 들어 있어요 - 자기 자신(봇)이 보낸 메시지인지 확인하는 조건
client.user
는 이 디스코드 봇 자체를 뜻하고message.author
는 메시지를 보낸 사람(혹은 봇)을 뜻합니다.- "이 메시지를 내가(봇이) 보냈으면 무시하자!" 라는 뜻입니다.
- 봇이 자기 메시지에 반응하면 무한반복 메시지 지옥이 되기 때문이에요
content = message.content.strip()
content_lower = content.lower()
channel_id = message.channel.id
message.content
→ 사용자가 보낸 채팅 메시지 내용을 가져옵니다. (예:"신발추천 "
).strip()
→ 문자열 앞뒤의 공백(띄어쓰기, 줄바꿈 등)을 제거해줍니다.- 대소문자 상관없이 명령어를 인식하게 하기 위해 소문자로 통일한 거예요
channel_id = message.channel.id
:메시지를 보낸 채널의 고유 ID 번호를 가져오는 코드- 디스코드 서버 안에 여러 채널이 있을 때, 각각의 채널마다 고유 번호가 있어요.
- 이 번호를
channel_id
라는 변수에 저장하며, 이걸 왜 저장하냐면, 채널마다 따로따로 추천 목록을 기억하려고 (last_recommendations[channel_id]
이런 식으로 사용)
if (
"신발추천" in content_lower
or "신발 추천" in content_lower
or content_lower == "신발"
):
await handle_recommendation(channel_id, "신발", message)
return
- 사용자가 보낸 메시지(
content_lower
)에 다음 중 하나라도 포함되면:"신발추천"
(붙여쓴 형태)"신발 추천"
(띄어쓴 형태)- 또는 메시지가 정확히
"신발"
인 경우 즉, 유저가 신발 관련 추천을 요청한 경우를 감지합니다.or
는 "또는"이라는 뜻으로, 조건 중 하나라도True
면 전체 조건이True
가 됩니다.
await
는 "잠깐 기다렸다가 결과가 오면 계속해" 라는 뜻
if any(
cmd in content_lower
for cmd in ("반팔추천", "반팔 추천", "티셔츠추천", "티셔츠 추천")
):
await handle_recommendation(
channel_id, "반팔", message, icon="👕", title="반팔 추천 TOP5"
)
return
content_lower
- 사용자가 디스코드에 보낸 메시지를 소문자로 바꾼 문자열
- 예:
"티셔츠추천"
→"티셔츠추천"
(소문자 그대로 유지)
("반팔추천", "반팔 추천", "티셔츠추천", "티셔츠 추천")
- 사용자가 입력할 수 있는 추천 명령어 목록이에요.
- 예:
"반팔추천"
이나"티셔츠 추천"
등
cmd in content_lower for cmd in ...
- 저 명령어들 중 하나라도 사용자가 보낸 메시지에 포함돼 있는지 확인해요.
any(...)
any()
는 하나라도 참(True)이면 전체 결과를 True로 만들어줘요.
즉, 이 조건은 다음과 같아요: 사용자의 메시지가 "반팔추천", "반팔 추천", "티셔츠추천", "티셔츠 추천" 중 하나라도 포함되어 있다면 아래 코드를 실행해줘!라는 뜻입니다.
detail_match = re.search(r"(\d+)번 상품 상세", content_lower)
if detail_match:
await handle_detail(detail_match, channel_id, message)
return
re.search()
는 정규표현식(문자 패턴)을 이용해 문자열에서 특정한 형식을 찾는 함수예요.r"(\d+)번 상품 상세"
는 정규표현식인데요:\d+
숫자가 1개 이상 있는지 확인(\d+)
괄호로 묶여 있으니 이 숫자를 추출하겠다는 뜻- 사용자가
"N번 상품 상세"
라고 입력했다면 → 아래 코드 실행! handle_detail()
이라는 함수에 다음 정보를 전달해 실행합니다detail_match
: "몇 번 상품인지"를 포함한 정규표현식 결과channel_id
: 요청한 채팅방의 IDmessage
: 디스코드 메시지 전체 정보 (보낸 사람, 채널 등)
bookmark_match = re.search(r"(\d+)번 상품 찜", content_lower)
if bookmark_match:
await handle_bookmark(bookmark_match, channel_id, message)
return
- 사용자가
"3번 상품 찜"
처럼 입력했는지 확인하고, - 해당 번호의 상품을 찜 목록에 추가하는 핸들러 함수(
handle_bookmark
)를 실행합니다. 이후 다른 조건은 더 이상 처리하지 않고 종료합니다.
if "가장 저렴" in content_lower:
await handle_cheapest(channel_id, message)
return
사용자가 보낸 메시지를 모두 소문자로 바꾼 content_lower
문자열 안에
"가장 저렴"
이라는 문구가 포함되어 있는지를 확인합니다.
사용자가 "가장 저렴"이라는 문구를 입력하면,
handle_cheapest()
라는 함수(비동기 함수)를 실행하고,
실행이 끝나면 이 이후의 코드는 실행하지 않고 종료합니다.
if "다음 페이지" in content_lower:
await handle_next_page(channel_id, message)
return
사용자가 "다음 페이지"라는 문구를 입력하면, 이 문구를 소문자로 변환한 메시지
(content_lower
)에서 찾아서, 해당 채널(channel_id
)에서 이전에 추천된 상품 리스트가 있다면, 그 키워드로 다음 페이지의 상품 목록을 새로 불러와서
디스코드 채팅창에 보여줍니다.
if content_lower in {"!cgv", "cgv", "영화추천"}:
await handle_cgv(message)
return
용자가 "!cgv"
, "cgv"
, 또는 "영화추천"
이라고 입력하면, CGV 영화 정보를 가져오는 함수(handle_cgv
)를 실행해서 디스코드 채널에 영화 랭킹을 보여줍니다.
GREETINGS = {"안녕하세요", "안녕", "안녕하십니까"}
if content_lower in GREETINGS:
await message.channel.send(f"{message.author.display_name}님, 안녕하세요! 😊")
return
사용자가 "안녕하세요"
, "안녕"
, "안녕하십니까"
중 하나를 입력하면,
해당 사용자의 이름을 포함해 "안녕하세요 😊"
라고 인사 메시지를 보내는 코드입니다.
if content_lower.startswith("$hello"):
await message.channel.send("Hello!")
return
사용자가 입력한 메시지가 "$hello"
로 시작하면,
디스코드 채널에 "Hello!"라는 인사 메시지를 보냅니다.
if content_lower.startswith("$hi") or content_lower == "hi":
await message.channel.send(f"hi, {message.author.display_name}! 🙂")
return
사용자가 "$hi"
로 시작하거나 "hi"
라고 입력하면, 그 사용자의 이름을 포함해서
“hi, [사용자 이름]! 🙂”
라는 인사 메시지를 보냅니다.
async def handle_recommendation(channel_id, keyword, message, size=5, icon="👟", title=None):
"""신발/반팔 등 추천 목록을 가져와서 전송"""
# 1. 첫 페이지부터 시작
page = 1
# 2. MusinsaAPI를 사용해서 상품 리스트를 가져옴 (예: 신발/반팔 등)
items = MusinsaAPI(keyword=keyword, page=page, size=size).fetch()
# 3. 해당 채널에 대한 추천 결과를 캐시에 저장함
# (다음에 상세보기, 찜하기, 다음페이지 기능 쓸 때 참조)
last_recommendations[channel_id] = {
"keyword": keyword, # 검색어
"page": page, # 현재 페이지
"items": items # 받아온 상품 목록
}
# 4. 제목이 있으면 그걸 쓰고, 없으면 기본 형식으로 제목 만들기
# 예: "👟 신발 추천 TOP5"
header = title or f"{icon} **{keyword} 추천 TOP{size}**"
# 5. 상품들을 한 줄씩 번호와 함께 정리
# 출력 예: "1. 나이키 에어포스 – 99,000원"
body = "\n".join(
f"{i+1}. {it['name']} – {it['salePrice']}" for i, it in enumerate(items)
)
# 6. Discord 채널에 추천 메시지 전송
await message.channel.send(f"{header}\n{body}")
async def handle_detail(detail_match, channel_id, message):
"""사용자가 요청한 'N번 상품 상세' 명령에 응답하여, 해당 상품의
정보를 Embed 형식으로 전송"""
# 추천 목록이 저장된 캐시에 현재 채널의 정보가 없다면
# 즉, 아직 "신발추천" 또는 "반팔추천" 같은 추천 요청을 하지 않았다면
if channel_id not in last_recommendations:
# 유저에게 먼저 추천 명령어를 입력하라고 알림
await message.channel.send("먼저 상품 추천 후 상세 보기 요청해 주세요.")
return # 더 이상 실행하지 않음
# detail_match는 정규표현식 결과로, 2번 상품 상세와 같은 메시지에서 숫자만 추출
# group(1)은 첫 번째 괄호로 묶인 숫자 → 예: 2번 상품 상세 → group(1)은 2
# -1을 하는 이유는 Python 리스트의 인덱스는 0부터 시작하기 때문
idx = int(detail_match.group(1)) - 1
# 이 채널에서 마지막으로 추천된 상품 목록을 가져옴
items = last_recommendations[channel_id]["items"]
# 인덱스가 0보다 작거나, 목록 길이보다 크거나 같으면 존재하지 않는 항목이므로 오류 처리
if idx < 0 or idx >= len(items):
# 유저에게 존재하지 않는 번호라고 안내
await message.channel.send("해당 번호의 상품이 없습니다.")
return
# 정상적인 인덱스인 경우,해당 상품 정보를 예쁜Embed 카드로 만들어 전송
embed = build_message(items[idx])
await message.channel.send(embed=embed)
# 디스코드에 카드 형태로 출력
async def handle_bookmark(bookmark_match, channel_id, message):
"""N번 상품 찜하기 요청을 처리하여 유저에게 알림 메시지를 전송"""
# 이 채널에 대한 추천 목록이 없으면 (즉, 사용자가 먼저 상품 추천을 하지 않았다면)
if channel_id not in last_recommendations:
# 추천 먼저 하라는 경고 메시지를 전송
await message.channel.send("먼저 상품 추천 후 찜하기 요청해 주세요.")
return # 함수 종료
# 3번 상품 찜 같은 메시지에서 숫자 부분(예: 3)을 추출하고, 리스트 인덱스로 변환 (1→0부터 시작하므로 -1)
idx = int(bookmark_match.group(1)) - 1
# 이 채널에서 저장된 추천 상품 목록을 꺼내옴
items = last_recommendations[channel_id]["items"]
# 인덱스가 음수이거나, 목록 범위를 벗어나면 잘못된 번호이므로 오류 처리
if idx < 0 or idx >= len(items):
await message.channel.send("해당 번호의 상품이 없습니다.")
return # 함수 종료
# 유효한 인덱스일 경우 해당 상품을 가져옴
fav = items[idx]
# TODO: 나중에 DB나 Redis 같은 저장소에 찜한 상품을 저장하는 로직을 여기에 추가할 수 있음
# 현재는 단순히 찜했다는 메시지만 유저에게 전송
await message.channel.send(f"`{fav['name']}` 상품을 찜 목록에 추가했어요!")
async def handle_cheapest(channel_id, message):
"""가장 저렴한 상품을 찾아서 사용자에게 전송하는 함수"""
# 채널별 추천 목록이 저장된 캐시에 현재 채널 ID가 없으면,
# 즉, 사용자가 추천을 먼저 요청하지 않았다면 오류 메시지를 보냄
if channel_id not in last_recommendations:
await message.channel.send("먼저 상품 추천 후 가장 저렴 요청해 주세요.")
return # 함수 종료
# 현재 채널에 저장된 추천 상품 리스트를 꺼내옴
items = last_recommendations[channel_id]["items"]
# 상품 가격 문자열에서 원과 ','를 제거하고 정수형으로 변환하는 헬퍼 함수 정의
# 예: "45,000원" → 45000
def parse_price(x):
return int(x["salePrice"].replace("원", "").replace(",", ""))
# 상품 리스트 중 가장 가격이 낮은 상품을 찾음
# min() 함수는 리스트에서 최솟값을 찾는 함수이며, 가격 기준으로 비교
cheapest = min(items, key=parse_price)
# 가장 저렴한 상품의 이름과 가격을 메시지로 전송
await message.channel.send(
f"💸 **가장 저렴한 상품**:\n{cheapest['name']} – {cheapest['salePrice']}"
)
# 비동기 함수로, 사용자가 "다음 페이지"라고 입력했을 때 다음 상품 목록을 가져오는 역할
async def handle_next_page(channel_id, message, size=5):
"""다음 페이지 상품 목록을 가져와 전송"""
# 먼저 이 채널에서 추천받은 기록이 있는지 확인
if channel_id not in last_recommendations:
# 없다면 오류 메시지를 보내고 함수 종료
await message.channel.send("먼저 상품 추천 후 다음 페이지 요청해 주세요.")
return
# 이전에 추천한 데이터(키워드, 현재 페이지, 상품 리스트)를 가져옴
cache = last_recommendations[channel_id]
# 현재 페이지보다 하나 큰 페이지 번호 계산 → 다음 페이지
next_page = cache["page"] + 1
# MusinsaAPI 클래스의 fetch() 메서드를 통해 새로운 상품 리스트를 가져옴
# 기존 검색 키워드(cache["keyword"])를 그대로 사용하고, 다음 페이지로 넘김
items = MusinsaAPI(keyword=cache["keyword"], page=next_page, size=size).fetch()
# 캐시(메모리 저장소)에 현재 채널에 대한 추천 목록 정보를 업데이트
# 사용자가 다음 페이지를 또 요청할 수 있으니 현재 페이지 번호, 검색어, 아이템 목록을 저장
last_recommendations[channel_id] = {
"keyword": cache["keyword"],
"page": next_page,
"items": items,
}
# 화면에 보여줄 상품 목록 텍스트 생성
# 예: 1. 상품명 – 가격\n 2. 상품명 – 가격 ...
body = "\n".join(
f"{i+1}. {it['name']} – {it['salePrice']}" for i, it in enumerate(items)
)
# 유저에게 다음 페이지 결과를 전송
# ex) "신발 검색 2페이지" 제목과 함께 목록을 보여줌
await message.channel.send(
f"➡️ **{cache['keyword']} 검색 {next_page}페이지**\n{body}"
)
# 비동기 함수로, 사용자가 "!cgv", "cgv", "영화추천" 등의 메시지를 보냈을 때 실행됨
# CGV 예매율 상위 N개(기본값 5개)를 가져와 메시지로 전송함
async def handle_cgv(message, limit=5):
"""CGV 영화 예매율 TOPN 출력"""
# 외부에서 구현된 함수 get_cgv_movies()를 호출해 영화 데이터를 가져옴
# 반환값은 영화 정보가 담긴 딕셔너리들의 리스트
movies = get_cgv_movies()
# 영화 데이터가 없거나 가져오기 실패한 경우
if not movies:
# 사용자에게 오류 메시지를 보냄
await message.channel.send("CGV 영화 정보를 가져올 수 없습니다.")
return
# 영화리스트 중 상위 limit개(기본 5개)를 순회하면서 메시지 줄들을 생성
# 각 줄은 "1. 영화제목 (개봉일) - 예매율: XX%" 형식으로 표시
lines = [
f"{i+1}. {m['title']} ({m['release_date']}) - 예매율: {m['reserve_percent']}"
for i, m in enumerate(movies[:limit])
]
# 위에서 만든 줄들을 \n 줄바꿈으로 이어붙여 사용자에게 전송
# 메시지 앞에는 🎬 이모지와 제목도 함께 보냄
await message.channel.send(f"🎬 **CGV 예매율 TOP{limit}**\n" + "\n".join(lines))
if __name__ == "__main__":
# .env에서 읽어온 토큰 가져오기 (load_dotenv 덕분)
token = os.getenv("DISCORD_TOKEN")
if not token:
# 없으면 실행 중단하고 에러 알리기 (개발자 작성)
raise RuntimeError("DISCORD_TOKEN 환경변수가 설정되지 않았습니다.")
# client.run: Discord 서버에 로그인/연결을 시도 (라이브러리 함수)
client.run(token)