AlanRex commited on
Commit
d220923
·
verified ·
1 Parent(s): 2e431ca

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +196 -415
main.py CHANGED
@@ -1,18 +1,24 @@
1
  import json, os, glob, pathlib, time, re
 
2
  from fastapi.middleware.cors import CORSMiddleware
3
- from fastapi import FastAPI, Request, Header, BackgroundTasks, HTTPException, status
4
  from google import genai
5
  from linebot import LineBotApi, WebhookHandler
6
  from linebot.exceptions import InvalidSignatureError
7
  from linebot.models import MessageEvent, TextMessage, TextSendMessage
8
  import PyPDF2
9
  import logging
10
- from fastapi.responses import HTMLResponse
11
  from datetime import datetime, timedelta
12
- from google.oauth2.credentials import Credentials
13
- from google.oauth2 import service_account
14
- from googleapiclient.discovery import build
15
- from googleapiclient.errors import HttpError
 
 
 
 
 
 
16
 
17
  # ============== 設定日誌 ==============
18
  logging.basicConfig(level=logging.INFO)
@@ -20,48 +26,45 @@ logger = logging.getLogger(__name__)
20
 
21
  # ============== Google Calendar 設定 ==============
22
  SCOPES = ['https://www.googleapis.com/auth/calendar']
 
23
 
24
- def get_calendar_service():
25
- """建立 Google Calendar 服務"""
26
- try:
27
- # 方法 1: 使用 Service Account(推薦用於伺服器)
28
- service_account_file = os.getenv('GOOGLE_SERVICE_ACCOUNT_JSON')
29
-
30
- if service_account_file:
31
- # 從環境變數讀取 JSON 內容
32
- service_account_info = json.loads(service_account_file)
33
- credentials = service_account.Credentials.from_service_account_info(
34
- service_account_info, scopes=SCOPES)
35
- else:
36
- logger.warning("未設定 Google Calendar 認證,預約將只儲存在本地")
37
- return None
38
 
39
- service = build('calendar', 'v3', credentials=credentials)
40
- logger.info("Google Calendar 服務已建立")
41
- return service
42
- except Exception as e:
43
- logger.error(f"建立 Google Calendar 服務失敗: {str(e)}")
44
- return None
 
 
 
 
 
 
 
 
 
 
45
 
46
- # 初始化 Calendar 服務
47
- calendar_service = get_calendar_service()
48
- CALENDAR_ID = os.getenv('GOOGLE_CALENDAR_ID', 'primary') # 使用主要日曆或指定 ID
49
 
50
  def create_calendar_event(booking_data, booking_id):
51
- """在 Google Calendar 建立事件"""
52
  if not calendar_service:
53
- logger.warning("Calendar 服務未啟用,跳過建立事件")
54
  return None
55
 
56
  try:
57
- # 解析日期和時間
58
  date_str = booking_data['date']
59
  time_str = booking_data['time']
60
 
61
- # 嘗試解析時間範圍(例如:14:00-16:00 或 下午2點到4點)
62
  time_patterns = [
63
- r'(\d{1,2}):(\d{2})\s*[-~到至]\s*(\d{1,2}):(\d{2})', # 14:00-16:00
64
- r'(\d{1,2})\s*點\s*[-~到至]\s*(\d{1,2})\s*點', # 2點到4點
65
  ]
66
 
67
  start_time = "09:00"
@@ -79,11 +82,9 @@ def create_calendar_event(booking_data, booking_id):
79
  end_time = f"{int(groups[1]):02d}:00"
80
  break
81
 
82
- # 建立 ISO 格式的時間
83
  start_datetime = f"{date_str}T{start_time}:00"
84
  end_datetime = f"{date_str}T{end_time}:00"
85
 
86
- # 建立事件
87
  event = {
88
  'summary': f'🎹 琴房預約 - {booking_data["room"]}',
89
  'description': (
@@ -100,118 +101,70 @@ def create_calendar_event(booking_data, booking_id):
100
  'dateTime': end_datetime,
101
  'timeZone': 'Asia/Taipei',
102
  },
103
- 'colorId': '9', # 藍色
104
  'reminders': {
105
  'useDefault': False,
106
  'overrides': [
107
- {'method': 'popup', 'minutes': 60}, # 1小時前提醒
108
- {'method': 'popup', 'minutes': 10}, # 10分鐘前提醒
109
  ],
110
  },
111
  }
112
 
113
- # 插入事件
114
  created_event = calendar_service.events().insert(
115
  calendarId=CALENDAR_ID,
116
  body=event
117
  ).execute()
118
 
119
- logger.info(f"Google Calendar 事件已建立: {created_event.get('id')}")
120
  return created_event.get('htmlLink')
121
 
122
- except HttpError as error:
123
- logger.error(f"Google Calendar API 錯誤: {error}")
124
- return None
125
  except Exception as e:
126
- logger.error(f"建立 Calendar 事件失敗: {str(e)}")
127
  return None
128
 
129
- def check_time_slot_available(date_str, time_str, room):
130
- """檢查時段是否可用"""
131
- if not calendar_service:
132
- return True, None
133
-
134
- try:
135
- # 解析日期
136
- date_obj = datetime.strptime(date_str, "%Y-%m-%d")
137
-
138
- # 查詢當天的所有事件
139
- time_min = date_obj.isoformat() + 'Z'
140
- time_max = (date_obj + timedelta(days=1)).isoformat() + 'Z'
141
-
142
- events_result = calendar_service.events().list(
143
- calendarId=CALENDAR_ID,
144
- timeMin=time_min,
145
- timeMax=time_max,
146
- singleEvents=True,
147
- orderBy='startTime'
148
- ).execute()
149
-
150
- events = events_result.get('items', [])
151
-
152
- # 檢查是否有衝突
153
- conflicts = []
154
- for event in events:
155
- event_summary = event.get('summary', '')
156
- if room in event_summary or '任意' in event_summary:
157
- start = event['start'].get('dateTime', event['start'].get('date'))
158
- conflicts.append(f"{event_summary} ({start})")
159
-
160
- if conflicts:
161
- return False, f"該時段已有預約:\n" + "\n".join(conflicts)
162
-
163
- return True, None
164
-
165
- except Exception as e:
166
- logger.error(f"檢查時段可用性失敗: {str(e)}")
167
- return True, None # 如果檢查失敗,允許預約
168
-
169
- # ============== API 金鑰檢查 ==============
170
  GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
 
 
171
  if GOOGLE_API_KEY:
172
- client = genai.Client(api_key=GOOGLE_API_KEY)
173
- ai_enabled = True
174
- logger.info("Gemini AI 已啟用")
 
 
 
175
  else:
176
- ai_enabled = False
177
- logger.warning("未設定 GOOGLE_API_KEY,AI 問答功能已停用")
178
 
179
- # ============== 讀取 PDF 內容 ==============
180
  files = glob.glob('docs/*.pdf')
181
  pdf_content = ''
182
 
183
- if not files:
184
- logger.warning("警告:docs/ 資料夾中找不到 PDF 檔案")
185
- else:
186
  logger.info(f"找到 {len(files)} 個 PDF 檔案")
187
  for filename in files:
188
  try:
189
  with open(filename, 'rb') as pdf_file:
190
  pdf_reader = PyPDF2.PdfReader(pdf_file)
191
- for i in range(len(pdf_reader.pages)):
192
- page = pdf_reader.pages[i]
193
  pdf_content += page.extract_text()
194
- logger.info(f"成功讀取: {filename}")
195
  except Exception as e:
196
- logger.error(f"讀取 {filename} 失敗: {str(e)}")
197
 
198
  MAX_CONTENT_LENGTH = 15000
199
  if len(pdf_content) > MAX_CONTENT_LENGTH:
200
  pdf_content = pdf_content[:MAX_CONTENT_LENGTH]
201
 
202
- logger.info(f"PDF 內容總長度: {len(pdf_content)} 字元")
203
-
204
  # ============== LINE Bot 設定 ==============
205
  line_bot_api = LineBotApi(os.getenv("CHANNEL_ACCESS_TOKEN"))
206
  line_handler = WebhookHandler(os.getenv("CHANNEL_SECRET"))
207
- working_status = True
208
-
209
- # ============== 請求速率控制 ==============
210
- last_request_time = {}
211
- REQUEST_COOLDOWN = 2
212
 
213
  # ============== 預約系統 ==============
214
  user_booking_state = {}
 
 
215
 
216
  class BookingState:
217
  IDLE = "idle"
@@ -270,19 +223,19 @@ def parse_date(date_str):
270
  date = datetime.strptime(match.group(0), fmt)
271
 
272
  if date.date() < today.date():
273
- return None, "❌ 日期不能是過���的時間,請重新輸入。"
274
 
275
  return date.strftime("%Y-%m-%d"), None
276
  except ValueError:
277
  pass
278
 
279
- return None, "❌ 日期格式不正確,請重新輸入。\n例如:明天、2025-12-30"
280
 
281
  def validate_time(time_str):
282
  time_keywords = ["點", "時", ":", "-", "到", "至", "~"]
283
  if any(kw in time_str for kw in time_keywords):
284
  return True, None
285
- return False, "❌ 時段格式不正確。\n例如:09:00-11:00、下午2點到4點"
286
 
287
  def parse_people(people_str):
288
  numbers = re.findall(r'\d+', people_str)
@@ -290,18 +243,15 @@ def parse_people(people_str):
290
  count = int(numbers[0])
291
  if 1 <= count <= 10:
292
  return count, None
293
- else:
294
- return None, "❌ 人數必須在 1-10 人之間。"
295
- return None, "❌ 請輸入有效的人數。\n例如:2人、3、5位"
296
 
297
  def validate_room(room_str):
298
  room_normalized = room_str.strip().upper()
299
-
300
  for room in AVAILABLE_ROOMS:
301
  if room.upper() in room_normalized or room_normalized in room.upper():
302
  return room, None
303
-
304
- return None, f"❌ 請選擇有效的琴房:{', '.join(AVAILABLE_ROOMS)}"
305
 
306
  def save_booking_to_file(user_id, booking_data):
307
  bookings_dir = pathlib.Path("bookings")
@@ -313,7 +263,7 @@ def save_booking_to_file(user_id, booking_data):
313
  with open(booking_file, 'r', encoding='utf-8') as f:
314
  try:
315
  bookings = json.load(f)
316
- except json.JSONDecodeError:
317
  bookings = []
318
  else:
319
  bookings = []
@@ -332,18 +282,16 @@ def save_booking_to_file(user_id, booking_data):
332
  "calendar_link": None
333
  }
334
 
335
- # 建立 Google Calendar 事件
336
  calendar_link = create_calendar_event(booking_data, booking_id)
337
  if calendar_link:
338
  booking_record["calendar_link"] = calendar_link
339
- logger.info(f"預約已同步至 Google Calendar: {booking_id}")
340
 
341
  bookings.append(booking_record)
342
 
343
  with open(booking_file, 'w', encoding='utf-8') as f:
344
  json.dump(bookings, f, ensure_ascii=False, indent=2)
345
 
346
- logger.info(f"預約已儲存: {booking_id}")
347
  return booking_id, calendar_link
348
 
349
  def handle_booking_flow(user_id, user_message):
@@ -357,21 +305,15 @@ def handle_booking_flow(user_id, user_message):
357
 
358
  booking["last_update"] = time.time()
359
 
360
- if user_message in ["取消", "取消預約", "重來", "重新開始"]:
361
  reset_booking(user_id)
362
- return "✅ 已取消預約流程。\n\n如需重新預約,請輸入「預約」。"
363
 
364
  if current_state == BookingState.IDLE:
365
  if is_booking_keyword(user_message):
366
  booking["state"] = BookingState.ASKING_DATE
367
- return (
368
- "🎹 歡迎使用琴房預約系統!\n\n"
369
- "📅 請問您想要預約哪一天呢?\n"
370
- "例如:明天、12/26、2025/12/30\n\n"
371
- "💡 隨時輸入「取消」可取消預約"
372
- )
373
- else:
374
- return None
375
 
376
  elif current_state == BookingState.ASKING_DATE:
377
  parsed_date, error = parse_date(user_message)
@@ -379,11 +321,7 @@ def handle_booking_flow(user_id, user_message):
379
  return error
380
  booking["date"] = parsed_date
381
  booking["state"] = BookingState.ASKING_TIME
382
- return (
383
- f"✅ 預約日期:{parsed_date}\n\n"
384
- f"⏰ 請問您想要預約哪個時段呢?\n"
385
- f"例如:09:00-11:00、下午2點到4點"
386
- )
387
 
388
  elif current_state == BookingState.ASKING_TIME:
389
  is_valid, error = validate_time(user_message)
@@ -391,11 +329,7 @@ def handle_booking_flow(user_id, user_message):
391
  return error
392
  booking["time"] = user_message
393
  booking["state"] = BookingState.ASKING_PEOPLE
394
- return (
395
- f"✅ 預約時段:{user_message}\n\n"
396
- f"👥 請問有幾位使用呢?\n"
397
- f"例如:1人、2人"
398
- )
399
 
400
  elif current_state == BookingState.ASKING_PEOPLE:
401
  people_count, error = parse_people(user_message)
@@ -403,75 +337,54 @@ def handle_booking_flow(user_id, user_message):
403
  return error
404
  booking["people"] = people_count
405
  booking["state"] = BookingState.ASKING_ROOM
406
- return (
407
- f"✅ 使用人數:{people_count}人\n\n"
408
- f"🎹 請問您想使用哪個琴房呢?\n"
409
- f"可選擇:{', '.join(AVAILABLE_ROOMS)}"
410
- )
411
 
412
  elif current_state == BookingState.ASKING_ROOM:
413
  room, error = validate_room(user_message)
414
  if error:
415
  return error
416
-
417
- # 檢查時段是否可用
418
- available, conflict_msg = check_time_slot_available(
419
- booking["date"],
420
- booking["time"],
421
- room
422
- )
423
-
424
- if not available:
425
- return f"⚠️ {conflict_msg}\n\n請重新選擇琴房或輸入「取消」重新預約。"
426
-
427
  booking["room"] = room
428
  booking["state"] = BookingState.CONFIRMING
429
 
430
- summary = (
431
- f"📋 請確認您的預約資訊:\n"
432
- f"{'='*25}\n"
433
- f"📅 日期:{booking['date']}\n"
434
- f"⏰ 時段:{booking['time']}\n"
435
- f"👥 人數:{booking['people']}人\n"
436
- f"🎹 琴房:{booking['room']}\n"
437
- f"{'='*25}\n\n"
438
- f"✅ 請輸入「確認」送出預約\n"
439
- f"❌ 輸入「取消」重新填寫"
440
  )
441
- return summary
442
 
443
  elif current_state == BookingState.CONFIRMING:
444
- if user_message in ["確認", "確定", "送出", "ok", "OK"]:
445
  try:
446
  booking_id, calendar_link = save_booking_to_file(user_id, booking)
447
 
448
- summary = (
449
  f"🎉 預約成功!\n"
450
- f"{'='*25}\n"
451
- f"📅 日期:{booking['date']}\n"
452
- f"⏰ 時段:{booking['time']}\n"
453
- f"👥 人數:{booking['people']}人\n"
454
- f"🎹 琴房:{booking['room']}\n"
455
- f"📝 預約編號:{booking_id}\n"
456
  )
457
 
458
  if calendar_link:
459
- summary += f"📆 已加入 Google Calendar\n"
460
 
461
- summary += (
462
- f"{'='*25}\n\n"
463
- f"✅ 我們已收到您的預約!\n"
464
- f"服務專員將盡快與您聯繫確認。"
465
- )
466
 
467
  reset_booking(user_id)
468
- return summary
469
  except Exception as e:
470
- logger.error(f"儲存預約失敗: {str(e)}")
471
  reset_booking(user_id)
472
- return "❌ 預約儲存失敗,請稍後再試。"
473
  else:
474
- return "請輸入「確認」來完成預約,或輸入「取消」重新開始。"
475
 
476
  return None
477
 
@@ -486,162 +399,121 @@ app.add_middleware(
486
  allow_headers=["*"],
487
  )
488
 
489
- @app.get("/")
490
  def root():
491
  return {
492
- "title": "Line Bot - 琴房預約系統",
493
  "status": "running",
494
- "version": "3.0",
495
  "features": {
496
  "booking": True,
497
  "google_calendar": calendar_service is not None,
498
  "ai_qa": ai_enabled,
499
  "pdf_loaded": len(pdf_content) > 0
500
  },
501
- "endpoints": {
502
- "查看所有預約": "/bookings",
503
- "下載預約資料": "/bookings/download",
504
- "查看最新預約": "/bookings/latest",
505
- "管理介面": "/admin"
506
- }
507
  }
508
 
509
- @app.get("/admin")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
510
  def admin_panel():
511
- """簡單的管理介面"""
512
  bookings_file = pathlib.Path("bookings/bookings.json")
513
  bookings = []
514
 
515
  if bookings_file.exists():
516
  with open(bookings_file, 'r', encoding='utf-8') as f:
517
- bookings = json.load(f)
518
-
519
- # 生成 HTML
520
- html = """
521
- <!DOCTYPE html>
522
- <html>
523
- <head>
524
- <meta charset="UTF-8">
525
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
526
- <title>琴房預約管理</title>
527
- <style>
528
- body {
529
- font-family: Arial, sans-serif;
530
- max-width: 1200px;
531
- margin: 0 auto;
532
- padding: 20px;
533
- background: #f5f5f5;
534
- }
535
- h1 {
536
- color: #333;
537
- text-align: center;
538
- }
539
- .stats {
540
- display: flex;
541
- gap: 20px;
542
- margin-bottom: 30px;
543
- }
544
- .stat-card {
545
- flex: 1;
546
- background: white;
547
- padding: 20px;
548
- border-radius: 8px;
549
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
550
- }
551
- .stat-card h3 {
552
- margin: 0 0 10px 0;
553
- color: #666;
554
- font-size: 14px;
555
- }
556
- .stat-card .number {
557
- font-size: 32px;
558
- font-weight: bold;
559
- color: #4CAF50;
560
- }
561
- table {
562
- width: 100%;
563
- background: white;
564
- border-collapse: collapse;
565
- border-radius: 8px;
566
- overflow: hidden;
567
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
568
- }
569
- th {
570
- background: #4CAF50;
571
- color: white;
572
- padding: 12px;
573
- text-align: left;
574
- }
575
- td {
576
- padding: 12px;
577
- border-bottom: 1px solid #ddd;
578
- }
579
- tr:hover {
580
- background: #f9f9f9;
581
- }
582
- .status-pending {
583
- color: #FF9800;
584
- font-weight: bold;
585
- }
586
- .calendar-link {
587
- color: #2196F3;
588
- text-decoration: none;
589
- }
590
- .calendar-link:hover {
591
- text-decoration: underline;
592
- }
593
- .refresh-btn {
594
- background: #4CAF50;
595
- color: white;
596
- padding: 10px 20px;
597
- border: none;
598
- border-radius: 4px;
599
- cursor: pointer;
600
- font-size: 16px;
601
- margin-bottom: 20px;
602
- }
603
- .refresh-btn:hover {
604
- background: #45a049;
605
- }
606
- </style>
607
- </head>
608
- <body>
609
- <h1>🎹 琴房預約管理系統</h1>
610
-
611
  <div class="stats">
612
  <div class="stat-card">
613
  <h3>總預約數</h3>
614
- <div class="number">""" + str(len(bookings)) + """</div>
615
  </div>
616
  <div class="stat-card">
617
  <h3>待確認</h3>
618
- <div class="number">""" + str(len([b for b in bookings if b.get('status') == 'pending'])) + """</div>
619
  </div>
620
  <div class="stat-card">
621
  <h3>Google Calendar</h3>
622
- <div class="number">""" + ('✓' if calendar_service else '✗') + """</div>
623
  </div>
624
  </div>
625
-
626
  <button class="refresh-btn" onclick="location.reload()">🔄 重新整理</button>
627
-
628
  <table>
629
  <thead>
630
  <tr>
631
- <th>預約編號</th>
632
  <th>日期</th>
633
  <th>時段</th>
634
  <th>人數</th>
635
  <th>琴房</th>
636
- <th>狀態</th>
637
  <th>建立時間</th>
638
  <th>日曆</th>
639
  </tr>
640
  </thead>
641
- <tbody>
642
- """
643
 
644
- # 按時間倒序排列
645
  for booking in reversed(bookings):
646
  calendar_icon = '📆' if booking.get('calendar_link') else '❌'
647
  html += f"""
@@ -651,89 +523,28 @@ def admin_panel():
651
  <td>{booking['time']}</td>
652
  <td>{booking['people']}人</td>
653
  <td>{booking['room']}</td>
654
- <td class="status-pending">{booking['status']}</td>
655
  <td>{booking['created_at']}</td>
656
- <td>
657
- """
658
  if booking.get('calendar_link'):
659
- html += f'<a href="{booking["calendar_link"]}" target="_blank" class="calendar-link">{calendar_icon} 查看</a>'
660
  else:
661
  html += calendar_icon
662
- html += """
663
- </td>
664
- </tr>
665
- """
666
 
667
  html += """
668
  </tbody>
669
  </table>
670
- </body>
671
- </html>
672
- """
673
 
674
- from fastapi.responses import HTMLResponse
675
- return HTMLResponse(content=html)
676
-
677
- @app.get("/bookings")
678
- def get_bookings():
679
- """查看所有預約(管理用)"""
680
- bookings_file = pathlib.Path("bookings/bookings.json")
681
- if bookings_file.exists():
682
- with open(bookings_file, 'r', encoding='utf-8') as f:
683
- bookings = json.load(f)
684
- return {"total": len(bookings), "bookings": bookings}
685
- return {"total": 0, "bookings": []}
686
-
687
- @app.get("/bookings/download")
688
- def download_bookings():
689
- """下載預約資料(JSON 格式)"""
690
- bookings_file = pathlib.Path("bookings/bookings.json")
691
- if bookings_file.exists():
692
- with open(bookings_file, 'r', encoding='utf-8') as f:
693
- return json.load(f)
694
- return []
695
-
696
- @app.get("/bookings/latest")
697
- def get_latest_booking():
698
- """查看最新一筆預約"""
699
- bookings_file = pathlib.Path("bookings/bookings.json")
700
- if bookings_file.exists():
701
- with open(bookings_file, 'r', encoding='utf-8') as f:
702
- bookings = json.load(f)
703
- if bookings:
704
- return bookings[-1] # 返回最後一筆
705
- return {"message": "尚無預約資料"}
706
-
707
- @app.delete("/bookings/{booking_id}")
708
- def delete_booking(booking_id: str):
709
- """刪除指定預約"""
710
- bookings_file = pathlib.Path("bookings/bookings.json")
711
- if bookings_file.exists():
712
- with open(bookings_file, 'r', encoding='utf-8') as f:
713
- bookings = json.load(f)
714
-
715
- # 找到並移除指定預約
716
- updated_bookings = [b for b in bookings if b['booking_id'] != booking_id]
717
-
718
- if len(updated_bookings) < len(bookings):
719
- with open(bookings_file, 'w', encoding='utf-8') as f:
720
- json.dump(updated_bookings, f, ensure_ascii=False, indent=2)
721
- return {"message": f"已刪除預約 {booking_id}"}
722
- else:
723
- return {"message": f"找不到預約 {booking_id}"}
724
- return {"message": "沒有預約資料"}
725
 
726
  @app.post("/webhook")
727
- async def webhook(
728
- request: Request,
729
- background_tasks: BackgroundTasks,
730
- x_line_signature=Header(None),
731
- ):
732
  body = await request.body()
733
  try:
734
- background_tasks.add_task(
735
- line_handler.handle, body.decode("utf-8"), x_line_signature
736
- )
737
  except InvalidSignatureError:
738
  raise HTTPException(status_code=400, detail="Invalid signature")
739
  return "ok"
@@ -744,72 +555,42 @@ def handle_message(event):
744
  user_id = event.source.user_id
745
 
746
  if user_message == "再見":
747
- line_bot_api.reply_message(
748
- event.reply_token,
749
- TextSendMessage(text="👋 再見!期待下次為您服務!")
750
- )
751
  return
752
 
753
  # 速率限制
754
  current_time = time.time()
755
  if user_id in last_request_time:
756
- elapsed = current_time - last_request_time[user_id]
757
- if elapsed < REQUEST_COOLDOWN:
758
- line_bot_api.reply_message(
759
- event.reply_token,
760
- TextSendMessage(text=f"⏰ 請稍等 {REQUEST_COOLDOWN - int(elapsed)} 秒")
761
- )
762
  return
763
-
764
  last_request_time[user_id] = current_time
765
 
766
- # 處理預約流程
767
  booking_response = handle_booking_flow(user_id, user_message)
768
-
769
  if booking_response:
770
- line_bot_api.reply_message(
771
- event.reply_token,
772
- TextSendMessage(text=booking_response)
773
- )
774
  return
775
 
776
  # AI 問答
777
  if ai_enabled:
778
  try:
779
- if pdf_content:
780
- full_prompt = f"""參考以下資料回答:
781
-
782
- {pdf_content}
783
-
784
- 問題:{user_message}
785
-
786
- 請簡潔回答。"""
787
- else:
788
- full_prompt = user_message
789
-
790
- response = client.models.generate_content(
791
- model="gemini-2.5-flash", # 使用最新版本
792
- contents=full_prompt
793
- )
794
-
795
- out = response.text if response and response.text else "抱歉,我無法回答這個問題。"
796
-
797
  except Exception as e:
798
- logger.error(f"AI 錯誤: {str(e)}")
799
- out = "系統忙碌中,請稍後再試。"
800
  else:
801
- out = "AI 問答功能未啟用。如需預約請輸入「預約」。"
802
 
803
- line_bot_api.reply_message(
804
- event.reply_token,
805
- TextSendMessage(text=out)
806
- )
807
 
808
  if __name__ == "__main__":
809
  import uvicorn
810
  logger.info("="*50)
811
- logger.info("啟動琴房預約系統 with Google Calendar")
812
- logger.info(f"Google Calendar: {'已啟用' if calendar_service else '未啟用'}")
813
- logger.info(f"AI 問答: {'已啟用' if ai_enabled else '未啟用'}")
 
814
  logger.info("="*50)
815
  uvicorn.run("main:app", host="0.0.0.0", port=7860, reload=True)
 
1
  import json, os, glob, pathlib, time, re
2
+ from fastapi import FastAPI, Request, Header, BackgroundTasks, HTTPException
3
  from fastapi.middleware.cors import CORSMiddleware
4
+ from fastapi.responses import HTMLResponse, JSONResponse
5
  from google import genai
6
  from linebot import LineBotApi, WebhookHandler
7
  from linebot.exceptions import InvalidSignatureError
8
  from linebot.models import MessageEvent, TextMessage, TextSendMessage
9
  import PyPDF2
10
  import logging
 
11
  from datetime import datetime, timedelta
12
+
13
+ # 嘗試導入 Google Calendar 相關套件
14
+ try:
15
+ from google.oauth2 import service_account
16
+ from googleapiclient.discovery import build
17
+ from googleapiclient.errors import HttpError
18
+ CALENDAR_AVAILABLE = True
19
+ except ImportError:
20
+ CALENDAR_AVAILABLE = False
21
+ logging.warning("Google Calendar 套件未安裝")
22
 
23
  # ============== 設定日誌 ==============
24
  logging.basicConfig(level=logging.INFO)
 
26
 
27
  # ============== Google Calendar 設定 ==============
28
  SCOPES = ['https://www.googleapis.com/auth/calendar']
29
+ calendar_service = None
30
 
31
+ if CALENDAR_AVAILABLE:
32
+ def get_calendar_service():
33
+ try:
34
+ service_account_json = os.getenv('GOOGLE_SERVICE_ACCOUNT_JSON')
 
 
 
 
 
 
 
 
 
 
35
 
36
+ if service_account_json:
37
+ service_account_info = json.loads(service_account_json)
38
+ credentials = service_account.Credentials.from_service_account_info(
39
+ service_account_info, scopes=SCOPES)
40
+ service = build('calendar', 'v3', credentials=credentials)
41
+ logger.info("✅ Google Calendar 服務已建立")
42
+ return service
43
+ else:
44
+ logger.warning("⚠️ 未設定 GOOGLE_SERVICE_ACCOUNT_JSON")
45
+ return None
46
+ except json.JSONDecodeError as e:
47
+ logger.error(f"❌ JSON 格式錯誤: {str(e)}")
48
+ return None
49
+ except Exception as e:
50
+ logger.error(f"❌ 建立 Calendar 服務失敗: {str(e)}")
51
+ return None
52
 
53
+ calendar_service = get_calendar_service()
54
+
55
+ CALENDAR_ID = os.getenv('GOOGLE_CALENDAR_ID', 'primary')
56
 
57
  def create_calendar_event(booking_data, booking_id):
 
58
  if not calendar_service:
 
59
  return None
60
 
61
  try:
 
62
  date_str = booking_data['date']
63
  time_str = booking_data['time']
64
 
 
65
  time_patterns = [
66
+ r'(\d{1,2}):(\d{2})\s*[-~到至]\s*(\d{1,2}):(\d{2})',
67
+ r'(\d{1,2})\s*點\s*[-~到至]\s*(\d{1,2})\s*點',
68
  ]
69
 
70
  start_time = "09:00"
 
82
  end_time = f"{int(groups[1]):02d}:00"
83
  break
84
 
 
85
  start_datetime = f"{date_str}T{start_time}:00"
86
  end_datetime = f"{date_str}T{end_time}:00"
87
 
 
88
  event = {
89
  'summary': f'🎹 琴房預約 - {booking_data["room"]}',
90
  'description': (
 
101
  'dateTime': end_datetime,
102
  'timeZone': 'Asia/Taipei',
103
  },
104
+ 'colorId': '9',
105
  'reminders': {
106
  'useDefault': False,
107
  'overrides': [
108
+ {'method': 'popup', 'minutes': 60},
109
+ {'method': 'popup', 'minutes': 10},
110
  ],
111
  },
112
  }
113
 
 
114
  created_event = calendar_service.events().insert(
115
  calendarId=CALENDAR_ID,
116
  body=event
117
  ).execute()
118
 
119
+ logger.info(f"Google Calendar 事件已建立: {created_event.get('id')}")
120
  return created_event.get('htmlLink')
121
 
 
 
 
122
  except Exception as e:
123
+ logger.error(f"建立 Calendar 事件失敗: {str(e)}")
124
  return None
125
 
126
+ # ============== Gemini AI 設定 ==============
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
128
+ ai_enabled = False
129
+
130
  if GOOGLE_API_KEY:
131
+ try:
132
+ client = genai.Client(api_key=GOOGLE_API_KEY)
133
+ ai_enabled = True
134
+ logger.info("✅ Gemini AI 已啟用")
135
+ except Exception as e:
136
+ logger.error(f"❌ Gemini AI 初始化失敗: {str(e)}")
137
  else:
138
+ logger.warning("⚠️ 未設定 GOOGLE_API_KEY")
 
139
 
140
+ # ============== 讀取 PDF ==============
141
  files = glob.glob('docs/*.pdf')
142
  pdf_content = ''
143
 
144
+ if files:
 
 
145
  logger.info(f"找到 {len(files)} 個 PDF 檔案")
146
  for filename in files:
147
  try:
148
  with open(filename, 'rb') as pdf_file:
149
  pdf_reader = PyPDF2.PdfReader(pdf_file)
150
+ for page in pdf_reader.pages:
 
151
  pdf_content += page.extract_text()
152
+ logger.info(f"成功讀取: {filename}")
153
  except Exception as e:
154
+ logger.error(f"讀取 {filename} 失敗: {str(e)}")
155
 
156
  MAX_CONTENT_LENGTH = 15000
157
  if len(pdf_content) > MAX_CONTENT_LENGTH:
158
  pdf_content = pdf_content[:MAX_CONTENT_LENGTH]
159
 
 
 
160
  # ============== LINE Bot 設定 ==============
161
  line_bot_api = LineBotApi(os.getenv("CHANNEL_ACCESS_TOKEN"))
162
  line_handler = WebhookHandler(os.getenv("CHANNEL_SECRET"))
 
 
 
 
 
163
 
164
  # ============== 預約系統 ==============
165
  user_booking_state = {}
166
+ last_request_time = {}
167
+ REQUEST_COOLDOWN = 2
168
 
169
  class BookingState:
170
  IDLE = "idle"
 
223
  date = datetime.strptime(match.group(0), fmt)
224
 
225
  if date.date() < today.date():
226
+ return None, "❌ 日期不能是過去的時間"
227
 
228
  return date.strftime("%Y-%m-%d"), None
229
  except ValueError:
230
  pass
231
 
232
+ return None, "❌ 日期格式不正確\n例如:明天、12/26"
233
 
234
  def validate_time(time_str):
235
  time_keywords = ["點", "時", ":", "-", "到", "至", "~"]
236
  if any(kw in time_str for kw in time_keywords):
237
  return True, None
238
+ return False, "❌ 時段格式不正確\n例如:09:00-11:00"
239
 
240
  def parse_people(people_str):
241
  numbers = re.findall(r'\d+', people_str)
 
243
  count = int(numbers[0])
244
  if 1 <= count <= 10:
245
  return count, None
246
+ return None, "❌ 人數必須在 1-10 人之間"
247
+ return None, "❌ 請輸入有效的人數\n例如:2人、3"
 
248
 
249
  def validate_room(room_str):
250
  room_normalized = room_str.strip().upper()
 
251
  for room in AVAILABLE_ROOMS:
252
  if room.upper() in room_normalized or room_normalized in room.upper():
253
  return room, None
254
+ return None, f"❌ 請選擇:{', '.join(AVAILABLE_ROOMS)}"
 
255
 
256
  def save_booking_to_file(user_id, booking_data):
257
  bookings_dir = pathlib.Path("bookings")
 
263
  with open(booking_file, 'r', encoding='utf-8') as f:
264
  try:
265
  bookings = json.load(f)
266
+ except:
267
  bookings = []
268
  else:
269
  bookings = []
 
282
  "calendar_link": None
283
  }
284
 
 
285
  calendar_link = create_calendar_event(booking_data, booking_id)
286
  if calendar_link:
287
  booking_record["calendar_link"] = calendar_link
 
288
 
289
  bookings.append(booking_record)
290
 
291
  with open(booking_file, 'w', encoding='utf-8') as f:
292
  json.dump(bookings, f, ensure_ascii=False, indent=2)
293
 
294
+ logger.info(f"預約已儲存: {booking_id}")
295
  return booking_id, calendar_link
296
 
297
  def handle_booking_flow(user_id, user_message):
 
305
 
306
  booking["last_update"] = time.time()
307
 
308
+ if user_message in ["取消", "取消預約", "重來"]:
309
  reset_booking(user_id)
310
+ return "✅ 已取消預約\n輸入「預約」可重新開始"
311
 
312
  if current_state == BookingState.IDLE:
313
  if is_booking_keyword(user_message):
314
  booking["state"] = BookingState.ASKING_DATE
315
+ return "🎹 琴房預約系統\n\n📅 請問預約日期?\n例如:明天、12/26"
316
+ return None
 
 
 
 
 
 
317
 
318
  elif current_state == BookingState.ASKING_DATE:
319
  parsed_date, error = parse_date(user_message)
 
321
  return error
322
  booking["date"] = parsed_date
323
  booking["state"] = BookingState.ASKING_TIME
324
+ return f"✅ 日期:{parsed_date}\n\n⏰ 請問時段?\n例如:09:00-11:00"
 
 
 
 
325
 
326
  elif current_state == BookingState.ASKING_TIME:
327
  is_valid, error = validate_time(user_message)
 
329
  return error
330
  booking["time"] = user_message
331
  booking["state"] = BookingState.ASKING_PEOPLE
332
+ return f"✅ 時段:{user_message}\n\n👥 請問人數?\n例如:2人"
 
 
 
 
333
 
334
  elif current_state == BookingState.ASKING_PEOPLE:
335
  people_count, error = parse_people(user_message)
 
337
  return error
338
  booking["people"] = people_count
339
  booking["state"] = BookingState.ASKING_ROOM
340
+ return f"✅ 人數:{people_count}人\n\n🎹 請選擇琴房:\n{', '.join(AVAILABLE_ROOMS)}"
 
 
 
 
341
 
342
  elif current_state == BookingState.ASKING_ROOM:
343
  room, error = validate_room(user_message)
344
  if error:
345
  return error
 
 
 
 
 
 
 
 
 
 
 
346
  booking["room"] = room
347
  booking["state"] = BookingState.CONFIRMING
348
 
349
+ return (
350
+ f"📋 確認預約資訊:\n"
351
+ f"{'='*20}\n"
352
+ f"📅 {booking['date']}\n"
353
+ f"⏰ {booking['time']}\n"
354
+ f"👥 {booking['people']}人\n"
355
+ f"🎹 {booking['room']}\n"
356
+ f"{'='*20}\n\n"
357
+ f"輸入「確認」送出"
 
358
  )
 
359
 
360
  elif current_state == BookingState.CONFIRMING:
361
+ if user_message in ["確認", "確定", "ok", "OK"]:
362
  try:
363
  booking_id, calendar_link = save_booking_to_file(user_id, booking)
364
 
365
+ result = (
366
  f"🎉 預約成功!\n"
367
+ f"{'='*20}\n"
368
+ f"📅 {booking['date']}\n"
369
+ f"⏰ {booking['time']}\n"
370
+ f"👥 {booking['people']}人\n"
371
+ f"🎹 {booking['room']}\n"
372
+ f"📝 {booking_id}\n"
373
  )
374
 
375
  if calendar_link:
376
+ result += f"📆 已加入 Google Calendar\n"
377
 
378
+ result += f"{'='*20}\n✅ 已收到預約!"
 
 
 
 
379
 
380
  reset_booking(user_id)
381
+ return result
382
  except Exception as e:
383
+ logger.error(f" 儲存失敗: {str(e)}")
384
  reset_booking(user_id)
385
+ return "❌ 預約失敗,請稍後再試"
386
  else:
387
+ return "請輸入「確認」完成預約"
388
 
389
  return None
390
 
 
399
  allow_headers=["*"],
400
  )
401
 
402
+ @app.get("/", response_class=JSONResponse)
403
  def root():
404
  return {
405
+ "title": "琴房預約系統",
406
  "status": "running",
407
+ "version": "3.1",
408
  "features": {
409
  "booking": True,
410
  "google_calendar": calendar_service is not None,
411
  "ai_qa": ai_enabled,
412
  "pdf_loaded": len(pdf_content) > 0
413
  },
414
+ "endpoints": [
415
+ "/admin - 管理介面",
416
+ "/bookings - 查看預約",
417
+ "/bookings/latest - 最新預約"
418
+ ]
 
419
  }
420
 
421
+ @app.get("/bookings", response_class=JSONResponse)
422
+ def get_bookings():
423
+ """查看所有預約"""
424
+ bookings_file = pathlib.Path("bookings/bookings.json")
425
+ if bookings_file.exists():
426
+ with open(bookings_file, 'r', encoding='utf-8') as f:
427
+ try:
428
+ bookings = json.load(f)
429
+ return {"total": len(bookings), "bookings": bookings}
430
+ except:
431
+ return {"total": 0, "bookings": [], "error": "讀取失敗"}
432
+ return {"total": 0, "bookings": []}
433
+
434
+ @app.get("/bookings/latest", response_class=JSONResponse)
435
+ def get_latest_booking():
436
+ """最新預約"""
437
+ bookings_file = pathlib.Path("bookings/bookings.json")
438
+ if bookings_file.exists():
439
+ with open(bookings_file, 'r', encoding='utf-8') as f:
440
+ try:
441
+ bookings = json.load(f)
442
+ if bookings:
443
+ return bookings[-1]
444
+ except:
445
+ pass
446
+ return {"message": "無預約資料"}
447
+
448
+ @app.get("/admin", response_class=HTMLResponse)
449
  def admin_panel():
450
+ """管理介面"""
451
  bookings_file = pathlib.Path("bookings/bookings.json")
452
  bookings = []
453
 
454
  if bookings_file.exists():
455
  with open(bookings_file, 'r', encoding='utf-8') as f:
456
+ try:
457
+ bookings = json.load(f)
458
+ except:
459
+ pass
460
+
461
+ html = f"""<!DOCTYPE html>
462
+ <html>
463
+ <head>
464
+ <meta charset="UTF-8">
465
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
466
+ <title>琴房預約管理</title>
467
+ <style>
468
+ * {{ margin: 0; padding: 0; box-sizing: border-box; }}
469
+ body {{ font-family: 'Segoe UI', Arial, sans-serif; background: #f5f5f5; padding: 20px; }}
470
+ .container {{ max-width: 1200px; margin: 0 auto; }}
471
+ h1 {{ text-align: center; color: #333; margin-bottom: 30px; }}
472
+ .stats {{ display: flex; gap: 20px; margin-bottom: 30px; flex-wrap: wrap; }}
473
+ .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); }}
474
+ .stat-card h3 {{ color: #666; font-size: 14px; margin-bottom: 10px; }}
475
+ .stat-card .number {{ font-size: 32px; font-weight: bold; color: #4CAF50; }}
476
+ table {{ width: 100%; background: white; border-collapse: collapse; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
477
+ th {{ background: #4CAF50; color: white; padding: 12px; text-align: left; }}
478
+ td {{ padding: 12px; border-bottom: 1px solid #ddd; }}
479
+ tr:hover {{ background: #f9f9f9; }}
480
+ .refresh-btn {{ background: #4CAF50; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; margin-bottom: 20px; }}
481
+ .refresh-btn:hover {{ background: #45a049; }}
482
+ @media (max-width: 768px) {{ .stats {{ flex-direction: column; }} }}
483
+ </style>
484
+ </head>
485
+ <body>
486
+ <div class="container">
487
+ <h1>🎹 琴房預約管理</h1>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488
  <div class="stats">
489
  <div class="stat-card">
490
  <h3>總預約數</h3>
491
+ <div class="number">{len(bookings)}</div>
492
  </div>
493
  <div class="stat-card">
494
  <h3>待確認</h3>
495
+ <div class="number">{len([b for b in bookings if b.get('status') == 'pending'])}</div>
496
  </div>
497
  <div class="stat-card">
498
  <h3>Google Calendar</h3>
499
+ <div class="number">{'✓' if calendar_service else '✗'}</div>
500
  </div>
501
  </div>
 
502
  <button class="refresh-btn" onclick="location.reload()">🔄 重新整理</button>
 
503
  <table>
504
  <thead>
505
  <tr>
506
+ <th>編號</th>
507
  <th>日期</th>
508
  <th>時段</th>
509
  <th>人數</th>
510
  <th>琴房</th>
 
511
  <th>建立時間</th>
512
  <th>日曆</th>
513
  </tr>
514
  </thead>
515
+ <tbody>"""
 
516
 
 
517
  for booking in reversed(bookings):
518
  calendar_icon = '📆' if booking.get('calendar_link') else '❌'
519
  html += f"""
 
523
  <td>{booking['time']}</td>
524
  <td>{booking['people']}人</td>
525
  <td>{booking['room']}</td>
 
526
  <td>{booking['created_at']}</td>
527
+ <td>"""
 
528
  if booking.get('calendar_link'):
529
+ html += f'<a href="{booking["calendar_link"]}" target="_blank">{calendar_icon}</a>'
530
  else:
531
  html += calendar_icon
532
+ html += "</td></tr>"
 
 
 
533
 
534
  html += """
535
  </tbody>
536
  </table>
537
+ </div>
538
+ </body>
539
+ </html>"""
540
 
541
+ return html
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
542
 
543
  @app.post("/webhook")
544
+ async def webhook(request: Request, background_tasks: BackgroundTasks, x_line_signature=Header(None)):
 
 
 
 
545
  body = await request.body()
546
  try:
547
+ background_tasks.add_task(line_handler.handle, body.decode("utf-8"), x_line_signature)
 
 
548
  except InvalidSignatureError:
549
  raise HTTPException(status_code=400, detail="Invalid signature")
550
  return "ok"
 
555
  user_id = event.source.user_id
556
 
557
  if user_message == "再見":
558
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="👋 再見!"))
 
 
 
559
  return
560
 
561
  # 速率限制
562
  current_time = time.time()
563
  if user_id in last_request_time:
564
+ if current_time - last_request_time[user_id] < REQUEST_COOLDOWN:
 
 
 
 
 
565
  return
 
566
  last_request_time[user_id] = current_time
567
 
568
+ # 預約流程
569
  booking_response = handle_booking_flow(user_id, user_message)
 
570
  if booking_response:
571
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text=booking_response))
 
 
 
572
  return
573
 
574
  # AI 問答
575
  if ai_enabled:
576
  try:
577
+ prompt = f"參考資料:{pdf_content}\n\n問題:{user_message}\n\n簡潔回答。" if pdf_content else user_message
578
+ response = client.models.generate_content(model="gemini-2.5-flash", contents=prompt)
579
+ out = response.text if response and response.text else "無法回答"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
580
  except Exception as e:
581
+ logger.error(f"AI錯誤: {str(e)}")
582
+ out = "系統忙碌中"
583
  else:
584
+ out = "請輸入「預約」開始預約琴房"
585
 
586
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text=out))
 
 
 
587
 
588
  if __name__ == "__main__":
589
  import uvicorn
590
  logger.info("="*50)
591
+ logger.info("🎹 琴房預約系統啟動")
592
+ logger.info(f"Google Calendar: {'' if calendar_service else ''}")
593
+ logger.info(f"AI 問答: {'' if ai_enabled else ''}")
594
+ logger.info(f"PDF: {'✅' if pdf_content else '❌'}")
595
  logger.info("="*50)
596
  uvicorn.run("main:app", host="0.0.0.0", port=7860, reload=True)