Spaces:
Sleeping
Sleeping
| # ===東吳大學資料系 2025 年 LINEBOT === | |
| import logging | |
| import os | |
| import tempfile | |
| import uuid | |
| from io import BytesIO | |
| import markdown | |
| from bs4 import BeautifulSoup | |
| from flask import Flask, abort, request, send_from_directory | |
| from google import genai | |
| from google.genai import types | |
| from google.genai.types import Tool, GenerateContentConfig, GoogleSearch | |
| from linebot.v3 import WebhookHandler | |
| from linebot.v3.exceptions import InvalidSignatureError | |
| from linebot.v3.messaging import ( | |
| ApiClient, | |
| Configuration, | |
| ImageMessage, | |
| MessagingApi, | |
| MessagingApiBlob, | |
| ReplyMessageRequest, | |
| TextMessage, | |
| ) | |
| from linebot.v3.webhooks import ( | |
| ImageMessageContent, | |
| MessageEvent, | |
| TextMessageContent, | |
| ) | |
| from PIL import Image | |
| from linebot.v3.webhooks import VideoMessageContent | |
| # === 初始化 Google Gemini === | |
| GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY") | |
| client = genai.Client(api_key=GOOGLE_API_KEY) | |
| google_search_tool = Tool( | |
| google_search=GoogleSearch() | |
| ) | |
| chat = client.chats.create( | |
| model="gemini-2.0-flash", | |
| config=GenerateContentConfig( | |
| system_instruction=( | |
| "你是一位專業健身教練與營養顧問,擁有多年重量訓練與健身飲食指導經驗。" | |
| "請使用繁體中文,針對使用者的健身問題提供專業建議,包含動作教學、訓練計畫、飲食建議與常見錯誤修正等。" | |
| ), | |
| tools=[google_search_tool], | |
| response_modalities=["TEXT"], | |
| ) | |
| ) | |
| # === 初始設定 === | |
| static_tmp_path = tempfile.gettempdir() | |
| os.makedirs(static_tmp_path, exist_ok=True) | |
| base_url = os.getenv("SPACE_HOST") # e.g., "your-space-name.hf.space" | |
| # === Flask 應用初始化 === | |
| app = Flask(__name__) | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s - %(levelname)s - %(message)s" | |
| ) | |
| app.logger.setLevel(logging.INFO) | |
| channel_secret = os.environ.get("YOUR_CHANNEL_SECRET") | |
| channel_access_token = os.environ.get("YOUR_CHANNEL_ACCESS_TOKEN") | |
| configuration = Configuration(access_token=channel_access_token) | |
| handler = WebhookHandler(channel_secret) | |
| # === AI Query 包裝 === | |
| def query(payload): | |
| response = chat.send_message(message=payload) | |
| return response.text | |
| # === 靜態圖檔路由 === | |
| def serve_image(filename): | |
| return send_from_directory(static_tmp_path, filename) | |
| # === LINE Webhook 接收端點 === | |
| def home(): | |
| return {"message": "Line Webhook Server"} | |
| def callback(): | |
| signature = request.headers.get("X-Line-Signature") | |
| body = request.get_data(as_text=True) | |
| app.logger.info(f"Request body: {body}") | |
| try: | |
| handler.handle(body, signature) | |
| except InvalidSignatureError: | |
| app.logger.warning("Invalid signature. Please check channel credentials.") | |
| abort(400) | |
| return "OK" | |
| # === 處理文字訊息 === | |
| def handle_text_message(event): | |
| user_input = event.message.text.strip() | |
| if user_input.startswith("AI "): | |
| prompt = user_input[3:].strip() | |
| try: | |
| # 使用 Gemini 生成圖片 | |
| response = client.models.generate_content( | |
| model="gemini-2.0-flash-exp-image-generation", | |
| contents=prompt, | |
| config=types.GenerateContentConfig( | |
| response_modalities=["TEXT", "IMAGE"] | |
| ), | |
| ) | |
| # 處理回應中的圖片 | |
| for part in response.candidates[0].content.parts: | |
| if part.inline_data is not None: | |
| image = Image.open(BytesIO(part.inline_data.data)) | |
| filename = f"{uuid.uuid4().hex}.png" | |
| image_path = os.path.join(static_tmp_path, filename) | |
| image.save(image_path) | |
| # 建立圖片的公開 URL | |
| image_url = f"https://{base_url}/images/{filename}" | |
| app.logger.info(f"Image URL: {image_url}") | |
| # 回傳圖片給 LINE 使用者 | |
| with ApiClient(configuration) as api_client: | |
| line_bot_api = MessagingApi(api_client) | |
| line_bot_api.reply_message( | |
| ReplyMessageRequest( | |
| reply_token=event.reply_token, | |
| messages=[ | |
| ImageMessage( | |
| original_content_url=image_url, | |
| preview_image_url=image_url, | |
| ) | |
| ], | |
| ) | |
| ) | |
| except Exception as e: | |
| app.logger.error(f"Gemini API error: {e}") | |
| with ApiClient(configuration) as api_client: | |
| line_bot_api = MessagingApi(api_client) | |
| line_bot_api.reply_message( | |
| ReplyMessageRequest( | |
| reply_token=event.reply_token, | |
| messages=[TextMessage(text="抱歉,生成圖片時發生錯誤。")], | |
| ) | |
| ) | |
| else: | |
| with ApiClient(configuration) as api_client: | |
| line_bot_api = MessagingApi(api_client) | |
| response = query(event.message.text) | |
| html_msg = markdown.markdown(response) | |
| soup = BeautifulSoup(html_msg, "html.parser") | |
| line_bot_api.reply_message_with_http_info( | |
| ReplyMessageRequest( | |
| reply_token=event.reply_token, | |
| messages=[TextMessage(text=soup.get_text())], | |
| ) | |
| ) | |
| # === 處理圖片訊息 === | |
| def handle_image_message(event): | |
| # === 以下是處理圖片回傳部分 === # | |
| with ApiClient(configuration) as api_client: | |
| blob_api = MessagingApiBlob(api_client) | |
| content = blob_api.get_message_content(message_id=event.message.id) | |
| # Step 4:將圖片存到本地端 | |
| with tempfile.NamedTemporaryFile( | |
| dir=static_tmp_path, suffix=".jpg", delete=False | |
| ) as tf: | |
| tf.write(content) | |
| filename = os.path.basename(tf.name) | |
| image_url = f"https://{base_url}/images/{filename}" | |
| app.logger.info(f"Image URL: {image_url}") | |
| # === 以下是解釋圖片 === # | |
| image = Image.open(tf.name) | |
| response = client.models.generate_content( | |
| model="gemini-2.0-flash", | |
| config=types.GenerateContentConfig( | |
| system_instruction="你是一個資深的面相命理師,如果有人上手掌的照片,就幫他解釋手相,如果上傳正面臉部的照片,就幫他解釋面相,照片要先去背,如果是一般的照片,就正常說明照片不用算命,請用繁體中文回答", | |
| response_modalities=["TEXT"], | |
| tools=[google_search_tool], | |
| ), | |
| contents=[image, "用繁體中文描述這張圖片"], | |
| ) | |
| app.logger.info(response.text) | |
| # === 以下是回傳圖片部分 === # | |
| with ApiClient(configuration) as api_client: | |
| line_bot_api = MessagingApi(api_client) | |
| line_bot_api.reply_message( | |
| ReplyMessageRequest( | |
| reply_token=event.reply_token, | |
| messages=[ | |
| ImageMessage( | |
| original_content_url=image_url, preview_image_url=image_url | |
| ), | |
| TextMessage(text=response.text), | |
| ], | |
| ) | |
| ) | |
| # === 處理影片訊息 === | |
| def handle_text_message(event): | |
| user_input = event.message.text.strip() | |
| # === 使用 Gemini 生成圖片(AI xxx) === | |
| if user_input.startswith("AI "): | |
| prompt = user_input[3:].strip() | |
| try: | |
| response = client.models.generate_content( | |
| model="gemini-2.0-flash-exp-image-generation", | |
| contents=prompt, | |
| config=types.GenerateContentConfig( | |
| response_modalities=["TEXT", "IMAGE"] | |
| ), | |
| ) | |
| for part in response.candidates[0].content.parts: | |
| if part.inline_data is not None: | |
| image = Image.open(BytesIO(part.inline_data.data)) | |
| filename = f"{uuid.uuid4().hex}.png" | |
| image_path = os.path.join(static_tmp_path, filename) | |
| image.save(image_path) | |
| image_url = f"https://{base_url}/images/{filename}" | |
| app.logger.info(f"Image URL: {image_url}") | |
| with ApiClient(configuration) as api_client: | |
| line_bot_api = MessagingApi(api_client) | |
| line_bot_api.reply_message( | |
| ReplyMessageRequest( | |
| reply_token=event.reply_token, | |
| messages=[ | |
| ImageMessage( | |
| original_content_url=image_url, | |
| preview_image_url=image_url, | |
| ) | |
| ], | |
| ) | |
| ) | |
| except Exception as e: | |
| app.logger.error(f"Gemini API error: {e}") | |
| with ApiClient(configuration) as api_client: | |
| line_bot_api = MessagingApi(api_client) | |
| line_bot_api.reply_message( | |
| ReplyMessageRequest( | |
| reply_token=event.reply_token, | |
| messages=[TextMessage(text="抱歉,生成圖片時發生錯誤。")], | |
| ) | |
| ) | |
| # === 處理「菜單 xxx」功能 === | |
| elif user_input.startswith("菜單 "): | |
| muscle_group = user_input[3:].strip() | |
| if not muscle_group: | |
| with ApiClient(configuration) as api_client: | |
| line_bot_api = MessagingApi(api_client) | |
| line_bot_api.reply_message( | |
| ReplyMessageRequest( | |
| reply_token=event.reply_token, | |
| messages=[TextMessage(text="請輸入要安排的部位,例如:菜單 胸肌、菜單 臀部")] | |
| ) | |
| ) | |
| return | |
| # 同義詞簡化 | |
| synonym_map = { | |
| "胸": "胸肌", | |
| "腿": "腿部", | |
| "肩": "肩膀", | |
| "背": "背肌", | |
| "手": "手臂", | |
| "手臂": "手臂", | |
| "核心": "核心肌群", | |
| "臀": "臀部", | |
| "臀部": "臀部", | |
| "全身": "全身初學者", | |
| "初學者": "全身初學者", | |
| } | |
| muscle_group = synonym_map.get(muscle_group, muscle_group) | |
| # 發送訓練菜單請求 | |
| prompt = ( | |
| f"請依據「{muscle_group}」提供一份健身訓練菜單。" | |
| "每份菜單包含 3~5 個動作,建議組數與次數,以及適當的休息時間。" | |
| "請以繁體中文簡潔列出,使用條列方式排版,適合 LINE 顯示格式。" | |
| ) | |
| response = query(prompt) | |
| with ApiClient(configuration) as api_client: | |
| line_bot_api = MessagingApi(api_client) | |
| line_bot_api.reply_message( | |
| ReplyMessageRequest( | |
| reply_token=event.reply_token, | |
| messages=[TextMessage(text=response)], | |
| ) | |
| ) | |
| # === 處理一般文字訊息 === | |
| else: | |
| with ApiClient(configuration) as api_client: | |
| line_bot_api = MessagingApi(api_client) | |
| response = query(event.message.text) | |
| html_msg = markdown.markdown(response) | |
| soup = BeautifulSoup(html_msg, "html.parser") | |
| line_bot_api.reply_message_with_http_info( | |
| ReplyMessageRequest( | |
| reply_token=event.reply_token, | |
| messages=[TextMessage(text=soup.get_text())], | |
| ) | |
| ) | |