|
|
import json, os, glob, pathlib, time, re |
|
|
from fastapi import FastAPI, Request, Header, BackgroundTasks, HTTPException |
|
|
from fastapi.middleware.cors import CORSMiddleware |
|
|
from fastapi.responses import HTMLResponse, JSONResponse |
|
|
import google.generativeai as genai |
|
|
from linebot import LineBotApi, WebhookHandler |
|
|
from linebot.exceptions import InvalidSignatureError |
|
|
from linebot.models import MessageEvent, TextMessage, TextSendMessage |
|
|
import PyPDF2 |
|
|
import logging |
|
|
from datetime import datetime, timedelta |
|
|
|
|
|
|
|
|
try: |
|
|
from google.oauth2 import service_account |
|
|
from googleapiclient.discovery import build |
|
|
from googleapiclient.errors import HttpError |
|
|
CALENDAR_AVAILABLE = True |
|
|
except ImportError: |
|
|
CALENDAR_AVAILABLE = False |
|
|
logging.warning("Google Calendar 套件未安裝") |
|
|
|
|
|
|
|
|
logging.basicConfig(level=logging.INFO) |
|
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
|
|
|
SCOPES = ['https://www.googleapis.com/auth/calendar'] |
|
|
calendar_service = None |
|
|
|
|
|
if CALENDAR_AVAILABLE: |
|
|
def get_calendar_service(): |
|
|
try: |
|
|
service_account_json = os.getenv('GOOGLE_SERVICE_ACCOUNT_JSON') |
|
|
|
|
|
if service_account_json: |
|
|
service_account_info = json.loads(service_account_json) |
|
|
credentials = service_account.Credentials.from_service_account_info( |
|
|
service_account_info, scopes=SCOPES) |
|
|
service = build('calendar', 'v3', credentials=credentials) |
|
|
logger.info("✅ Google Calendar 服務已建立") |
|
|
return service |
|
|
else: |
|
|
logger.warning("⚠️ 未設定 GOOGLE_SERVICE_ACCOUNT_JSON") |
|
|
return None |
|
|
except json.JSONDecodeError as e: |
|
|
logger.error(f"❌ JSON 格式錯誤: {str(e)}") |
|
|
return None |
|
|
except Exception as e: |
|
|
logger.error(f"❌ 建立 Calendar 服務失敗: {str(e)}") |
|
|
return None |
|
|
|
|
|
calendar_service = get_calendar_service() |
|
|
|
|
|
CALENDAR_ID = os.getenv('GOOGLE_CALENDAR_ID', 'primary') |
|
|
|
|
|
def create_calendar_event(booking_data, booking_id): |
|
|
if not calendar_service: |
|
|
return None |
|
|
|
|
|
try: |
|
|
date_str = booking_data['date'] |
|
|
|
|
|
|
|
|
start_time = booking_data.get('start_time', '09:00') |
|
|
end_time = booking_data.get('end_time', '10:00') |
|
|
|
|
|
|
|
|
start_datetime = f"{date_str}T{start_time}:00" |
|
|
end_datetime = f"{date_str}T{end_time}:00" |
|
|
|
|
|
event = { |
|
|
'summary': f'🎹 琴房預約 - {booking_data["room"]}', |
|
|
'description': ( |
|
|
f'預約編號:{booking_id}\n' |
|
|
f'琴房:{booking_data["room"]}\n' |
|
|
f'人數:{booking_data["people"]}人\n' |
|
|
f'時段:{booking_data["time"]}\n' |
|
|
f'狀態:待確認' |
|
|
), |
|
|
'start': { |
|
|
'dateTime': start_datetime, |
|
|
'timeZone': 'Asia/Taipei', |
|
|
}, |
|
|
'end': { |
|
|
'dateTime': end_datetime, |
|
|
'timeZone': 'Asia/Taipei', |
|
|
}, |
|
|
'colorId': '9', |
|
|
'reminders': { |
|
|
'useDefault': False, |
|
|
'overrides': [ |
|
|
{'method': 'popup', 'minutes': 60}, |
|
|
{'method': 'popup', 'minutes': 10}, |
|
|
], |
|
|
}, |
|
|
} |
|
|
|
|
|
created_event = calendar_service.events().insert( |
|
|
calendarId=CALENDAR_ID, |
|
|
body=event |
|
|
).execute() |
|
|
|
|
|
logger.info(f"✅ Google Calendar 事件已建立: {created_event.get('id')}") |
|
|
return created_event.get('htmlLink') |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"❌ 建立 Calendar 事件失敗: {str(e)}") |
|
|
return None |
|
|
|
|
|
|
|
|
GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY") |
|
|
ai_enabled = False |
|
|
|
|
|
if GOOGLE_API_KEY: |
|
|
try: |
|
|
genai.configure(api_key=GOOGLE_API_KEY) |
|
|
ai_enabled = True |
|
|
logger.info("✅ Gemini AI 已啟用") |
|
|
except Exception as e: |
|
|
logger.error(f"❌ Gemini AI 初始化失敗: {str(e)}") |
|
|
else: |
|
|
logger.warning("⚠️ 未設定 GOOGLE_API_KEY") |
|
|
|
|
|
|
|
|
files = glob.glob('docs/*.pdf') |
|
|
pdf_content = '' |
|
|
|
|
|
if files: |
|
|
logger.info(f"找到 {len(files)} 個 PDF 檔案") |
|
|
for filename in files: |
|
|
try: |
|
|
with open(filename, 'rb') as pdf_file: |
|
|
pdf_reader = PyPDF2.PdfReader(pdf_file) |
|
|
for page in pdf_reader.pages: |
|
|
pdf_content += page.extract_text() |
|
|
logger.info(f"✅ 成功讀取: {filename}") |
|
|
except Exception as e: |
|
|
logger.error(f"❌ 讀取 {filename} 失敗: {str(e)}") |
|
|
|
|
|
MAX_CONTENT_LENGTH = 15000 |
|
|
if len(pdf_content) > MAX_CONTENT_LENGTH: |
|
|
pdf_content = pdf_content[:MAX_CONTENT_LENGTH] |
|
|
|
|
|
|
|
|
line_bot_api = LineBotApi(os.getenv("CHANNEL_ACCESS_TOKEN")) |
|
|
line_handler = WebhookHandler(os.getenv("CHANNEL_SECRET")) |
|
|
|
|
|
|
|
|
user_booking_state = {} |
|
|
last_request_time = {} |
|
|
REQUEST_COOLDOWN = 2 |
|
|
|
|
|
class BookingState: |
|
|
IDLE = "idle" |
|
|
ASKING_DATE = "asking_date" |
|
|
ASKING_TIME = "asking_time" |
|
|
ASKING_PEOPLE = "asking_people" |
|
|
ASKING_ROOM = "asking_room" |
|
|
CONFIRMING = "confirming" |
|
|
|
|
|
AVAILABLE_ROOMS = ["A琴房", "B琴房", "C琴房", "D琴房", "任意"] |
|
|
|
|
|
def init_user_booking(user_id): |
|
|
user_booking_state[user_id] = { |
|
|
"state": BookingState.IDLE, |
|
|
"date": None, |
|
|
"time": None, |
|
|
"start_time": None, |
|
|
"end_time": None, |
|
|
"people": None, |
|
|
"room": None, |
|
|
"last_update": time.time() |
|
|
} |
|
|
|
|
|
def get_user_booking(user_id): |
|
|
if user_id not in user_booking_state: |
|
|
init_user_booking(user_id) |
|
|
return user_booking_state[user_id] |
|
|
|
|
|
def reset_booking(user_id): |
|
|
init_user_booking(user_id) |
|
|
|
|
|
def is_booking_keyword(text): |
|
|
keywords = ["預約", "預定", "訂位", "訂房", "訂琴房", "借琴房", "租琴房", "我要預約", "想預約"] |
|
|
return any(keyword in text for keyword in keywords) |
|
|
|
|
|
def parse_date(date_str): |
|
|
today = datetime.now() |
|
|
|
|
|
if "今天" in date_str or "今日" in date_str: |
|
|
return today.strftime("%Y-%m-%d"), None |
|
|
elif "明天" in date_str or "明日" in date_str: |
|
|
return (today + timedelta(days=1)).strftime("%Y-%m-%d"), None |
|
|
elif "後天" in date_str: |
|
|
return (today + timedelta(days=2)).strftime("%Y-%m-%d"), None |
|
|
|
|
|
patterns = [ |
|
|
(r'(\d{4})[/-](\d{1,2})[/-](\d{1,2})', '%Y-%m-%d'), |
|
|
(r'(\d{1,2})[/-](\d{1,2})', '%m-%d'), |
|
|
] |
|
|
|
|
|
for pattern, fmt in patterns: |
|
|
match = re.search(pattern, date_str) |
|
|
if match: |
|
|
try: |
|
|
if fmt == '%m-%d': |
|
|
date = datetime.strptime(f"{today.year}-{match.group(0)}", f'%Y-{fmt}') |
|
|
else: |
|
|
date = datetime.strptime(match.group(0), fmt) |
|
|
|
|
|
if date.date() < today.date(): |
|
|
return None, "❌ 日期不能是過去的時間" |
|
|
|
|
|
return date.strftime("%Y-%m-%d"), None |
|
|
except ValueError: |
|
|
pass |
|
|
|
|
|
return None, "❌ 日期格式不正確\n例如:明天、2025-12-26" |
|
|
|
|
|
def parse_time_range(time_str): |
|
|
"""解析並驗證時間範圍,返回標準格式""" |
|
|
|
|
|
|
|
|
time_str = time_str.replace(" ", "") |
|
|
|
|
|
|
|
|
patterns = [ |
|
|
|
|
|
(r'(\d{1,2}):(\d{2})[-~到至](\d{1,2}):(\d{2})', 'hh:mm-hh:mm'), |
|
|
|
|
|
(r'(\d{4})[-~到至](\d{4})', 'hhmm-hhmm'), |
|
|
|
|
|
(r'(?:上午|下午)?(\d{1,2})點(?:半)?[-~到至](?:上午|下午)?(\d{1,2})點(?:半)?', '中文'), |
|
|
|
|
|
(r'(\d{1,2}):(\d{2})[-~到至](\d{1,2})', 'h:mm-h'), |
|
|
] |
|
|
|
|
|
start_hour = None |
|
|
start_minute = None |
|
|
end_hour = None |
|
|
end_minute = None |
|
|
|
|
|
for pattern, format_type in patterns: |
|
|
match = re.search(pattern, time_str) |
|
|
if match: |
|
|
groups = match.groups() |
|
|
|
|
|
if format_type == 'hh:mm-hh:mm': |
|
|
|
|
|
start_hour = int(groups[0]) |
|
|
start_minute = int(groups[1]) |
|
|
end_hour = int(groups[2]) |
|
|
end_minute = int(groups[3]) |
|
|
|
|
|
elif format_type == 'hhmm-hhmm': |
|
|
|
|
|
start_str = groups[0] |
|
|
end_str = groups[1] |
|
|
|
|
|
if len(start_str) == 4 and len(end_str) == 4: |
|
|
start_hour = int(start_str[:2]) |
|
|
start_minute = int(start_str[2:]) |
|
|
end_hour = int(end_str[:2]) |
|
|
end_minute = int(end_str[2:]) |
|
|
else: |
|
|
continue |
|
|
|
|
|
elif format_type == '中文': |
|
|
|
|
|
start_hour = int(groups[0]) |
|
|
start_minute = 0 |
|
|
end_hour = int(groups[1]) |
|
|
end_minute = 0 |
|
|
|
|
|
|
|
|
if '下午' in time_str and start_hour < 12: |
|
|
start_hour += 12 |
|
|
if '下午' in time_str and end_hour < 12: |
|
|
end_hour += 12 |
|
|
|
|
|
|
|
|
if '半' in time_str.split('到')[0]: |
|
|
start_minute = 30 |
|
|
if '半' in time_str.split('到')[1]: |
|
|
end_minute = 30 |
|
|
|
|
|
elif format_type == 'h:mm-h': |
|
|
|
|
|
start_hour = int(groups[0]) |
|
|
start_minute = int(groups[1]) |
|
|
end_hour = int(groups[2]) |
|
|
end_minute = 0 |
|
|
|
|
|
break |
|
|
|
|
|
|
|
|
if start_hour is None or end_hour is None: |
|
|
return None, None, None, None, "❌ 時段格式不正確\n請使用以下格式:\n• 09:00-11:00\n• 0900-1100\n• 9點到11點\n• 下午2點到4點" |
|
|
|
|
|
|
|
|
if not (0 <= start_hour <= 23 and 0 <= end_hour <= 23): |
|
|
return None, None, None, None, "❌ 小時必須在 0-23 之間" |
|
|
|
|
|
if not (0 <= start_minute <= 59 and 0 <= end_minute <= 59): |
|
|
return None, None, None, None, "❌ 分鐘必須在 0-59 之間" |
|
|
|
|
|
|
|
|
start_total = start_hour * 60 + start_minute |
|
|
end_total = end_hour * 60 + end_minute |
|
|
|
|
|
if start_total >= end_total: |
|
|
return None, None, None, None, "❌ 結束時間必須晚於開始時間" |
|
|
|
|
|
duration = (end_total - start_total) / 60 |
|
|
if duration > 8: |
|
|
return None, None, None, None, "❌ 預約時段不能超過 8 小時" |
|
|
|
|
|
|
|
|
start_time = f"{start_hour:02d}:{start_minute:02d}" |
|
|
end_time = f"{end_hour:02d}:{end_minute:02d}" |
|
|
display_time = f"{start_time}-{end_time}" |
|
|
|
|
|
return start_time, end_time, display_time, None, None |
|
|
|
|
|
def validate_time(time_str): |
|
|
"""驗證時間格式(保留向後兼容)""" |
|
|
start_time, end_time, display_time, error, _ = parse_time_range(time_str) |
|
|
if error: |
|
|
return False, error |
|
|
return True, None |
|
|
|
|
|
def parse_people(people_str): |
|
|
numbers = re.findall(r'\d+', people_str) |
|
|
if numbers: |
|
|
count = int(numbers[0]) |
|
|
if 1 <= count <= 10: |
|
|
return count, None |
|
|
return None, "❌ 人數必須在 1-10 人之間" |
|
|
return None, "❌ 請輸入有效的人數\n例如:2人、3" |
|
|
|
|
|
def validate_room(room_str): |
|
|
room_normalized = room_str.strip().upper() |
|
|
for room in AVAILABLE_ROOMS: |
|
|
if room.upper() in room_normalized or room_normalized in room.upper(): |
|
|
return room, None |
|
|
return None, f"❌ 請選擇:{', '.join(AVAILABLE_ROOMS)}" |
|
|
|
|
|
def save_booking_to_file(user_id, booking_data): |
|
|
bookings_dir = pathlib.Path("bookings") |
|
|
bookings_dir.mkdir(exist_ok=True) |
|
|
|
|
|
booking_file = bookings_dir / "bookings.json" |
|
|
|
|
|
if booking_file.exists(): |
|
|
with open(booking_file, 'r', encoding='utf-8') as f: |
|
|
try: |
|
|
bookings = json.load(f) |
|
|
except: |
|
|
bookings = [] |
|
|
else: |
|
|
bookings = [] |
|
|
|
|
|
booking_id = f"BK{int(time.time())}" |
|
|
|
|
|
booking_record = { |
|
|
"booking_id": booking_id, |
|
|
"user_id": user_id, |
|
|
"date": booking_data["date"], |
|
|
"time": booking_data["time"], |
|
|
"start_time": booking_data.get("start_time"), |
|
|
"end_time": booking_data.get("end_time"), |
|
|
"people": booking_data["people"], |
|
|
"room": booking_data["room"], |
|
|
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), |
|
|
"status": "pending", |
|
|
"calendar_link": None |
|
|
} |
|
|
|
|
|
calendar_link = create_calendar_event(booking_data, booking_id) |
|
|
if calendar_link: |
|
|
booking_record["calendar_link"] = calendar_link |
|
|
|
|
|
bookings.append(booking_record) |
|
|
|
|
|
with open(booking_file, 'w', encoding='utf-8') as f: |
|
|
json.dump(bookings, f, ensure_ascii=False, indent=2) |
|
|
|
|
|
logger.info(f"✅ 預約已儲存: {booking_id}") |
|
|
return booking_id, calendar_link |
|
|
|
|
|
def handle_booking_flow(user_id, user_message): |
|
|
booking = get_user_booking(user_id) |
|
|
current_state = booking["state"] |
|
|
|
|
|
if time.time() - booking["last_update"] > 600: |
|
|
reset_booking(user_id) |
|
|
booking = get_user_booking(user_id) |
|
|
current_state = booking["state"] |
|
|
|
|
|
booking["last_update"] = time.time() |
|
|
|
|
|
if user_message in ["取消", "取消預約", "重來"]: |
|
|
reset_booking(user_id) |
|
|
return "✅ 已取消預約\n輸入「預約」可重新開始" |
|
|
|
|
|
if current_state == BookingState.IDLE: |
|
|
if is_booking_keyword(user_message): |
|
|
booking["state"] = BookingState.ASKING_DATE |
|
|
return "🎹 琴房預約系統\n\n📅 請問預約日期?\n例如:明天、2025-12-26" |
|
|
return None |
|
|
|
|
|
elif current_state == BookingState.ASKING_DATE: |
|
|
parsed_date, error = parse_date(user_message) |
|
|
if error: |
|
|
return error |
|
|
booking["date"] = parsed_date |
|
|
booking["state"] = BookingState.ASKING_TIME |
|
|
return f"✅ 日期:{parsed_date}\n\n⏰ 請問時段?\n例如:09:00-11:00" |
|
|
|
|
|
elif current_state == BookingState.ASKING_TIME: |
|
|
|
|
|
start_time, end_time, display_time, error, err_msg = parse_time_range(user_message) |
|
|
if error: |
|
|
return err_msg |
|
|
|
|
|
|
|
|
booking["time"] = display_time |
|
|
booking["start_time"] = start_time |
|
|
booking["end_time"] = end_time |
|
|
booking["state"] = BookingState.ASKING_PEOPLE |
|
|
|
|
|
return f"✅ 時段:{display_time}\n\n👥 請問人數?\n例如:2人" |
|
|
|
|
|
elif current_state == BookingState.ASKING_PEOPLE: |
|
|
people_count, error = parse_people(user_message) |
|
|
if error: |
|
|
return error |
|
|
booking["people"] = people_count |
|
|
booking["state"] = BookingState.ASKING_ROOM |
|
|
return f"✅ 人數:{people_count}人\n\n🎹 請選擇琴房:\n{', '.join(AVAILABLE_ROOMS)}" |
|
|
|
|
|
elif current_state == BookingState.ASKING_ROOM: |
|
|
room, error = validate_room(user_message) |
|
|
if error: |
|
|
return error |
|
|
booking["room"] = room |
|
|
booking["state"] = BookingState.CONFIRMING |
|
|
|
|
|
return ( |
|
|
f"📋 確認預約資訊:\n" |
|
|
f"{'='*20}\n" |
|
|
f"📅 {booking['date']}\n" |
|
|
f"⏰ {booking['time']}\n" |
|
|
f"👥 {booking['people']}人\n" |
|
|
f"🎹 {booking['room']}\n" |
|
|
f"{'='*20}\n\n" |
|
|
f"輸入「確認」送出" |
|
|
) |
|
|
|
|
|
elif current_state == BookingState.CONFIRMING: |
|
|
if user_message in ["確認", "確定", "ok", "OK"]: |
|
|
try: |
|
|
booking_id, calendar_link = save_booking_to_file(user_id, booking) |
|
|
|
|
|
result = ( |
|
|
f"🎉 預約成功!\n" |
|
|
f"{'='*20}\n" |
|
|
f"📅 {booking['date']}\n" |
|
|
f"⏰ {booking['time']}\n" |
|
|
f"👥 {booking['people']}人\n" |
|
|
f"🎹 {booking['room']}\n" |
|
|
f"📝 {booking_id}\n" |
|
|
) |
|
|
|
|
|
if calendar_link: |
|
|
result += f"📆 已加入 Google Calendar\n" |
|
|
|
|
|
result += f"{'='*20}\n✅ 已收到預約!" |
|
|
|
|
|
reset_booking(user_id) |
|
|
return result |
|
|
except Exception as e: |
|
|
logger.error(f"❌ 儲存失敗: {str(e)}") |
|
|
reset_booking(user_id) |
|
|
return "❌ 預約失敗,請稍後再試" |
|
|
else: |
|
|
return "請輸入「確認」完成預約" |
|
|
|
|
|
return None |
|
|
|
|
|
|
|
|
app = FastAPI() |
|
|
|
|
|
app.add_middleware( |
|
|
CORSMiddleware, |
|
|
allow_origins=["*"], |
|
|
allow_credentials=True, |
|
|
allow_methods=["*"], |
|
|
allow_headers=["*"], |
|
|
) |
|
|
|
|
|
@app.get("/", response_class=JSONResponse) |
|
|
def root(): |
|
|
return { |
|
|
"title": "琴房預約系統", |
|
|
"status": "running", |
|
|
"version": "3.1", |
|
|
"features": { |
|
|
"booking": True, |
|
|
"google_calendar": calendar_service is not None, |
|
|
"ai_qa": ai_enabled, |
|
|
"pdf_loaded": len(pdf_content) > 0 |
|
|
}, |
|
|
"endpoints": [ |
|
|
"/admin - 管理介面", |
|
|
"/bookings - 查看預約", |
|
|
"/bookings/latest - 最新預約" |
|
|
] |
|
|
} |
|
|
|
|
|
@app.get("/bookings", response_class=JSONResponse) |
|
|
def get_bookings(): |
|
|
"""查看所有預約""" |
|
|
bookings_file = pathlib.Path("bookings/bookings.json") |
|
|
if bookings_file.exists(): |
|
|
with open(bookings_file, 'r', encoding='utf-8') as f: |
|
|
try: |
|
|
bookings = json.load(f) |
|
|
return {"total": len(bookings), "bookings": bookings} |
|
|
except: |
|
|
return {"total": 0, "bookings": [], "error": "讀取失敗"} |
|
|
return {"total": 0, "bookings": []} |
|
|
|
|
|
@app.get("/bookings/latest", response_class=JSONResponse) |
|
|
def get_latest_booking(): |
|
|
"""最新預約""" |
|
|
bookings_file = pathlib.Path("bookings/bookings.json") |
|
|
if bookings_file.exists(): |
|
|
with open(bookings_file, 'r', encoding='utf-8') as f: |
|
|
try: |
|
|
bookings = json.load(f) |
|
|
if bookings: |
|
|
return bookings[-1] |
|
|
except: |
|
|
pass |
|
|
return {"message": "無預約資料"} |
|
|
|
|
|
@app.get("/admin", response_class=HTMLResponse) |
|
|
def admin_panel(): |
|
|
"""管理介面""" |
|
|
bookings_file = pathlib.Path("bookings/bookings.json") |
|
|
bookings = [] |
|
|
|
|
|
if bookings_file.exists(): |
|
|
with open(bookings_file, 'r', encoding='utf-8') as f: |
|
|
try: |
|
|
bookings = json.load(f) |
|
|
except: |
|
|
pass |
|
|
|
|
|
html = f"""<!DOCTYPE html> |
|
|
<html> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>琴房預約管理</title> |
|
|
<style> |
|
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }} |
|
|
body {{ font-family: 'Segoe UI', Arial, sans-serif; background: #f5f5f5; padding: 20px; }} |
|
|
.container {{ max-width: 1200px; margin: 0 auto; }} |
|
|
h1 {{ text-align: center; color: #333; margin-bottom: 30px; }} |
|
|
.stats {{ display: flex; gap: 20px; margin-bottom: 30px; flex-wrap: wrap; }} |
|
|
.stat-card {{ flex: 1; min-width: 200px; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }} |
|
|
.stat-card h3 {{ color: #666; font-size: 14px; margin-bottom: 10px; }} |
|
|
.stat-card .number {{ font-size: 32px; font-weight: bold; color: #4CAF50; }} |
|
|
table {{ width: 100%; background: white; border-collapse: collapse; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }} |
|
|
th {{ background: #4CAF50; color: white; padding: 12px; text-align: left; }} |
|
|
td {{ padding: 12px; border-bottom: 1px solid #ddd; }} |
|
|
tr:hover {{ background: #f9f9f9; }} |
|
|
.refresh-btn {{ background: #4CAF50; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; margin-bottom: 20px; }} |
|
|
.refresh-btn:hover {{ background: #45a049; }} |
|
|
@media (max-width: 768px) {{ .stats {{ flex-direction: column; }} }} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div class="container"> |
|
|
<h1>🎹 琴房預約管理</h1> |
|
|
<div class="stats"> |
|
|
<div class="stat-card"> |
|
|
<h3>總預約數</h3> |
|
|
<div class="number">{len(bookings)}</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<h3>待確認</h3> |
|
|
<div class="number">{len([b for b in bookings if b.get('status') == 'pending'])}</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<h3>Google Calendar</h3> |
|
|
<div class="number">{'✓' if calendar_service else '✗'}</div> |
|
|
</div> |
|
|
</div> |
|
|
<button class="refresh-btn" onclick="location.reload()">🔄 重新整理</button> |
|
|
<table> |
|
|
<thead> |
|
|
<tr> |
|
|
<th>編號</th> |
|
|
<th>日期</th> |
|
|
<th>時段</th> |
|
|
<th>人數</th> |
|
|
<th>琴房</th> |
|
|
<th>建立時間</th> |
|
|
<th>日曆</th> |
|
|
</tr> |
|
|
</thead> |
|
|
<tbody>""" |
|
|
|
|
|
for booking in reversed(bookings): |
|
|
calendar_icon = '📆' if booking.get('calendar_link') else '❌' |
|
|
html += f""" |
|
|
<tr> |
|
|
<td>{booking['booking_id']}</td> |
|
|
<td>{booking['date']}</td> |
|
|
<td>{booking['time']}</td> |
|
|
<td>{booking['people']}人</td> |
|
|
<td>{booking['room']}</td> |
|
|
<td>{booking['created_at']}</td> |
|
|
<td>""" |
|
|
if booking.get('calendar_link'): |
|
|
html += f'<a href="{booking["calendar_link"]}" target="_blank">{calendar_icon}</a>' |
|
|
else: |
|
|
html += calendar_icon |
|
|
html += "</td></tr>" |
|
|
|
|
|
html += """ |
|
|
</tbody> |
|
|
</table> |
|
|
</div> |
|
|
</body> |
|
|
</html>""" |
|
|
|
|
|
return html |
|
|
|
|
|
@app.post("/webhook") |
|
|
async def webhook(request: Request, background_tasks: BackgroundTasks, x_line_signature=Header(None)): |
|
|
body = await request.body() |
|
|
try: |
|
|
background_tasks.add_task(line_handler.handle, body.decode("utf-8"), x_line_signature) |
|
|
except InvalidSignatureError: |
|
|
raise HTTPException(status_code=400, detail="Invalid signature") |
|
|
return "ok" |
|
|
|
|
|
@line_handler.add(MessageEvent, message=TextMessage) |
|
|
def handle_message(event): |
|
|
user_message = event.message.text.strip() |
|
|
user_id = event.source.user_id |
|
|
|
|
|
if user_message == "再見": |
|
|
line_bot_api.reply_message(event.reply_token, TextSendMessage(text="👋 再見!")) |
|
|
return |
|
|
|
|
|
|
|
|
current_time = time.time() |
|
|
if user_id in last_request_time: |
|
|
if current_time - last_request_time[user_id] < REQUEST_COOLDOWN: |
|
|
return |
|
|
last_request_time[user_id] = current_time |
|
|
|
|
|
|
|
|
booking_response = handle_booking_flow(user_id, user_message) |
|
|
if booking_response: |
|
|
line_bot_api.reply_message(event.reply_token, TextSendMessage(text=booking_response)) |
|
|
return |
|
|
|
|
|
|
|
|
if ai_enabled: |
|
|
try: |
|
|
prompt = f"參考資料:{pdf_content}\n\n問題:{user_message}\n\n簡潔回答。" if pdf_content else user_message |
|
|
|
|
|
model = genai.GenerativeModel('gemini-2.5-flash') |
|
|
response = model.generate_content(prompt) |
|
|
|
|
|
out = response.text if response and response.text else "無法回答" |
|
|
except Exception as e: |
|
|
logger.error(f"AI錯誤: {str(e)}") |
|
|
out = "系統忙碌中" |
|
|
else: |
|
|
out = "請輸入「預約」開始預約琴房" |
|
|
|
|
|
line_bot_api.reply_message(event.reply_token, TextSendMessage(text=out)) |
|
|
|
|
|
if __name__ == "__main__": |
|
|
import uvicorn |
|
|
logger.info("="*50) |
|
|
logger.info("🎹 琴房預約系統啟動") |
|
|
logger.info(f"Google Calendar: {'✅' if calendar_service else '❌'}") |
|
|
logger.info(f"AI 問答: {'✅' if ai_enabled else '❌'}") |
|
|
logger.info(f"PDF: {'✅' if pdf_content else '❌'}") |
|
|
logger.info("="*50) |
|
|
uvicorn.run("main:app", host="0.0.0.0", port=7860, reload=True) |