- 문의 폼 프론트엔드 (이름, 이메일, 전화, 문의유형, 메시지, 개인정보동의) - api-server.py에 /api/contact 엔드포인트 추가 (Discord Bot API 2채널) - bot/discord_cleanup.py: 7일 후 자동 삭제 봇 - 개인정보처리방침 모달 (PIPA 10개 항목, 국외이전 명시) - 민감정보 입력 경고 문구 - 개인정보 동의 체크 시에만 제출 버튼 활성화 - API 실패 시 대체 연락 안내 오버레이 (이메일/전화/Discord) - 4개 언어 i18n 지원 (ko/en/zh/ja) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
196 lines
6.5 KiB
Python
196 lines
6.5 KiB
Python
#!/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()
|