AlanRex commited on
Commit
2ce0fd5
·
verified ·
1 Parent(s): a1bc610

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +237 -149
main.py CHANGED
@@ -8,17 +8,172 @@ from linebot.models import MessageEvent, TextMessage, TextSendMessage
8
  import PyPDF2
9
  import logging
10
  from datetime import datetime, timedelta
 
 
 
 
11
 
12
  # ============== 設定日誌 ==============
13
  logging.basicConfig(level=logging.INFO)
14
  logger = logging.getLogger(__name__)
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  # ============== API 金鑰檢查 ==============
17
  GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
18
- if not GOOGLE_API_KEY:
19
- raise ValueError("請在環境變數中設定 GOOGLE_API_KEY")
20
-
21
- client = genai.Client(api_key=GOOGLE_API_KEY)
 
 
 
22
 
23
  # ============== 讀取 PDF 內容 ==============
24
  files = glob.glob('docs/*.pdf')
@@ -39,37 +194,16 @@ else:
39
  except Exception as e:
40
  logger.error(f"讀取 {filename} 失敗: {str(e)}")
41
 
42
- # 限制 PDF 內容長度
43
- MAX_CONTENT_LENGTH = 30000
44
  if len(pdf_content) > MAX_CONTENT_LENGTH:
45
  pdf_content = pdf_content[:MAX_CONTENT_LENGTH]
46
 
47
  logger.info(f"PDF 內容總長度: {len(pdf_content)} 字元")
48
 
49
- # ============== Gemini 設定 ==============
50
- # 簡化的 system instruction(不包含 PDF 內容)
51
- base_system_instruction = (
52
- "你是問答助手,使用提供的參考資料回答問題。\n"
53
- "規則:\n"
54
- "1. 只用提供的資料回答\n"
55
- "2. 不知道就說「請聯繫服務專員」\n"
56
- "3. 答案簡潔明瞭"
57
- )
58
-
59
- thinking_config = genai.types.ThinkingConfig(thinking_budget=0)
60
-
61
- # 建立基本配置(不含 system_instruction)
62
- generation_config_basic = genai.types.GenerateContentConfig(
63
- max_output_tokens=1000,
64
- temperature=0.1,
65
- top_p=0.2,
66
- thinking_config=thinking_config
67
- )
68
-
69
  # ============== LINE Bot 設定 ==============
70
  line_bot_api = LineBotApi(os.getenv("CHANNEL_ACCESS_TOKEN"))
71
  line_handler = WebhookHandler(os.getenv("CHANNEL_SECRET"))
72
- working_status = os.getenv("DEFALUT_TALKING", default="true").lower() == "true"
73
 
74
  # ============== 請求速率控制 ==============
75
  last_request_time = {}
@@ -79,7 +213,6 @@ REQUEST_COOLDOWN = 2
79
  user_booking_state = {}
80
 
81
  class BookingState:
82
- """預約狀態管理"""
83
  IDLE = "idle"
84
  ASKING_DATE = "asking_date"
85
  ASKING_TIME = "asking_time"
@@ -90,7 +223,6 @@ class BookingState:
90
  AVAILABLE_ROOMS = ["A琴房", "B琴房", "C琴房", "D琴房", "任意"]
91
 
92
  def init_user_booking(user_id):
93
- """初始化使用者的預約資料"""
94
  user_booking_state[user_id] = {
95
  "state": BookingState.IDLE,
96
  "date": None,
@@ -101,25 +233,20 @@ def init_user_booking(user_id):
101
  }
102
 
103
  def get_user_booking(user_id):
104
- """取得使用者的預約狀態"""
105
  if user_id not in user_booking_state:
106
  init_user_booking(user_id)
107
  return user_booking_state[user_id]
108
 
109
  def reset_booking(user_id):
110
- """重置使用者的預約狀態"""
111
  init_user_booking(user_id)
112
 
113
  def is_booking_keyword(text):
114
- """判斷是否為預約相關關鍵字"""
115
  keywords = ["預約", "預定", "訂位", "訂房", "訂琴房", "借琴房", "租琴房", "我要預約", "想預約"]
116
  return any(keyword in text for keyword in keywords)
117
 
118
  def parse_date(date_str):
119
- """解析並驗證日期"""
120
  today = datetime.now()
121
 
122
- # 處理相對日期
123
  if "今天" in date_str or "今日" in date_str:
124
  return today.strftime("%Y-%m-%d"), None
125
  elif "明天" in date_str or "明日" in date_str:
@@ -127,10 +254,9 @@ def parse_date(date_str):
127
  elif "後天" in date_str:
128
  return (today + timedelta(days=2)).strftime("%Y-%m-%d"), None
129
 
130
- # 處理絕對日期格式
131
  patterns = [
132
- (r'(\d{4})[/-](\d{1,2})[/-](\d{1,2})', '%Y-%m-%d'), # 2025-12-26
133
- (r'(\d{1,2})[/-](\d{1,2})', '%m-%d'), # 12/26
134
  ]
135
 
136
  for pattern, fmt in patterns:
@@ -142,9 +268,8 @@ def parse_date(date_str):
142
  else:
143
  date = datetime.strptime(match.group(0), fmt)
144
 
145
- # 檢查是否為過去的日期
146
  if date.date() < today.date():
147
- return None, "❌ 日期不能是過去的時間,請重新輸入。\n例如:明天、12/26、2025-12-30"
148
 
149
  return date.strftime("%Y-%m-%d"), None
150
  except ValueError:
@@ -153,25 +278,22 @@ def parse_date(date_str):
153
  return None, "❌ 日期格式不正確,請重新輸入。\n例如:明天、12/26、2025-12-30"
154
 
155
  def validate_time(time_str):
156
- """驗證時段格式"""
157
  time_keywords = ["點", "時", ":", "-", "到", "至", "~"]
158
  if any(kw in time_str for kw in time_keywords):
159
  return True, None
160
- return False, "❌ 時段格式不正確,請重新輸入。\n例如:09:00-11:00、下午2點到4點"
161
 
162
  def parse_people(people_str):
163
- """解析並驗證人數"""
164
  numbers = re.findall(r'\d+', people_str)
165
  if numbers:
166
  count = int(numbers[0])
167
  if 1 <= count <= 10:
168
  return count, None
169
  else:
170
- return None, "❌ 人數必須在 1-10 人之間,請重新輸入。"
171
  return None, "❌ 請輸入有效的人數。\n例如:2人、3、5位"
172
 
173
  def validate_room(room_str):
174
- """驗證琴房選擇"""
175
  room_normalized = room_str.strip().upper()
176
 
177
  for room in AVAILABLE_ROOMS:
@@ -181,13 +303,11 @@ def validate_room(room_str):
181
  return None, f"❌ 請選擇有效的琴房:{', '.join(AVAILABLE_ROOMS)}"
182
 
183
  def save_booking_to_file(user_id, booking_data):
184
- """將預約資料儲存到 JSON 檔案"""
185
  bookings_dir = pathlib.Path("bookings")
186
  bookings_dir.mkdir(exist_ok=True)
187
 
188
  booking_file = bookings_dir / "bookings.json"
189
 
190
- # 讀取現有預約
191
  if booking_file.exists():
192
  with open(booking_file, 'r', encoding='utf-8') as f:
193
  try:
@@ -197,47 +317,49 @@ def save_booking_to_file(user_id, booking_data):
197
  else:
198
  bookings = []
199
 
200
- # 新增預約
 
201
  booking_record = {
202
- "booking_id": f"BK{int(time.time())}",
203
  "user_id": user_id,
204
  "date": booking_data["date"],
205
  "time": booking_data["time"],
206
  "people": booking_data["people"],
207
  "room": booking_data["room"],
208
  "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
209
- "status": "pending"
 
210
  }
211
 
 
 
 
 
 
 
212
  bookings.append(booking_record)
213
 
214
- # 寫入檔案
215
  with open(booking_file, 'w', encoding='utf-8') as f:
216
  json.dump(bookings, f, ensure_ascii=False, indent=2)
217
 
218
- logger.info(f"預約已儲存: {booking_record['booking_id']}")
219
- return booking_record["booking_id"]
220
 
221
  def handle_booking_flow(user_id, user_message):
222
- """處理預約流程"""
223
  booking = get_user_booking(user_id)
224
  current_state = booking["state"]
225
 
226
- # 檢查是否超時(10分鐘無互動則重置)
227
  if time.time() - booking["last_update"] > 600:
228
  reset_booking(user_id)
229
  booking = get_user_booking(user_id)
230
  current_state = booking["state"]
231
 
232
- # 更新最後互動時間
233
  booking["last_update"] = time.time()
234
 
235
- # 取消預約
236
  if user_message in ["取消", "取消預約", "重來", "重新開始"]:
237
  reset_booking(user_id)
238
  return "✅ 已取消預約流程。\n\n如需重新預約,請輸入「預約」。"
239
 
240
- # 開始預約流程
241
  if current_state == BookingState.IDLE:
242
  if is_booking_keyword(user_message):
243
  booking["state"] = BookingState.ASKING_DATE
@@ -248,9 +370,8 @@ def handle_booking_flow(user_id, user_message):
248
  "💡 隨時輸入「取消」可取消預約"
249
  )
250
  else:
251
- return None # 不是預約,交給 AI 處理
252
 
253
- # 詢問日期
254
  elif current_state == BookingState.ASKING_DATE:
255
  parsed_date, error = parse_date(user_message)
256
  if error:
@@ -260,10 +381,9 @@ def handle_booking_flow(user_id, user_message):
260
  return (
261
  f"✅ 預約日期:{parsed_date}\n\n"
262
  f"⏰ 請問您想要預約哪個時段呢?\n"
263
- f"例如:09:00-11:00、下午2點到4點、14:00-16:00"
264
  )
265
 
266
- # 詢問時段
267
  elif current_state == BookingState.ASKING_TIME:
268
  is_valid, error = validate_time(user_message)
269
  if not is_valid:
@@ -273,10 +393,9 @@ def handle_booking_flow(user_id, user_message):
273
  return (
274
  f"✅ 預約時段:{user_message}\n\n"
275
  f"👥 請問有幾位使用呢?\n"
276
- f"例如:1人、2人、3位"
277
  )
278
 
279
- # 詢問人數
280
  elif current_state == BookingState.ASKING_PEOPLE:
281
  people_count, error = parse_people(user_message)
282
  if error:
@@ -286,15 +405,24 @@ def handle_booking_flow(user_id, user_message):
286
  return (
287
  f"✅ 使用人數:{people_count}人\n\n"
288
  f"🎹 請問您想使用哪個琴房呢?\n"
289
- f"可選擇:{', '.join(AVAILABLE_ROOMS)}\n\n"
290
- f"💡 如不指定可輸入「任意」"
291
  )
292
 
293
- # 詢問琴房
294
  elif current_state == BookingState.ASKING_ROOM:
295
  room, error = validate_room(user_message)
296
  if error:
297
  return error
 
 
 
 
 
 
 
 
 
 
 
298
  booking["room"] = room
299
  booking["state"] = BookingState.CONFIRMING
300
 
@@ -311,12 +439,10 @@ def handle_booking_flow(user_id, user_message):
311
  )
312
  return summary
313
 
314
- # 確認預約
315
  elif current_state == BookingState.CONFIRMING:
316
- if user_message in ["確認", "確定", "送出", "ok", "OK", "沒錯", "正確"]:
317
- # 儲存預約資料
318
  try:
319
- booking_id = save_booking_to_file(user_id, booking)
320
 
321
  summary = (
322
  f"🎉 預約成功!\n"
@@ -326,19 +452,23 @@ def handle_booking_flow(user_id, user_message):
326
  f"👥 人數:{booking['people']}人\n"
327
  f"🎹 琴房:{booking['room']}\n"
328
  f"📝 預約編號:{booking_id}\n"
 
 
 
 
 
 
329
  f"{'='*25}\n\n"
330
  f"✅ 我們已收到您的預約!\n"
331
- f"服務專員將盡快與您聯繫確認。\n\n"
332
- f"如有任何問題,歡迎隨時詢問!"
333
  )
334
 
335
- # 重置狀態
336
  reset_booking(user_id)
337
  return summary
338
  except Exception as e:
339
  logger.error(f"儲存預約失敗: {str(e)}")
340
  reset_booking(user_id)
341
- return "❌ 預約儲存失敗,請稍後再試或聯繫服務專員。"
342
  else:
343
  return "請輸入「確認」來完成預約,或輸入「取消」重新開始。"
344
 
@@ -358,28 +488,19 @@ app.add_middleware(
358
  @app.get("/")
359
  def root():
360
  return {
361
- "title": "Line Bot with Booking System",
362
  "status": "running",
363
- "version": "2.0",
364
- "pdf_loaded": len(pdf_content) > 0,
365
- "pdf_length": len(pdf_content),
366
- "active_bookings": len(user_booking_state),
367
- "features": ["AI Q&A", "Booking System", "PDF Knowledge Base"]
368
- }
369
-
370
- @app.get("/health")
371
- def health_check():
372
- """健康檢查端點"""
373
- return {
374
- "status": "healthy",
375
- "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
376
- "pdf_loaded": len(pdf_content) > 0,
377
- "api_configured": bool(GOOGLE_API_KEY)
378
  }
379
 
380
  @app.get("/bookings")
381
  def get_bookings():
382
- """查看所有預約(管理用)"""
383
  bookings_file = pathlib.Path("bookings/bookings.json")
384
  if bookings_file.exists():
385
  with open(bookings_file, 'r', encoding='utf-8') as f:
@@ -399,27 +520,18 @@ async def webhook(
399
  line_handler.handle, body.decode("utf-8"), x_line_signature
400
  )
401
  except InvalidSignatureError:
402
- logger.error("無效的簽章")
403
  raise HTTPException(status_code=400, detail="Invalid signature")
404
  return "ok"
405
 
406
  @line_handler.add(MessageEvent, message=TextMessage)
407
  def handle_message(event):
408
- global working_status
409
-
410
- if event.type != "message" or event.message.type != "text":
411
- return
412
-
413
  user_message = event.message.text.strip()
414
  user_id = event.source.user_id
415
 
416
- logger.info(f"收到訊息 - 使用者: {user_id[:8]}..., 內容: {user_message[:50]}")
417
-
418
- # 特殊指令
419
  if user_message == "再見":
420
  line_bot_api.reply_message(
421
  event.reply_token,
422
- TextSendMessage(text="👋 Bye! 期待下次為您服務!")
423
  )
424
  return
425
 
@@ -430,83 +542,59 @@ def handle_message(event):
430
  if elapsed < REQUEST_COOLDOWN:
431
  line_bot_api.reply_message(
432
  event.reply_token,
433
- TextSendMessage(text=f"⏰ 請稍等 {REQUEST_COOLDOWN - int(elapsed)} 秒後再提問")
434
  )
435
  return
436
 
437
  last_request_time[user_id] = current_time
438
 
439
- # ============== 優先處理預約流程 ==============
440
  booking_response = handle_booking_flow(user_id, user_message)
441
 
442
  if booking_response:
443
- # 預約流程有回應,直接返回
444
  line_bot_api.reply_message(
445
  event.reply_token,
446
  TextSendMessage(text=booking_response)
447
  )
448
  return
449
 
450
- # ============== 一般問答(使用 Gemini) ==============
451
- if working_status:
452
- try:
453
- logger.info(f"呼叫 Gemini API - 使用者提問: {user_message[:50]}")
454
-
455
- # 將 PDF 內容和使用者問題組合在 prompt 中
456
  if pdf_content:
457
- full_prompt = f"""參考以下資料回答問題:
458
 
459
- {pdf_content[:15000]}
460
 
461
  問題:{user_message}
462
 
463
- 請根據以上資料回答,如果資料中沒有相關資訊,請說「這個問題我需要確認,請聯繫服務專員」。答案要簡潔明瞭。"""
464
  else:
465
  full_prompt = user_message
466
 
467
  response = client.models.generate_content(
468
- model="gemini-1.5-flash-002", # 使用穩定版本
469
- contents=full_prompt,
470
- config=generation_config_basic
471
  )
472
 
473
- if response and response.text:
474
- out = response.text
475
- logger.info(f"AI 回應成功 - 長度: {len(out)} 字元")
476
- else:
477
- out = "抱歉,我無法回答這個問題。請聯繫服務專員。"
478
- logger.warning("Gemini 回應為空")
479
-
480
- except Exception as e:
481
- error_msg = str(e)
482
- error_type = type(e).__name__
483
- logger.error(f"Gemini API 錯誤 [{error_type}]: {error_msg}")
484
 
485
- # 更詳細的錯誤分類
486
- if "quota" in error_msg.lower() or "limit" in error_msg.lower() or "resource_exhausted" in error_msg.lower():
487
- out = "⚠️ API 使用額度已達上限,請稍後再試。"
488
- elif "429" in error_msg or "rate" in error_msg.lower():
489
- out = "⚠️ 請求過於頻繁,請稍後再試。"
490
- elif "key" in error_msg.lower() or "auth" in error_msg.lower() or "api_key" in error_msg.lower():
491
- out = "⚠️ API 金鑰驗證失敗,請聯繫服務專員。"
492
- elif "invalid" in error_msg.lower() and "model" in error_msg.lower():
493
- out = "⚠️ 模型設定錯誤,請聯繫服務專員。"
494
- elif "content" in error_msg.lower() and ("too" in error_msg.lower() or "large" in error_msg.lower()):
495
- out = "⚠️ 內容過長,請簡化您的問題。"
496
- else:
497
- # 顯示部分錯誤訊息以便除錯
498
- out = f"⚠️ 系統錯誤。\n錯誤:{error_type}\n詳情:{error_msg[:150]}"
499
-
500
- line_bot_api.reply_message(
501
- event.reply_token,
502
- TextSendMessage(text=out)
503
- )
504
 
505
  if __name__ == "__main__":
506
  import uvicorn
507
  logger.info("="*50)
508
- logger.info("啟動琴房預約 LINE Bot")
509
- logger.info(f"PDF 內容長度: {len(pdf_content)} 字元")
510
- logger.info(f"可用琴房: {', '.join(AVAILABLE_ROOMS)}")
511
  logger.info("="*50)
512
  uvicorn.run("main:app", host="0.0.0.0", port=7860, reload=True)
 
8
  import PyPDF2
9
  import logging
10
  from datetime import datetime, timedelta
11
+ from google.oauth2.credentials import Credentials
12
+ from google.oauth2 import service_account
13
+ from googleapiclient.discovery import build
14
+ from googleapiclient.errors import HttpError
15
 
16
  # ============== 設定日誌 ==============
17
  logging.basicConfig(level=logging.INFO)
18
  logger = logging.getLogger(__name__)
19
 
20
+ # ============== Google Calendar 設定 ==============
21
+ SCOPES = ['https://www.googleapis.com/auth/calendar']
22
+
23
+ def get_calendar_service():
24
+ """建立 Google Calendar 服務"""
25
+ try:
26
+ # 方法 1: 使用 Service Account(推薦用於伺服器)
27
+ service_account_file = os.getenv('GOOGLE_SERVICE_ACCOUNT_JSON')
28
+
29
+ if service_account_file:
30
+ # 從環境變數讀取 JSON 內容
31
+ service_account_info = json.loads(service_account_file)
32
+ credentials = service_account.Credentials.from_service_account_info(
33
+ service_account_info, scopes=SCOPES)
34
+ else:
35
+ logger.warning("未設定 Google Calendar 認證,預約將只儲存在本地")
36
+ return None
37
+
38
+ service = build('calendar', 'v3', credentials=credentials)
39
+ logger.info("Google Calendar 服務已建立")
40
+ return service
41
+ except Exception as e:
42
+ logger.error(f"建立 Google Calendar 服務失敗: {str(e)}")
43
+ return None
44
+
45
+ # 初始化 Calendar 服務
46
+ calendar_service = get_calendar_service()
47
+ CALENDAR_ID = os.getenv('GOOGLE_CALENDAR_ID', 'primary') # 使用主要日曆或指定 ID
48
+
49
+ def create_calendar_event(booking_data, booking_id):
50
+ """在 Google Calendar 建立事件"""
51
+ if not calendar_service:
52
+ logger.warning("Calendar 服務未啟用,跳過建立事件")
53
+ return None
54
+
55
+ try:
56
+ # 解析日期和時間
57
+ date_str = booking_data['date']
58
+ time_str = booking_data['time']
59
+
60
+ # 嘗試解析時間範圍(例如:14:00-16:00 或 下午2點到4點)
61
+ time_patterns = [
62
+ r'(\d{1,2}):(\d{2})\s*[-~到至]\s*(\d{1,2}):(\d{2})', # 14:00-16:00
63
+ r'(\d{1,2})\s*點\s*[-~到至]\s*(\d{1,2})\s*點', # 2點到4點
64
+ ]
65
+
66
+ start_time = "09:00"
67
+ end_time = "10:00"
68
+
69
+ for pattern in time_patterns:
70
+ match = re.search(pattern, time_str)
71
+ if match:
72
+ groups = match.groups()
73
+ if len(groups) == 4:
74
+ start_time = f"{int(groups[0]):02d}:{int(groups[1]):02d}"
75
+ end_time = f"{int(groups[2]):02d}:{int(groups[3]):02d}"
76
+ elif len(groups) == 2:
77
+ start_time = f"{int(groups[0]):02d}:00"
78
+ end_time = f"{int(groups[1]):02d}:00"
79
+ break
80
+
81
+ # 建立 ISO 格式的時間
82
+ start_datetime = f"{date_str}T{start_time}:00"
83
+ end_datetime = f"{date_str}T{end_time}:00"
84
+
85
+ # 建立事件
86
+ event = {
87
+ 'summary': f'🎹 琴房預約 - {booking_data["room"]}',
88
+ 'description': (
89
+ f'預約編號:{booking_id}\n'
90
+ f'琴房:{booking_data["room"]}\n'
91
+ f'人數:{booking_data["people"]}人\n'
92
+ f'狀態:待確認'
93
+ ),
94
+ 'start': {
95
+ 'dateTime': start_datetime,
96
+ 'timeZone': 'Asia/Taipei',
97
+ },
98
+ 'end': {
99
+ 'dateTime': end_datetime,
100
+ 'timeZone': 'Asia/Taipei',
101
+ },
102
+ 'colorId': '9', # 藍色
103
+ 'reminders': {
104
+ 'useDefault': False,
105
+ 'overrides': [
106
+ {'method': 'popup', 'minutes': 60}, # 1小時前提醒
107
+ {'method': 'popup', 'minutes': 10}, # 10分鐘前提醒
108
+ ],
109
+ },
110
+ }
111
+
112
+ # 插入事件
113
+ created_event = calendar_service.events().insert(
114
+ calendarId=CALENDAR_ID,
115
+ body=event
116
+ ).execute()
117
+
118
+ logger.info(f"Google Calendar 事件已建立: {created_event.get('id')}")
119
+ return created_event.get('htmlLink')
120
+
121
+ except HttpError as error:
122
+ logger.error(f"Google Calendar API 錯誤: {error}")
123
+ return None
124
+ except Exception as e:
125
+ logger.error(f"建立 Calendar 事件失敗: {str(e)}")
126
+ return None
127
+
128
+ def check_time_slot_available(date_str, time_str, room):
129
+ """檢查時段是否可用"""
130
+ if not calendar_service:
131
+ return True, None
132
+
133
+ try:
134
+ # 解析日期
135
+ date_obj = datetime.strptime(date_str, "%Y-%m-%d")
136
+
137
+ # 查詢當天的所有事件
138
+ time_min = date_obj.isoformat() + 'Z'
139
+ time_max = (date_obj + timedelta(days=1)).isoformat() + 'Z'
140
+
141
+ events_result = calendar_service.events().list(
142
+ calendarId=CALENDAR_ID,
143
+ timeMin=time_min,
144
+ timeMax=time_max,
145
+ singleEvents=True,
146
+ orderBy='startTime'
147
+ ).execute()
148
+
149
+ events = events_result.get('items', [])
150
+
151
+ # 檢查是否有衝突
152
+ conflicts = []
153
+ for event in events:
154
+ event_summary = event.get('summary', '')
155
+ if room in event_summary or '任意' in event_summary:
156
+ start = event['start'].get('dateTime', event['start'].get('date'))
157
+ conflicts.append(f"{event_summary} ({start})")
158
+
159
+ if conflicts:
160
+ return False, f"該時段已有預約:\n" + "\n".join(conflicts)
161
+
162
+ return True, None
163
+
164
+ except Exception as e:
165
+ logger.error(f"檢查時段可用性失敗: {str(e)}")
166
+ return True, None # 如果檢查失敗,允許預約
167
+
168
  # ============== API 金鑰檢查 ==============
169
  GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
170
+ if GOOGLE_API_KEY:
171
+ client = genai.Client(api_key=GOOGLE_API_KEY)
172
+ ai_enabled = True
173
+ logger.info("Gemini AI 已啟用")
174
+ else:
175
+ ai_enabled = False
176
+ logger.warning("未設定 GOOGLE_API_KEY,AI 問答功能已停用")
177
 
178
  # ============== 讀取 PDF 內容 ==============
179
  files = glob.glob('docs/*.pdf')
 
194
  except Exception as e:
195
  logger.error(f"讀取 {filename} 失敗: {str(e)}")
196
 
197
+ MAX_CONTENT_LENGTH = 15000
 
198
  if len(pdf_content) > MAX_CONTENT_LENGTH:
199
  pdf_content = pdf_content[:MAX_CONTENT_LENGTH]
200
 
201
  logger.info(f"PDF 內容總長度: {len(pdf_content)} 字元")
202
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  # ============== LINE Bot 設定 ==============
204
  line_bot_api = LineBotApi(os.getenv("CHANNEL_ACCESS_TOKEN"))
205
  line_handler = WebhookHandler(os.getenv("CHANNEL_SECRET"))
206
+ working_status = True
207
 
208
  # ============== 請求速率控制 ==============
209
  last_request_time = {}
 
213
  user_booking_state = {}
214
 
215
  class BookingState:
 
216
  IDLE = "idle"
217
  ASKING_DATE = "asking_date"
218
  ASKING_TIME = "asking_time"
 
223
  AVAILABLE_ROOMS = ["A琴房", "B琴房", "C琴房", "D琴房", "任意"]
224
 
225
  def init_user_booking(user_id):
 
226
  user_booking_state[user_id] = {
227
  "state": BookingState.IDLE,
228
  "date": None,
 
233
  }
234
 
235
  def get_user_booking(user_id):
 
236
  if user_id not in user_booking_state:
237
  init_user_booking(user_id)
238
  return user_booking_state[user_id]
239
 
240
  def reset_booking(user_id):
 
241
  init_user_booking(user_id)
242
 
243
  def is_booking_keyword(text):
 
244
  keywords = ["預約", "預定", "訂位", "訂房", "訂琴房", "借琴房", "租琴房", "我要預約", "想預約"]
245
  return any(keyword in text for keyword in keywords)
246
 
247
  def parse_date(date_str):
 
248
  today = datetime.now()
249
 
 
250
  if "今天" in date_str or "今日" in date_str:
251
  return today.strftime("%Y-%m-%d"), None
252
  elif "明天" in date_str or "明日" in date_str:
 
254
  elif "後天" in date_str:
255
  return (today + timedelta(days=2)).strftime("%Y-%m-%d"), None
256
 
 
257
  patterns = [
258
+ (r'(\d{4})[/-](\d{1,2})[/-](\d{1,2})', '%Y-%m-%d'),
259
+ (r'(\d{1,2})[/-](\d{1,2})', '%m-%d'),
260
  ]
261
 
262
  for pattern, fmt in patterns:
 
268
  else:
269
  date = datetime.strptime(match.group(0), fmt)
270
 
 
271
  if date.date() < today.date():
272
+ return None, "❌ 日期不能是過去的時間,請重新輸入。"
273
 
274
  return date.strftime("%Y-%m-%d"), None
275
  except ValueError:
 
278
  return None, "❌ 日期格式不正確,請重新輸入。\n例如:明天、12/26、2025-12-30"
279
 
280
  def validate_time(time_str):
 
281
  time_keywords = ["點", "時", ":", "-", "到", "至", "~"]
282
  if any(kw in time_str for kw in time_keywords):
283
  return True, None
284
+ return False, "❌ 時段格式不正確。\n例如:09:00-11:00、下午2點到4點"
285
 
286
  def parse_people(people_str):
 
287
  numbers = re.findall(r'\d+', people_str)
288
  if numbers:
289
  count = int(numbers[0])
290
  if 1 <= count <= 10:
291
  return count, None
292
  else:
293
+ return None, "❌ 人數必須在 1-10 人之間。"
294
  return None, "❌ 請輸入有效的人數。\n例如:2人、3、5位"
295
 
296
  def validate_room(room_str):
 
297
  room_normalized = room_str.strip().upper()
298
 
299
  for room in AVAILABLE_ROOMS:
 
303
  return None, f"❌ 請選擇有效的琴房:{', '.join(AVAILABLE_ROOMS)}"
304
 
305
  def save_booking_to_file(user_id, booking_data):
 
306
  bookings_dir = pathlib.Path("bookings")
307
  bookings_dir.mkdir(exist_ok=True)
308
 
309
  booking_file = bookings_dir / "bookings.json"
310
 
 
311
  if booking_file.exists():
312
  with open(booking_file, 'r', encoding='utf-8') as f:
313
  try:
 
317
  else:
318
  bookings = []
319
 
320
+ booking_id = f"BK{int(time.time())}"
321
+
322
  booking_record = {
323
+ "booking_id": booking_id,
324
  "user_id": user_id,
325
  "date": booking_data["date"],
326
  "time": booking_data["time"],
327
  "people": booking_data["people"],
328
  "room": booking_data["room"],
329
  "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
330
+ "status": "pending",
331
+ "calendar_link": None
332
  }
333
 
334
+ # 建立 Google Calendar 事件
335
+ calendar_link = create_calendar_event(booking_data, booking_id)
336
+ if calendar_link:
337
+ booking_record["calendar_link"] = calendar_link
338
+ logger.info(f"預約已同步至 Google Calendar: {booking_id}")
339
+
340
  bookings.append(booking_record)
341
 
 
342
  with open(booking_file, 'w', encoding='utf-8') as f:
343
  json.dump(bookings, f, ensure_ascii=False, indent=2)
344
 
345
+ logger.info(f"預約已儲存: {booking_id}")
346
+ return booking_id, calendar_link
347
 
348
  def handle_booking_flow(user_id, user_message):
 
349
  booking = get_user_booking(user_id)
350
  current_state = booking["state"]
351
 
 
352
  if time.time() - booking["last_update"] > 600:
353
  reset_booking(user_id)
354
  booking = get_user_booking(user_id)
355
  current_state = booking["state"]
356
 
 
357
  booking["last_update"] = time.time()
358
 
 
359
  if user_message in ["取消", "取消預約", "重來", "重新開始"]:
360
  reset_booking(user_id)
361
  return "✅ 已取消預約流程。\n\n如需重新預約,請輸入「預約」。"
362
 
 
363
  if current_state == BookingState.IDLE:
364
  if is_booking_keyword(user_message):
365
  booking["state"] = BookingState.ASKING_DATE
 
370
  "💡 隨時輸入「取消」可取消預約"
371
  )
372
  else:
373
+ return None
374
 
 
375
  elif current_state == BookingState.ASKING_DATE:
376
  parsed_date, error = parse_date(user_message)
377
  if error:
 
381
  return (
382
  f"✅ 預約日期:{parsed_date}\n\n"
383
  f"⏰ 請問您想要預約哪個時段呢?\n"
384
+ f"例如:09:00-11:00、下午2點到4"
385
  )
386
 
 
387
  elif current_state == BookingState.ASKING_TIME:
388
  is_valid, error = validate_time(user_message)
389
  if not is_valid:
 
393
  return (
394
  f"✅ 預約時段:{user_message}\n\n"
395
  f"👥 請問有幾位使用呢?\n"
396
+ f"例如:1人、2"
397
  )
398
 
 
399
  elif current_state == BookingState.ASKING_PEOPLE:
400
  people_count, error = parse_people(user_message)
401
  if error:
 
405
  return (
406
  f"✅ 使用人數:{people_count}人\n\n"
407
  f"🎹 請問您想使用哪個琴房呢?\n"
408
+ f"可選擇:{', '.join(AVAILABLE_ROOMS)}"
 
409
  )
410
 
 
411
  elif current_state == BookingState.ASKING_ROOM:
412
  room, error = validate_room(user_message)
413
  if error:
414
  return error
415
+
416
+ # 檢查時段是否可用
417
+ available, conflict_msg = check_time_slot_available(
418
+ booking["date"],
419
+ booking["time"],
420
+ room
421
+ )
422
+
423
+ if not available:
424
+ return f"⚠️ {conflict_msg}\n\n請重新選擇琴房或輸入「取消」重新預約。"
425
+
426
  booking["room"] = room
427
  booking["state"] = BookingState.CONFIRMING
428
 
 
439
  )
440
  return summary
441
 
 
442
  elif current_state == BookingState.CONFIRMING:
443
+ if user_message in ["確認", "確定", "送出", "ok", "OK"]:
 
444
  try:
445
+ booking_id, calendar_link = save_booking_to_file(user_id, booking)
446
 
447
  summary = (
448
  f"🎉 預約成功!\n"
 
452
  f"👥 人數:{booking['people']}人\n"
453
  f"🎹 琴房:{booking['room']}\n"
454
  f"📝 預約編號:{booking_id}\n"
455
+ )
456
+
457
+ if calendar_link:
458
+ summary += f"📆 已加入 Google Calendar\n"
459
+
460
+ summary += (
461
  f"{'='*25}\n\n"
462
  f"✅ 我們已收到您的預約!\n"
463
+ f"服務專員將盡快與您聯繫確認。"
 
464
  )
465
 
 
466
  reset_booking(user_id)
467
  return summary
468
  except Exception as e:
469
  logger.error(f"儲存預約失敗: {str(e)}")
470
  reset_booking(user_id)
471
+ return "❌ 預約儲存失敗,請稍後再試。"
472
  else:
473
  return "請輸入「確認」來完成預約,或輸入「取消」重新開始。"
474
 
 
488
  @app.get("/")
489
  def root():
490
  return {
491
+ "title": "Line Bot - 琴房預約系統",
492
  "status": "running",
493
+ "version": "3.0",
494
+ "features": {
495
+ "booking": True,
496
+ "google_calendar": calendar_service is not None,
497
+ "ai_qa": ai_enabled,
498
+ "pdf_loaded": len(pdf_content) > 0
499
+ }
 
 
 
 
 
 
 
 
500
  }
501
 
502
  @app.get("/bookings")
503
  def get_bookings():
 
504
  bookings_file = pathlib.Path("bookings/bookings.json")
505
  if bookings_file.exists():
506
  with open(bookings_file, 'r', encoding='utf-8') as f:
 
520
  line_handler.handle, body.decode("utf-8"), x_line_signature
521
  )
522
  except InvalidSignatureError:
 
523
  raise HTTPException(status_code=400, detail="Invalid signature")
524
  return "ok"
525
 
526
  @line_handler.add(MessageEvent, message=TextMessage)
527
  def handle_message(event):
 
 
 
 
 
528
  user_message = event.message.text.strip()
529
  user_id = event.source.user_id
530
 
 
 
 
531
  if user_message == "再見":
532
  line_bot_api.reply_message(
533
  event.reply_token,
534
+ TextSendMessage(text="👋 再見!期待下次為您服務!")
535
  )
536
  return
537
 
 
542
  if elapsed < REQUEST_COOLDOWN:
543
  line_bot_api.reply_message(
544
  event.reply_token,
545
+ TextSendMessage(text=f"⏰ 請稍等 {REQUEST_COOLDOWN - int(elapsed)} ")
546
  )
547
  return
548
 
549
  last_request_time[user_id] = current_time
550
 
551
+ # 處理預約流程
552
  booking_response = handle_booking_flow(user_id, user_message)
553
 
554
  if booking_response:
 
555
  line_bot_api.reply_message(
556
  event.reply_token,
557
  TextSendMessage(text=booking_response)
558
  )
559
  return
560
 
561
+ # AI 問答
562
+ if ai_enabled:
563
+ try:
 
 
 
564
  if pdf_content:
565
+ full_prompt = f"""參考以下資料回答:
566
 
567
+ {pdf_content}
568
 
569
  問題:{user_message}
570
 
571
+ 請簡潔回答。"""
572
  else:
573
  full_prompt = user_message
574
 
575
  response = client.models.generate_content(
576
+ model="gemini-1.5-flash",
577
+ contents=full_prompt
 
578
  )
579
 
580
+ out = response.text if response and response.text else "抱歉,我無法回答這個問題。"
 
 
 
 
 
 
 
 
 
 
581
 
582
+ except Exception as e:
583
+ logger.error(f"AI 錯誤: {str(e)}")
584
+ out = "系統忙碌中,請稍後再試。"
585
+ else:
586
+ out = "AI 問答功能未啟用。如需預約請輸入「預約」。"
587
+
588
+ line_bot_api.reply_message(
589
+ event.reply_token,
590
+ TextSendMessage(text=out)
591
+ )
 
 
 
 
 
 
 
 
 
592
 
593
  if __name__ == "__main__":
594
  import uvicorn
595
  logger.info("="*50)
596
+ logger.info("啟動琴房預約系統 with Google Calendar")
597
+ logger.info(f"Google Calendar: {'已啟用' if calendar_service else '未啟用'}")
598
+ logger.info(f"AI 問答: {'已啟用' if ai_enabled else '未啟用'}")
599
  logger.info("="*50)
600
  uvicorn.run("main:app", host="0.0.0.0", port=7860, reload=True)