# ===東吳大學資料系 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 # === 靜態圖檔路由 === @app.route("/images/") def serve_image(filename): return send_from_directory(static_tmp_path, filename) # === LINE Webhook 接收端點 === @app.route("/") def home(): return {"message": "Line Webhook Server"} @app.route("/", methods=["POST"]) 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" # === 處理文字訊息 === @handler.add(MessageEvent, message=TextMessageContent) 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())], ) ) # === 處理圖片訊息 === @handler.add(MessageEvent, message=ImageMessageContent) 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), ], ) ) # === 處理影片訊息 === @handler.add(MessageEvent, message=TextMessageContent) 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())], ) )