diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..ef33d75
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,20 @@
+# ─── Discord 봇 설정 ─────────────────────────────
+# 봇 토큰: https://discord.com/developers/applications 에서 봇 생성 후 토큰 복사
+DISCORD_BOT_TOKEN=YOUR_BOT_TOKEN_HERE
+
+# 문의 상세 내용이 전달될 채널 ID (본계정만 열람 가능한 비공개 채널)
+DISCORD_INQUIRY_CHANNEL_ID=YOUR_INQUIRY_CHANNEL_ID
+
+# 관리자 알림 채널 ID (멘션으로 알림만 보냄)
+DISCORD_NOTIFY_CHANNEL_ID=YOUR_NOTIFY_CHANNEL_ID
+
+# 관리자 Discord User ID (쉼표 구분)
+# Discord 개발자 모드 켜기 > 유저 우클릭 > ID 복사
+DISCORD_ADMIN_IDS=123456789,987654321,111222333
+
+# ─── 자동 삭제 설정 ──────────────────────────────
+# 문의 채널 메시지 삭제까지 보관 일수 (기본: 7일)
+DELETE_AFTER_DAYS=7
+
+# 삭제 체크 주기 (시간 단위, 기본: 6시간)
+CHECK_INTERVAL_HOURS=6
diff --git a/.gitignore b/.gitignore
index 27592c8..d339555 100644
Binary files a/.gitignore and b/.gitignore differ
diff --git a/api-server.py b/api-server.py
index f1b845a..b84d686 100644
--- a/api-server.py
+++ b/api-server.py
@@ -10,6 +10,8 @@ import socketserver
import os
import sys
import json
+import urllib.request
+import ssl
from datetime import datetime
from urllib.parse import urlparse
@@ -31,6 +33,54 @@ HOST = '127.0.0.1' # 로컬만 허용 (Caddy가 프록시)
WORKING_DIR = os.path.dirname(os.path.abspath(__file__))
os.chdir(WORKING_DIR)
+# Discord 설정 (.env 파일에서 로드)
+DISCORD_BOT_TOKEN = ''
+DISCORD_INQUIRY_CHANNEL_ID = '' # 문의 상세 (본계정만 열람)
+DISCORD_NOTIFY_CHANNEL_ID = '' # 관리자 알림 (멘션)
+DISCORD_ADMIN_IDS = [] # 멘션할 관리자 User ID 목록
+
+def load_env():
+ """Load .env file"""
+ global DISCORD_BOT_TOKEN, DISCORD_INQUIRY_CHANNEL_ID, DISCORD_NOTIFY_CHANNEL_ID, DISCORD_ADMIN_IDS
+ env_path = os.path.join(WORKING_DIR, '.env')
+ if os.path.exists(env_path):
+ with open(env_path, 'r', encoding='utf-8') as f:
+ for line in f:
+ line = line.strip()
+ if line and not line.startswith('#') and '=' in line:
+ key, value = line.split('=', 1)
+ key = key.strip()
+ value = value.strip().strip('"').strip("'")
+ if key == 'DISCORD_BOT_TOKEN':
+ DISCORD_BOT_TOKEN = value
+ elif key == 'DISCORD_INQUIRY_CHANNEL_ID':
+ DISCORD_INQUIRY_CHANNEL_ID = value
+ elif key == 'DISCORD_NOTIFY_CHANNEL_ID':
+ DISCORD_NOTIFY_CHANNEL_ID = value
+ elif key == 'DISCORD_ADMIN_IDS':
+ DISCORD_ADMIN_IDS = [id.strip() for id in value.split(',') if id.strip()]
+
+load_env()
+
+def discord_bot_send(channel_id, payload):
+ """Discord Bot API로 메시지 전송"""
+ url = f'https://discord.com/api/v10/channels/{channel_id}/messages'
+ body = json.dumps(payload).encode('utf-8')
+ req = urllib.request.Request(
+ url,
+ data=body,
+ headers={
+ 'Content-Type': 'application/json',
+ 'Authorization': f'Bot {DISCORD_BOT_TOKEN}'
+ },
+ method='POST'
+ )
+ ctx = ssl.create_default_context()
+ with urllib.request.urlopen(req, context=ctx, timeout=10) as resp:
+ if resp.status not in (200, 201):
+ raise Exception(f'Discord API failed: {resp.status}')
+ return json.loads(resp.read().decode('utf-8'))
+
class APIRequestHandler(http.server.BaseHTTPRequestHandler):
"""API 전용 HTTP 요청 핸들러"""
@@ -67,6 +117,8 @@ class APIRequestHandler(http.server.BaseHTTPRequestHandler):
self.handle_post_backgrounds()
elif parsed_path.path == '/api/props':
self.handle_post_props()
+ elif parsed_path.path == '/api/contact':
+ self.handle_post_contact()
else:
self.send_error_json(404, 'API endpoint not found')
@@ -200,6 +252,91 @@ class APIRequestHandler(http.server.BaseHTTPRequestHandler):
except Exception as e:
self.send_error_json(500, f'데이터 저장 실패: {str(e)}')
+ def handle_post_contact(self):
+ """문의 폼 처리 → Discord Bot API (2채널: 문의상세 + 관리자알림)"""
+ try:
+ content_length = int(self.headers.get('Content-Length', 0))
+ if content_length == 0:
+ self.send_error_json(400, '요청 본문이 비어있습니다')
+ return
+
+ post_data = self.rfile.read(content_length)
+ try:
+ data = json.loads(post_data.decode('utf-8'))
+ except json.JSONDecodeError as e:
+ self.send_error_json(400, f'잘못된 JSON 형식: {str(e)}')
+ return
+
+ # 필수 필드 검증
+ required = ['name', 'email', 'service', 'message']
+ for field in required:
+ if not data.get(field, '').strip():
+ self.send_error_json(400, f'{field} 필드가 필요합니다')
+ return
+
+ # 문의 유형 한글 매핑
+ service_map = {
+ 'studio_rental': '스튜디오 대관',
+ 'vtuber': 'VTuber 제작',
+ 'mocap': '모션캡쳐 촬영',
+ 'music_video': '뮤직비디오 제작',
+ 'partnership': '제휴/협력',
+ 'other': '기타'
+ }
+ service_label = service_map.get(data['service'], data['service'])
+
+ # Discord 봇 설정 확인
+ if not DISCORD_BOT_TOKEN or not DISCORD_INQUIRY_CHANNEL_ID:
+ print("[API] Warning: Discord bot not configured")
+ self.send_error_json(500, '서버 설정 오류')
+ return
+
+ now = datetime.now()
+
+ # 1) 문의 상세 채널에 전체 내용 전송
+ embed = {
+ 'title': '📩 새로운 문의가 접수되었습니다',
+ 'color': 0xff8800,
+ 'fields': [
+ {'name': '👤 이름', 'value': data['name'], 'inline': True},
+ {'name': '📧 이메일', 'value': data['email'], 'inline': True},
+ {'name': '📱 전화번호', 'value': data.get('phone', '-') or '-', 'inline': True},
+ {'name': '📋 문의 유형', 'value': service_label, 'inline': True},
+ {'name': '💬 문의 내용', 'value': data['message'][:1024], 'inline': False},
+ ],
+ 'footer': {'text': f'⏰ {now.strftime("%Y-%m-%d %H:%M")} | 7일 후 자동 삭제'},
+ 'timestamp': now.isoformat()
+ }
+ discord_bot_send(DISCORD_INQUIRY_CHANNEL_ID, {'embeds': [embed]})
+
+ # 2) 관리자 알림 채널에 멘션 알림 전송
+ if DISCORD_NOTIFY_CHANNEL_ID and DISCORD_ADMIN_IDS:
+ mentions = ' '.join(f'<@{uid}>' for uid in DISCORD_ADMIN_IDS)
+ notify_msg = f'📩 **고객 문의가 접수되었습니다!**\n\n> **{data["name"]}**님 | {service_label}\n\n{mentions}'
+ discord_bot_send(DISCORD_NOTIFY_CHANNEL_ID, {
+ 'content': notify_msg,
+ 'allowed_mentions': {'users': DISCORD_ADMIN_IDS}
+ })
+
+ # 성공 응답
+ response = {
+ 'success': True,
+ 'message': '문의가 성공적으로 전송되었습니다.'
+ }
+ self.send_response(200)
+ self.send_header('Content-Type', 'application/json; charset=utf-8')
+ self.end_headers()
+ self.wfile.write(json.dumps(response, ensure_ascii=False).encode('utf-8'))
+
+ print(f"[API] 문의 접수: {data['name']} ({service_label})")
+
+ except urllib.error.URLError as e:
+ print(f"[API] Discord API error: {e}")
+ self.send_error_json(500, '문의 전송에 실패했습니다. 잠시 후 다시 시도해 주세요.')
+ except Exception as e:
+ print(f"[API] Contact error: {e}")
+ self.send_error_json(500, f'문의 처리 실패: {str(e)}')
+
def send_error_json(self, code, message):
"""JSON 형식 에러 응답"""
self.send_response(code)
@@ -228,6 +365,14 @@ def main():
print(f" POST /api/backgrounds")
print(f" GET /api/props")
print(f" POST /api/props")
+ print(f" POST /api/contact → Discord Bot API")
+ if DISCORD_BOT_TOKEN:
+ print(f" Discord Bot: configured")
+ print(f" Inquiry channel: {DISCORD_INQUIRY_CHANNEL_ID or 'NOT SET'}")
+ print(f" Notify channel: {DISCORD_NOTIFY_CHANNEL_ID or 'NOT SET'}")
+ print(f" Admin mentions: {len(DISCORD_ADMIN_IDS)} user(s)")
+ else:
+ print(f" ⚠ Discord Bot: NOT configured (set DISCORD_BOT_TOKEN in .env)")
print("=" * 40)
try:
diff --git a/bot/discord_cleanup.py b/bot/discord_cleanup.py
new file mode 100644
index 0000000..c7f1de7
--- /dev/null
+++ b/bot/discord_cleanup.py
@@ -0,0 +1,195 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+밍글 스튜디오 Discord 문의 자동 삭제 봇
+- 지정된 채널의 메시지를 7일 후 자동 삭제
+- 개인정보보호법(PIPA) 준수를 위한 데이터 보관기간 관리
+"""
+
+import os
+import sys
+import json
+import urllib.request
+import ssl
+import time
+from datetime import datetime, timedelta, timezone
+
+# ─── 설정 ───────────────────────────────────────
+BOT_TOKEN = ''
+CHANNEL_ID = ''
+DELETE_AFTER_DAYS = 7
+CHECK_INTERVAL_HOURS = 6 # 삭제 체크 주기 (시간)
+
+def load_env():
+ """Load .env file from parent directory"""
+ global BOT_TOKEN, CHANNEL_ID, DELETE_AFTER_DAYS, CHECK_INTERVAL_HOURS
+
+ env_paths = [
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '.env'),
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env'),
+ ]
+
+ for env_path in env_paths:
+ if os.path.exists(env_path):
+ with open(env_path, 'r', encoding='utf-8') as f:
+ for line in f:
+ line = line.strip()
+ if line and not line.startswith('#') and '=' in line:
+ key, value = line.split('=', 1)
+ key = key.strip()
+ value = value.strip().strip('"').strip("'")
+ if key == 'DISCORD_BOT_TOKEN':
+ BOT_TOKEN = value
+ elif key == 'DISCORD_INQUIRY_CHANNEL_ID':
+ CHANNEL_ID = value
+ elif key == 'DELETE_AFTER_DAYS':
+ DELETE_AFTER_DAYS = int(value)
+ elif key == 'CHECK_INTERVAL_HOURS':
+ CHECK_INTERVAL_HOURS = int(value)
+ break
+
+def discord_api(method, endpoint, data=None):
+ """Discord API 호출"""
+ url = f'https://discord.com/api/v10{endpoint}'
+ headers = {
+ 'Authorization': f'Bot {BOT_TOKEN}',
+ 'Content-Type': 'application/json',
+ }
+
+ body = json.dumps(data).encode('utf-8') if data else None
+ req = urllib.request.Request(url, data=body, headers=headers, method=method)
+
+ ctx = ssl.create_default_context()
+ try:
+ with urllib.request.urlopen(req, context=ctx, timeout=15) as resp:
+ if resp.status == 204:
+ return None
+ return json.loads(resp.read().decode('utf-8'))
+ except urllib.error.HTTPError as e:
+ error_body = e.read().decode('utf-8') if e.fp else ''
+ print(f"[ERROR] Discord API {method} {endpoint}: {e.code} {error_body}")
+
+ # Rate limit 처리
+ if e.code == 429:
+ retry_data = json.loads(error_body) if error_body else {}
+ retry_after = retry_data.get('retry_after', 5)
+ print(f"[RATE LIMIT] Waiting {retry_after}s...")
+ time.sleep(retry_after + 0.5)
+ return discord_api(method, endpoint, data)
+
+ raise
+
+def get_old_messages():
+ """지정 채널에서 만료된 메시지 조회"""
+ cutoff = datetime.now(timezone.utc) - timedelta(days=DELETE_AFTER_DAYS)
+ old_messages = []
+ last_id = None
+
+ while True:
+ params = f'?limit=100'
+ if last_id:
+ params += f'&before={last_id}'
+
+ messages = discord_api('GET', f'/channels/{CHANNEL_ID}/messages{params}')
+
+ if not messages:
+ break
+
+ for msg in messages:
+ # Discord 타임스탬프 파싱
+ msg_time = datetime.fromisoformat(msg['timestamp'].replace('+00:00', '+00:00'))
+ if msg_time.tzinfo is None:
+ msg_time = msg_time.replace(tzinfo=timezone.utc)
+
+ if msg_time < cutoff:
+ old_messages.append({
+ 'id': msg['id'],
+ 'timestamp': msg['timestamp'],
+ 'content': msg.get('content', '')[:50]
+ })
+
+ last_id = messages[-1]['id']
+
+ # 가장 오래된 메시지도 만료 기간 내라면 더 조회할 필요 없음
+ oldest_time = datetime.fromisoformat(messages[-1]['timestamp'].replace('+00:00', '+00:00'))
+ if oldest_time.tzinfo is None:
+ oldest_time = oldest_time.replace(tzinfo=timezone.utc)
+ if oldest_time >= cutoff:
+ continue
+ else:
+ break
+
+ time.sleep(0.5) # Rate limit 방지
+
+ return old_messages
+
+def delete_messages(messages):
+ """메시지 삭제"""
+ deleted = 0
+ for msg in messages:
+ try:
+ discord_api('DELETE', f'/channels/{CHANNEL_ID}/messages/{msg["id"]}')
+ print(f" [DELETE] {msg['id']} ({msg['timestamp'][:10]})")
+ deleted += 1
+ time.sleep(0.5) # Rate limit 방지
+ except Exception as e:
+ print(f" [ERROR] Failed to delete {msg['id']}: {e}")
+ return deleted
+
+def run_cleanup():
+ """한 번의 정리 사이클 실행"""
+ now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+ print(f"\n[{now}] Cleanup check started (delete after {DELETE_AFTER_DAYS} days)")
+
+ try:
+ messages = get_old_messages()
+
+ if messages:
+ print(f" Found {len(messages)} expired message(s)")
+ deleted = delete_messages(messages)
+ print(f" Deleted {deleted}/{len(messages)} message(s)")
+ else:
+ print(f" No expired messages found")
+
+ except Exception as e:
+ print(f" [ERROR] Cleanup failed: {e}")
+
+def main():
+ """메인 실행"""
+ load_env()
+
+ if not BOT_TOKEN:
+ print("Error: DISCORD_BOT_TOKEN not set in .env")
+ sys.exit(1)
+ if not CHANNEL_ID:
+ print("Error: DISCORD_CHANNEL_ID not set in .env")
+ sys.exit(1)
+
+ print("Mingle Studio Discord Cleanup Bot")
+ print("=" * 40)
+ print(f"Channel ID: {CHANNEL_ID}")
+ print(f"Delete after: {DELETE_AFTER_DAYS} days")
+ print(f"Check interval: {CHECK_INTERVAL_HOURS} hours")
+ print("=" * 40)
+
+ # 봇 정보 확인
+ try:
+ bot_info = discord_api('GET', '/users/@me')
+ print(f"Bot: {bot_info['username']}#{bot_info.get('discriminator', '0')}")
+ except Exception as e:
+ print(f"Error: Failed to connect to Discord: {e}")
+ sys.exit(1)
+
+ print("Bot started. Press Ctrl+C to stop.\n")
+
+ try:
+ while True:
+ run_cleanup()
+ print(f" Next check in {CHECK_INTERVAL_HOURS} hours...")
+ time.sleep(CHECK_INTERVAL_HOURS * 3600)
+ except KeyboardInterrupt:
+ print("\nBot stopped.")
+
+
+if __name__ == '__main__':
+ main()
diff --git a/contact.html b/contact.html
index 9109426..c4f320b 100644
--- a/contact.html
+++ b/contact.html
@@ -190,6 +190,185 @@
+
+ 아래 양식을 작성하시면 빠르게 답변 드리겠습니다온라인 문의
+
아래 양식을 작성하시면 빠르게 답변 드리겠습니다
+아래 양식을 작성하시면 빠르게 답변 드리겠습니다
+${_t('contact.js.errorDesc', '일시적인 서버 오류로 문의가 전송되지 않았습니다. 아래 방법으로 직접 연락해 주세요.')}
+ +아래 양식을 작성하시면 빠르게 답변 드리겠습니다
+