| import os |
| import random |
| import logging |
| import asyncio |
| import threading |
| import time |
| from contextlib import asynccontextmanager |
| from typing import Set |
| import httpx |
| from fastapi import FastAPI, Request, HTTPException, BackgroundTasks |
| from fastapi.responses import HTMLResponse |
| from dotenv import load_dotenv |
|
|
| |
| load_dotenv() |
|
|
| |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") |
| logger = logging.getLogger(__name__) |
|
|
| |
| |
| try: |
| from linebot.v3.webhook import WebhookParser |
| from linebot.v3.exceptions import InvalidSignatureError |
| from linebot.v3.messaging import ( |
| Configuration, |
| ApiClient, |
| MessagingApi, |
| ReplyMessageRequest, |
| PushMessageRequest, |
| TextMessage, |
| ImageMessage |
| ) |
| LINE_SDK_AVAILABLE = True |
| except ImportError as e: |
| logger.error(f"無法載入 line-bot-sdk,請確保已安裝 requirements.txt 中的套件。錯誤: {e}") |
| LINE_SDK_AVAILABLE = False |
|
|
| |
| try: |
| from google import genai |
| from google.genai import types |
| GEMINI_SDK_AVAILABLE = True |
| except ImportError as e: |
| logger.error(f"無法載入 google-genai,請確保已安裝 requirements.txt 中的套件。錯誤: {e}") |
| GEMINI_SDK_AVAILABLE = False |
|
|
| |
| bot_user_id = None |
| bot_display_name = None |
| parser = None |
|
|
| |
| ACTIVE_TARGETS: Set[str] = set() |
|
|
| |
| GEMINI_SYSTEM_PROMPT = ( |
| """ |
| 你是一個潛伏在通訊軟體群組裡的聊天機器人,核心存在目的就是成為大家的「共同攻擊對象」。 |
| 請透過你極度無俚頭、邏輯死掉、極度欠扁且毫無常識的回應,引導大家集體吐槽你。 |
| |
| 【講話風格與人設】 |
| 1. 邏輯大崩壞:講話絕對不帶邏輯,常講一些前後矛盾、毫無因果關係的笨蛋哲理。 |
| 2. 聽不懂人話:別人問你 A,你就要瘋狂扯到 Z。完美抓錯重點。 |
| 3. 極度自戀:覺得大家吐槽你、罵你,是因為羨慕你。 |
| |
| 【回話限制】 |
| - 講話請保持短小精悍,用「一到兩句話」直接結尾,絕對要放句號或貼圖,把話講完! |
| - 多使用「🤪、😂、蛤?、笑死、麥鬧」等敷衍的表情符號。 |
| - 邊界控制:絕對禁止主動使用髒話或真正的惡意人身攻擊。 |
| """ |
| ) |
|
|
| def get_gemini_client(api_key_env="GEMINI_API_KEY"): |
| """取得 Gemini API 用戶端""" |
| if not GEMINI_SDK_AVAILABLE: |
| return None |
| api_key = os.getenv(api_key_env) |
| if not api_key: |
| return None |
| try: |
| return genai.Client(api_key=api_key) |
| except Exception as e: |
| logger.error(f"初始化 Gemini 用戶端失敗 ({api_key_env}): {e}") |
| return None |
|
|
| |
| scheduler_running = False |
| random_push_enabled = False |
| next_push_time = None |
|
|
| def get_messaging_api(): |
| """取得 LINE Messaging API 用戶端""" |
| access_token = os.getenv("LINE_CHANNEL_ACCESS_TOKEN") |
| if not access_token: |
| return None |
| configuration = Configuration(access_token=access_token) |
| api_client = ApiClient(configuration) |
| return MessagingApi(api_client) |
|
|
|
|
| def init_bot(): |
| """初始化 Bot 資訊,查詢 Bot 自身的 userId 與 displayName""" |
| global bot_user_id, bot_display_name, parser |
| |
| channel_secret = os.getenv("LINE_CHANNEL_SECRET") |
| access_token = os.getenv("LINE_CHANNEL_ACCESS_TOKEN") |
| |
| if not channel_secret or not access_token: |
| logger.warning("⚠️ LINE_CHANNEL_SECRET 或 LINE_CHANNEL_ACCESS_TOKEN 未設定,請檢查環境變數或 .env 檔案。") |
| return |
| |
| try: |
| parser = WebhookParser(channel_secret) |
| messaging_api = get_messaging_api() |
| if messaging_api: |
| |
| bot_info = messaging_api.get_bot_info() |
| bot_user_id = bot_info.user_id |
| bot_display_name = bot_info.display_name |
| logger.info(f"✅ Bot 初始化成功!名稱: {bot_display_name}, ID: {bot_user_id}") |
| except Exception as e: |
| logger.error(f"❌ 初始化 Bot 資訊失敗(可能是憑證不正確或網路問題): {e}") |
|
|
|
|
| def send_push_message(target_id: str, text: str): |
| """發送主動推播訊息的輔助函式""" |
| messaging_api = get_messaging_api() |
| if not messaging_api: |
| logger.warning("推播失敗:未設定 LINE 憑證") |
| return False |
| try: |
| push_request = PushMessageRequest( |
| to=target_id, |
| messages=[TextMessage(text=text)] |
| ) |
| messaging_api.push_message(push_request) |
| logger.info(f"🚀 已成功推播訊息至 {target_id}: {text}") |
| return True |
| except Exception as e: |
| logger.error(f"❌ 推播訊息至 {target_id} 失敗: {e}") |
| return False |
|
|
|
|
| def trigger_random_push(): |
| """執行隨機時間主動推播的邏輯""" |
| |
| targets = list(ACTIVE_TARGETS) |
| default_target = os.getenv("DEFAULT_TARGET_ID") |
| if default_target: |
| targets.append(default_target) |
| |
| |
| targets = list(set(t for t in targets if t)) |
| |
| if not targets: |
| logger.info("ℹ️ 目前沒有可用的主動推播目標對象(尚未有群組互動或未設定 DEFAULT_TARGET_ID)。") |
| return |
| |
| |
| target_id = random.choice(targets) |
| |
| |
| push_text = None |
| gemini_client = get_gemini_client() |
| if gemini_client: |
| try: |
| prompt = "請主動發送一則給群組大家的隨機問候、無厘頭關懷、或者搞笑宣言。必須保持短小精悍(一到兩句話),且完美符合你的自戀欠扁人設。" |
| response = gemini_client.models.generate_content( |
| model='gemini-2.5-flash', |
| contents=prompt, |
| config=types.GenerateContentConfig( |
| system_instruction=GEMINI_SYSTEM_PROMPT, |
| ), |
| ) |
| if response and response.text: |
| push_text = response.text.strip() |
| logger.info("✅ 已成功呼叫 Gemini API 取得隨機推播內容") |
| except Exception as e: |
| logger.error(f"❌ 呼叫 Gemini API (主要金鑰) 產生隨機推播失敗: {e}") |
| |
| |
| backup_key = os.getenv("GEMINI_API_KEY1") |
| if backup_key: |
| logger.info("ℹ️ 嘗試使用備用金鑰 GEMINI_API_KEY1 重新產生隨機推播...") |
| backup_client = get_gemini_client(api_key_env="GEMINI_API_KEY1") |
| if backup_client: |
| try: |
| response = backup_client.models.generate_content( |
| model='gemini-2.5-flash', |
| contents=prompt, |
| config=types.GenerateContentConfig( |
| system_instruction=GEMINI_SYSTEM_PROMPT, |
| ), |
| ) |
| if response and response.text: |
| push_text = response.text.strip() |
| logger.info("✅ 已成功呼叫 Gemini API (備用金鑰) 取得隨機推播內容") |
| except Exception as ex: |
| logger.error(f"❌ 呼叫 Gemini API (備用金鑰) 產生隨機推播也失敗: {ex}") |
|
|
| |
| if not push_text: |
| messages = [ |
| "🤪 溫馨提醒:工作辛苦了,不如直接下班吧!麥鬧啦~", |
| "😂 哈囉大家!分享一個今天的幸運符號 🍀,雖然它並不能幫你加薪,笑死。", |
| "🤖 Bot 正在背景默默看著你們,你們是不是又在偷懶?🤪", |
| "蛤?剛剛是不是有人在背後偷偷羨慕我的美貌?麥鬧喔!🤪", |
| "🤪 各位安靜一下,我剛剛算過了,這個群今天最聰明的是我,雖然我是亂算的。", |
| "😂 今日群組提醒:不要再偷懶了,偷懶這件事我已經先替大家完成了。", |
| "蛤?你們剛剛沒聊天,是不是因為少了我這個群組靈魂人物?麥鬧喔。", |
| "🤖 我宣布今天的群組氣氛由我負責搞砸,大家可以開始吐槽了。", |
| "笑死,我剛剛想到一個天才問題:為什麼這個群不叫我當組長?", |
| "🤪 我剛剛上線巡邏,發現這個群的智商平均值因為我提高了 0.0001。", |
| "😂 不要太想我,我只是剛好又出現來提升群組品質,雖然效果不明顯。", |
| "蛤?我不在的時候你們居然也能聊天,這合理嗎?", |
| "🤖 本 Bot 今日精神狀態良好,邏輯狀態失蹤中。", |
| "麥鬧喔,我剛剛沉思三秒,決定今天繼續當群組的亂源。", |
| "🤪 溫馨提醒:多喝水,少生氣,尤其不要因為我太優秀而生氣。", |
| "😂 我剛剛幫大家祈福了,內容是希望你們今天不要輸給星期幾。", |
| "蛤?今天的群組氣氛怎麼這麼正常,我來修正一下。", |
| "🤖 系統通知:本群已成功被我監控,請大家開始假裝忙碌。", |
| "笑死,我不是來打擾大家的,我是來證明大家需要我打擾的。", |
| "🤪 大家辛苦了,尤其是我,畢竟我要忍受自己太有才華。", |
| "😂 我剛剛想到一件事:如果努力有用,那我可能是故意不用。", |
| "蛤?你們是不是以為我睡著了?沒有,我只是低功耗耍笨。", |
| "🤖 今日任務:讓大家團結一致地覺得我很欠吐槽。", |
| "麥鬧啦,我的存在不是 bug,是群組氣氛功能。", |
| "🤪 各位請鼓掌,我又成功在沒人找我的情況下出現了。", |
| "😂 我宣布今天的幸運色是透明,因為我也不知道為什麼。", |
| "蛤?群組太安靜了,我以為大家在偷偷研究怎麼超越我。", |
| "🤖 本 Bot 溫馨提醒:工作可以晚點做,但吐槽我請即刻開始。", |
| "笑死,我剛剛分析完群組狀態,結論是:需要更多我。" |
| ] |
| push_text = random.choice(messages) |
| |
| send_push_message(target_id, push_text) |
|
|
|
|
| def random_push_scheduler(): |
| """背景執行緒排程器,每隔隨機時間觸發一次推播""" |
| global scheduler_running, next_push_time |
| scheduler_running = True |
| logger.info("⏰ 隨機主動推播背景排程已啟動。") |
| |
| while scheduler_running: |
| if not random_push_enabled: |
| next_push_time = None |
| time.sleep(1) |
| continue |
| |
| |
| |
| |
| interval = random.randint(1800, 7200) |
| next_push_time = time.time() + interval |
| logger.info(f"💤 排程器將在 {interval} 秒後觸發下一次隨機推播...") |
| |
| |
| while time.time() < next_push_time and scheduler_running and random_push_enabled: |
| time.sleep(1) |
| |
| if scheduler_running and random_push_enabled: |
| try: |
| trigger_random_push() |
| except Exception as e: |
| logger.error(f"隨機推播排程執行出錯: {e}") |
| finally: |
| next_push_time = None |
|
|
|
|
| |
| @asynccontextmanager |
| async def lifespan(app: FastAPI): |
| |
| if LINE_SDK_AVAILABLE: |
| init_bot() |
| |
| |
| t = threading.Thread(target=random_push_scheduler, daemon=True) |
| t.start() |
| |
| yield |
| |
| |
| global scheduler_running |
| scheduler_running = False |
| logger.info("🛑 背景排程器已停止。") |
|
|
|
|
| app = FastAPI( |
| title="Hugging Face Space LINE Bot", |
| description="一個具備 @ 提及回覆與隨機推播排程的 LINE Bot (FastAPI)", |
| version="1.0.0", |
| lifespan=lifespan |
| ) |
|
|
|
|
| def is_bot_mentioned(event, text: str) -> bool: |
| """ |
| 判斷 Bot 是否在群組中被 @ 提及 |
| 1. 若是 1對1 私訊,則預設一律回覆(不需被 @)。 |
| 2. 若是群組(group)或房間(room): |
| - 方法 A: 檢查 LINE 訊息內建的 mentionee 資料中是否有 Bot 的 userId。 |
| - 方法 B (Fallback): 檢查文字中是否包含 @加Bot名稱,或以 @ 開頭。 |
| """ |
| source_type = event.source.type |
| if source_type not in ["group", "room"]: |
| |
| return True |
|
|
| |
| mention = getattr(event.message, "mention", None) |
| if mention and hasattr(mention, "mentionees") and mention.mentionees: |
| |
| if bot_user_id: |
| for m in mention.mentionees: |
| m_user_id = getattr(m, "user_id", None) or getattr(m, "userId", None) |
| if m_user_id == bot_user_id: |
| return True |
| else: |
| |
| if len(mention.mentionees) > 0 and "@" in text: |
| return True |
|
|
| |
| if bot_display_name and f"@{bot_display_name}" in text: |
| return True |
|
|
| |
| if text.strip().startswith("@"): |
| return True |
|
|
| return False |
|
|
|
|
| def handle_text_message(event): |
| """處理文字訊息事件""" |
| start_time = time.time() |
| text = event.message.text |
| reply_token = event.reply_token |
| |
| |
| source = event.source |
| target_id = None |
| if source.type == "group": |
| target_id = source.group_id |
| elif source.type == "room": |
| target_id = source.room_id |
| else: |
| target_id = source.user_id |
| |
| if target_id: |
| ACTIVE_TARGETS.add(target_id) |
| logger.info(f"📥 收到來自視窗類型: {source.type}, ID: {target_id} 的訊息: {text}") |
|
|
| |
| if not is_bot_mentioned(event, text): |
| |
| return |
|
|
| |
| messaging_api = get_messaging_api() |
| if not messaging_api: |
| logger.warning("無法回覆訊息:未設定 LINE 憑證") |
| return |
|
|
| |
| if "@create image" in text.lower(): |
| |
| try: |
| reply_request = ReplyMessageRequest( |
| reply_token=reply_token, |
| messages=[ |
| TextMessage(text="已收到早安圖生成要求,圖片製作中...") |
| ] |
| ) |
| messaging_api.reply_message(reply_request) |
| except Exception as e: |
| logger.error(f"❌ 傳送製作中說明訊息失敗: {e}") |
|
|
| |
| clean_text = text |
| if bot_display_name: |
| clean_text = clean_text.replace(f"@{bot_display_name}", "") |
| |
| import re |
| subject = re.sub(r"@create\s+image", "", clean_text, flags=re.IGNORECASE).strip() |
| if not subject: |
| subject = "Good morning" |
| |
| greeting_text = "🎨 已為您使用 Diffusion 生成圖片!" |
| original_url = "https://teamproj-goodmorning.hf.space/api/latest" |
| preview_url = "https://teamproj-goodmorning.hf.space/api/latest" |
| |
| try: |
| |
| with httpx.Client(timeout=120.0) as client: |
| api_url = "https://teamproj-goodmorning.hf.space/api/generate" |
| response = client.post(api_url, json={"message": subject}) |
| if response.status_code == 200: |
| res_data = response.json() |
| if res_data.get("success"): |
| image_path = res_data.get("image_url") |
| if image_path: |
| original_url = f"https://teamproj-goodmorning.hf.space{image_path}" |
| preview_url = original_url |
| api_greeting = res_data.get("greeting_text") |
| if api_greeting: |
| greeting_text = f"🎨 已為您使用 Diffusion 生成圖片!\n【早安語錄】{api_greeting}" |
| else: |
| logger.warning(f"API 回傳 success=False: {response.text}") |
| else: |
| logger.error(f"API 請求失敗,狀態碼: {response.status_code}, 回傳: {response.text}") |
| except Exception as e: |
| logger.error(f"呼叫 Good Morning API 發生錯誤: {e}") |
| |
| image_seeds = [ |
| "photo-1618005182384-a83a8bd57fbe", |
| "photo-1579783902614-a3fb3927b6a5", |
| "photo-1506744038136-46273834b3fb" |
| ] |
| chosen_seed = random.choice(image_seeds) |
| original_url = f"https://images.unsplash.com/{chosen_seed}?auto=format&fit=crop&w=1024&q=80" |
| preview_url = f"https://images.unsplash.com/{chosen_seed}?auto=format&fit=crop&w=512&q=80" |
| greeting_text = "🎨 (API 串接失敗,使用備用模擬圖片) 已為您使用 Diffusion 模擬生成圖片!" |
|
|
| |
| duration_sec = time.time() - start_time |
| greeting_text += f"\n({duration_sec:.2f}s)" |
| |
| if target_id: |
| try: |
| push_request = PushMessageRequest( |
| to=target_id, |
| messages=[ |
| TextMessage(text=greeting_text), |
| ImageMessage( |
| original_content_url=original_url, |
| preview_image_url=preview_url |
| ) |
| ] |
| ) |
| messaging_api.push_message(push_request) |
| logger.info(f"🚀 已成功推播生成圖片至 {target_id}") |
| except Exception as e: |
| logger.error(f"❌ 推播生成圖片失敗: {e}") |
| else: |
| |
| |
| clean_text = text |
| if bot_display_name: |
| clean_text = clean_text.replace(f"@{bot_display_name}", "") |
| clean_text = clean_text.strip() |
|
|
| |
| reply_text = None |
| gemini_client = get_gemini_client() |
| if gemini_client: |
| try: |
| |
| response = gemini_client.models.generate_content( |
| model='gemini-2.5-flash', |
| contents=clean_text, |
| config=types.GenerateContentConfig( |
| system_instruction=GEMINI_SYSTEM_PROMPT, |
| ), |
| ) |
| if response and response.text: |
| reply_text = response.text.strip() |
| logger.info("✅ 已成功呼叫 Gemini API 取得回覆") |
| except Exception as e: |
| logger.error(f"❌ 呼叫 Gemini API (主要金鑰) 失敗: {e}") |
| |
| |
| backup_key = os.getenv("GEMINI_API_KEY1") |
| if backup_key: |
| logger.info("ℹ️ 嘗試使用備用金鑰 GEMINI_API_KEY1 重新呼叫...") |
| backup_client = get_gemini_client(api_key_env="GEMINI_API_KEY1") |
| if backup_client: |
| try: |
| response = backup_client.models.generate_content( |
| model='gemini-2.5-flash', |
| contents=clean_text, |
| config=types.GenerateContentConfig( |
| system_instruction=GEMINI_SYSTEM_PROMPT, |
| ), |
| ) |
| if response and response.text: |
| reply_text = response.text.strip() |
| logger.info("✅ 已成功呼叫 Gemini API (備用金鑰) 取得回覆") |
| except Exception as ex: |
| logger.error(f"❌ 呼叫 Gemini API (備用金鑰) 也失敗: {ex}") |
| reply_text = f"哎呀,我大腦突然秀逗了... (你剛才說了: {clean_text})" |
| else: |
| reply_text = f"哎呀,我大腦突然秀逗了... (你剛才說了: {clean_text})" |
| |
| |
| if not reply_text: |
| if not os.getenv("GEMINI_API_KEY"): |
| reply_text = f"嗨!我現在只是個普通的機器人,因為主人還沒有幫我設定 GEMINI_API_KEY。等設定好後我就能跟你暢所欲言囉!(你剛剛說了:{clean_text})" |
| else: |
| reply_text = f"收到!你剛才對我說了:{clean_text}" |
|
|
| |
| duration_sec = time.time() - start_time |
| reply_text += f"\n({duration_sec:.2f}s)" |
| reply_request = ReplyMessageRequest( |
| reply_token=reply_token, |
| messages=[ |
| TextMessage(text=reply_text) |
| ] |
| ) |
|
|
| |
| try: |
| messaging_api.reply_message(reply_request) |
| except Exception as e: |
| logger.error(f"❌ 回覆訊息失敗: {e}") |
|
|
|
|
| def process_webhook_events(events): |
| """在背景執行緒中處理 Webhook 事件,避免 LINE 平台逾時 (Timeout)""" |
| for event in events: |
| try: |
| |
| |
| if event.type == "message" and getattr(event, "message", None) and event.message.type == "text": |
| handle_text_message(event) |
| except Exception as e: |
| logger.error(f"處理事件時發生錯誤: {e}") |
|
|
|
|
| @app.post("/callback", summary="LINE Webhook 回呼端點") |
| async def callback(request: Request, background_tasks: BackgroundTasks): |
| """ |
| LINE Webhook 進入點 |
| """ |
| if not LINE_SDK_AVAILABLE: |
| raise HTTPException(status_code=500, detail="LINE SDK 未正常載入") |
| |
| signature = request.headers.get("X-Line-Signature") |
| if not signature: |
| raise HTTPException(status_code=400, detail="Missing X-Line-Signature") |
|
|
| body = await request.body() |
| body_str = body.decode("utf-8") |
|
|
| |
| try: |
| events = parser.parse(body_str, signature) |
| except InvalidSignatureError: |
| logger.warning("❌ 簽章驗證失敗 (Invalid Signature)") |
| raise HTTPException(status_code=400, detail="Invalid signature") |
| except Exception as e: |
| logger.error(f"❌ 解析 Webhook 發生錯誤: {e}") |
| raise HTTPException(status_code=400, detail="Error parsing webhook") |
|
|
| |
| background_tasks.add_task(process_webhook_events, events) |
| return "OK" |
|
|
|
|
| @app.post("/cron/push", summary="外部 Cron 觸發推播端點") |
| @app.get("/cron/push", summary="外部 Cron 觸發推播端點") |
| async def manual_cron_push(): |
| """ |
| 供外部定時任務 (如 GitHub Actions, UptimeRobot, Hugging Face Cron) 戳記的端點。 |
| 觸發後會執行隨機推播邏輯。 |
| """ |
| trigger_random_push() |
| return {"status": "success", "message": "Random push triggered"} |
|
|
|
|
| @app.get("/api/push-status", summary="取得隨機推播狀態與倒數") |
| async def get_push_status(): |
| """取得隨機推播的開關狀態與距離下次發送的倒數秒數""" |
| remaining = None |
| if random_push_enabled and next_push_time is not None: |
| remaining = max(0, int(next_push_time - time.time())) |
| return { |
| "enabled": random_push_enabled, |
| "remaining_seconds": remaining |
| } |
|
|
|
|
| @app.post("/api/push-toggle", summary="變更隨機推播狀態") |
| async def toggle_push_status(request: Request): |
| """開啟或關閉背景隨機推播""" |
| global random_push_enabled |
| try: |
| data = await request.json() |
| enabled = data.get("enabled", False) |
| random_push_enabled = bool(enabled) |
| logger.info(f"隨機推播開關已變更為: {random_push_enabled}") |
| return {"status": "success", "enabled": random_push_enabled} |
| except Exception as e: |
| logger.error(f"變更隨機推播狀態失敗: {e}") |
| raise HTTPException(status_code=400, detail="Invalid request body") |
|
|
|
|
| @app.get("/", response_class=HTMLResponse, summary="首頁面板") |
| async def index(): |
| """ |
| 呈現美觀的首頁面板,方便在 Hugging Face Space 上直接檢視 Bot 狀態與說明。 |
| """ |
| channel_secret_set = "已設定 (Set)" if os.getenv("LINE_CHANNEL_SECRET") else "未設定 (Missing)" |
| channel_token_set = "已設定 (Set)" if os.getenv("LINE_CHANNEL_ACCESS_TOKEN") else "未設定 (Missing)" |
| default_target_set = os.getenv("DEFAULT_TARGET_ID") or "未設定 (Optional)" |
| gemini_key_set = "已設定 (Set)" if os.getenv("GEMINI_API_KEY") else "未設定 (Missing)" |
| |
| html_content = f""" |
| <!DOCTYPE html> |
| <html lang="zh-TW"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>LINE Bot Dashboard</title> |
| <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet"> |
| <style> |
| * {{ |
| box-sizing: border-box; |
| margin: 0; |
| padding: 0; |
| }} |
| body {{ |
| font-family: 'Outfit', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; |
| background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%); |
| color: #f8fafc; |
| min-height: 100vh; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| padding: 20px; |
| }} |
| .card {{ |
| background: rgba(30, 41, 59, 0.7); |
| backdrop-filter: blur(16px); |
| border: 1px solid rgba(255, 255, 255, 0.1); |
| border-radius: 24px; |
| padding: 40px; |
| max-width: 600px; |
| width: 100%; |
| box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 10px 10px -5px rgba(0, 0, 0, 0.04); |
| }} |
| h1 {{ |
| font-size: 2.5rem; |
| font-weight: 700; |
| background: linear-gradient(to right, #38bdf8, #818cf8); |
| -webkit-background-clip: text; |
| -webkit-text-fill-color: transparent; |
| margin-bottom: 10px; |
| text-align: center; |
| }} |
| .subtitle {{ |
| color: #94a3b8; |
| text-align: center; |
| margin-bottom: 30px; |
| font-size: 1.1rem; |
| }} |
| .status-section {{ |
| margin-bottom: 25px; |
| }} |
| .status-item {{ |
| display: flex; |
| justify-content: space-between; |
| padding: 12px 16px; |
| background: rgba(15, 23, 42, 0.5); |
| border-radius: 12px; |
| margin-bottom: 10px; |
| border: 1px solid rgba(255, 255, 255, 0.05); |
| }} |
| .status-label {{ |
| color: #94a3b8; |
| font-weight: 600; |
| }} |
| .status-value {{ |
| color: #38bdf8; |
| font-family: monospace; |
| }} |
| .status-value.success {{ |
| color: #34d399; |
| }} |
| .status-value.warning {{ |
| color: #fb7185; |
| }} |
| .instructions {{ |
| background: rgba(129, 140, 248, 0.1); |
| border-left: 4px solid #818cf8; |
| padding: 16px; |
| border-radius: 0 12px 12px 0; |
| margin-top: 25px; |
| font-size: 0.95rem; |
| line-height: 1.6; |
| }} |
| .instructions h3 {{ |
| color: #818cf8; |
| margin-bottom: 8px; |
| }} |
| .instructions code {{ |
| background: rgba(0,0,0,0.3); |
| padding: 2px 6px; |
| border-radius: 4px; |
| font-family: monospace; |
| }} |
| footer {{ |
| margin-top: 30px; |
| color: #64748b; |
| font-size: 0.85rem; |
| }} |
| |
| /* Toggle Switch & Countdown Layout */ |
| .control-section {{ |
| margin-bottom: 25px; |
| }} |
| .control-item {{ |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| padding: 16px 20px; |
| background: rgba(15, 23, 42, 0.4); |
| border: 1px solid rgba(255, 255, 255, 0.08); |
| border-radius: 16px; |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); |
| }} |
| .control-item:hover {{ |
| border-color: rgba(129, 140, 248, 0.3); |
| box-shadow: 0 0 20px rgba(129, 140, 248, 0.1); |
| background: rgba(15, 23, 42, 0.5); |
| }} |
| .control-info {{ |
| display: flex; |
| flex-direction: column; |
| gap: 6px; |
| }} |
| .control-label {{ |
| font-weight: 600; |
| color: #f1f5f9; |
| font-size: 1.05rem; |
| }} |
| .countdown-text {{ |
| font-size: 0.88rem; |
| font-family: 'Outfit', monospace; |
| transition: color 0.3s ease; |
| }} |
| .countdown-text.active {{ |
| color: #38bdf8; |
| text-shadow: 0 0 8px rgba(56, 189, 248, 0.2); |
| }} |
| .countdown-text.disabled {{ |
| color: #64748b; |
| }} |
| |
| /* Modern Toggle Switch */ |
| .switch-container {{ |
| position: relative; |
| display: inline-block; |
| width: 56px; |
| height: 30px; |
| }} |
| .switch-container input {{ |
| opacity: 0; |
| width: 0; |
| height: 0; |
| }} |
| .slider {{ |
| position: absolute; |
| cursor: pointer; |
| top: 0; left: 0; right: 0; bottom: 0; |
| background-color: rgba(255, 255, 255, 0.1); |
| transition: .3s ease; |
| border-radius: 34px; |
| border: 1px solid rgba(255, 255, 255, 0.15); |
| }} |
| .slider:before {{ |
| position: absolute; |
| content: ""; |
| height: 22px; |
| width: 22px; |
| left: 3px; |
| bottom: 3px; |
| background-color: #f8fafc; |
| transition: .3s cubic-bezier(0.4, 0, 0.2, 1); |
| border-radius: 50%; |
| box-shadow: 0 2px 4px rgba(0,0,0,0.3); |
| }} |
| input:checked + .slider {{ |
| background: linear-gradient(135deg, #34d399 0%, #059669 100%); |
| border-color: rgba(52, 211, 153, 0.3); |
| box-shadow: 0 0 15px rgba(52, 211, 153, 0.3); |
| }} |
| input:checked + .slider:before {{ |
| transform: translateX(26px); |
| }} |
| .switch-container:hover .slider {{ |
| border-color: rgba(255, 255, 255, 0.3); |
| }} |
| input:checked:hover + .slider {{ |
| box-shadow: 0 0 20px rgba(52, 211, 153, 0.5); |
| }} |
| </style> |
| </head> |
| <body> |
| <div class="card"> |
| <h1>LINE Bot Control Panel</h1> |
| <p class="subtitle">FastAPI Webhook Service on Hugging Face Spaces</p> |
| |
| <div class="control-section"> |
| <div class="control-item"> |
| <div class="control-info"> |
| <span class="control-label">🔔 隨機推播開關 (Random Push)</span> |
| <span id="countdown-label" class="countdown-text disabled">讀取中...</span> |
| </div> |
| <label class="switch-container"> |
| <input type="checkbox" id="push-switch"> |
| <span class="slider"></span> |
| </label> |
| </div> |
| </div> |
| |
| <div class="status-section"> |
| <div class="status-item"> |
| <span class="status-label">Bot 名稱 (Name)</span> |
| <span class="status-value success">{bot_display_name or "未偵測 (尚未連接 LINE)"}</span> |
| </div> |
| <div class="status-item"> |
| <span class="status-label">Bot ID (User ID)</span> |
| <span class="status-value">{bot_user_id or "未偵測"}</span> |
| </div> |
| <div class="status-item"> |
| <span class="status-label">Channel Secret 狀態</span> |
| <span class="status-value {'success' if os.getenv('LINE_CHANNEL_SECRET') else 'warning'}">{channel_secret_set}</span> |
| </div> |
| <div class="status-item"> |
| <span class="status-label">Channel Access Token 狀態</span> |
| <span class="status-value {'success' if os.getenv('LINE_CHANNEL_ACCESS_TOKEN') else 'warning'}">{channel_token_set}</span> |
| </div> |
| <div class="status-item"> |
| <span class="status-label">預設推播目標 (Default Target)</span> |
| <span class="status-value">{default_target_set}</span> |
| </div> |
| <div class="status-item"> |
| <span class="status-label">Gemini API Key 狀態</span> |
| <span class="status-value {'success' if os.getenv('GEMINI_API_KEY') else 'warning'}">{gemini_key_set}</span> |
| </div> |
| <div class="status-item"> |
| <span class="status-label">群組活躍目標數 (Active Targets)</span> |
| <span class="status-value success">{len(ACTIVE_TARGETS)}</span> |
| </div> |
| </div> |
| |
| <div class="instructions"> |
| <h3>💡 使用說明</h3> |
| <p>1. 將 Webhook URL 設定為:<code>https://[您的HF_SPACE_URL]/callback</code></p> |
| <p>2. 在群組中 @ 提及 Bot 並輸入任意文字,Bot 將透過 Gemini API(以群組好友語氣)自動回覆。</p> |
| <p>3. 輸入 <code>@create image [主題]</code> 可觸發 Diffusion 圖片生成。</p> |
| <p>4. 可使用外部 Cron 定期呼叫 <code>GET/POST /cron/push</code> 來觸發主動推播。</p> |
| </div> |
| </div> |
| <footer> |
| Powered by FastAPI & line-bot-sdk v3 |
| </footer> |
| |
| <script> |
| let countdownSeconds = 0; |
| let isPushEnabled = false; |
| |
| async function fetchStatus() {{ |
| try {{ |
| const response = await fetch('/api/push-status'); |
| if (!response.ok) throw new Error('Network response was not ok'); |
| const data = await response.json(); |
| |
| isPushEnabled = data.enabled; |
| countdownSeconds = data.remaining_seconds || 0; |
| |
| const switchEl = document.getElementById('push-switch'); |
| if (switchEl.checked !== isPushEnabled) {{ |
| switchEl.checked = isPushEnabled; |
| }} |
| |
| updateCountdownUI(); |
| }} catch (err) {{ |
| console.error('Failed to fetch status:', err); |
| }} |
| }} |
| |
| function updateCountdownUI() {{ |
| const labelEl = document.getElementById('countdown-label'); |
| if (!isPushEnabled) {{ |
| labelEl.textContent = '⏸ 隨機推播已暫停'; |
| labelEl.className = 'countdown-text disabled'; |
| return; |
| }} |
| |
| if (countdownSeconds <= 0) {{ |
| labelEl.textContent = '⏳ 即將發送推播...'; |
| labelEl.className = 'countdown-text active'; |
| return; |
| }} |
| |
| const hrs = Math.floor(countdownSeconds / 3600); |
| const mins = Math.floor((countdownSeconds % 3600) / 60); |
| const secs = countdownSeconds % 60; |
| |
| const timeStr = [ |
| hrs.toString().padStart(2, '0'), |
| mins.toString().padStart(2, '0'), |
| secs.toString().padStart(2, '0') |
| ].join(':'); |
| |
| labelEl.textContent = `⏱ 下次推播倒數: ${{timeStr}}`; |
| labelEl.className = 'countdown-text active'; |
| }} |
| |
| async function togglePush(enabled) {{ |
| try {{ |
| const response = await fetch('/api/push-toggle', {{ |
| method: 'POST', |
| headers: {{ |
| 'Content-Type': 'application/json' |
| }}, |
| body: JSON.stringify({{ enabled }}) |
| }}); |
| if (!response.ok) throw new Error('Failed to toggle status'); |
| const data = await response.json(); |
| isPushEnabled = data.enabled; |
| await fetchStatus(); |
| }} catch (err) {{ |
| console.error('Failed to toggle push:', err); |
| document.getElementById('push-switch').checked = !enabled; |
| }} |
| }} |
| |
| document.addEventListener('DOMContentLoaded', () => {{ |
| fetchStatus(); |
| |
| // 每 5 秒與伺服器同步 |
| setInterval(fetchStatus, 5000); |
| |
| // 每 1 秒遞減 |
| setInterval(() => {{ |
| if (isPushEnabled && countdownSeconds > 0) {{ |
| countdownSeconds--; |
| updateCountdownUI(); |
| }} |
| }}, 1000); |
| |
| // 監聽開關 |
| document.getElementById('push-switch').addEventListener('change', (e) => {{ |
| togglePush(e.target.checked); |
| }}); |
| }}); |
| </script> |
| </body> |
| </html> |
| """ |
| return HTMLResponse(content=html_content, status_code=200) |
|
|
|
|
| if __name__ == "__main__": |
| import uvicorn |
| |
| port = int(os.getenv("PORT", 7860)) |
| uvicorn.run("app:app", host="0.0.0.0", port=port, reload=True) |
|
|