Doris33's picture
Update app.py
1fd1cc8 verified
Raw
History Blame Contribute Delete
40.9 kB
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
# 載入 .env 檔案(如果存在)
load_dotenv()
# 設定日誌
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
# 導入 line-bot-sdk v3 套件
# 注意:line-bot-sdk v3 將 APIs 與 Webhooks 模組化分離
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
# 導入 Google GenAI SDK
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
# 用於紀錄曾經發送訊息給 Bot 的群組、房間或使用者 ID,作為隨機推播的發送對象
ACTIVE_TARGETS: Set[str] = set()
# Gemini 系統提示詞
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:
# 呼叫 LINE API 取得 Bot 本身資訊
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)
# 呼叫 Gemini API 產生主動推播訊息
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}")
# 嘗試使用備用金鑰 GEMINI_API_KEY1
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}")
# 若 Gemini API 呼叫失敗或未設定,則使用預設無厘頭訊息庫
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
# 設定隨機間隔時間(例如:每 1 至 4 小時推播一次,即 3600 到 14400 秒)
# 為了展示與測試方便,這裡設定為 1800 至 7200 秒(30分鐘至2小時)
# 您可以根據實際需求調整此數值
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
# 使用 FastAPI Lifespan 管理啟動與關閉事件
@asynccontextmanager
async def lifespan(app: FastAPI):
# [啟動] 初始化 Bot 資訊
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"]:
# 1-on-1 私訊一律視為被提及
return True
# 檢查 LINE 內建的 mention 資料
mention = getattr(event.message, "mention", None)
if mention and hasattr(mention, "mentionees") and mention.mentionees:
# 如果有查到 Bot 的 userId,進行精準比對
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:
# 若因尚未設定憑證而無法查到 Bot userId,但有 @ 任何人,且文字中有 @ 符號,則採取寬鬆的 fallback 判斷
if len(mention.mentionees) > 0 and "@" in text:
return True
# Fallback: 比對 Bot 的 display name
if bot_display_name and f"@{bot_display_name}" in text:
return True
# Fallback: 如果訊息文字開頭是 @
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
# 1. 記錄互動對象,供後續隨機推播使用
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}")
# 2. 判斷是否是在群組中被 @ 提及
if not is_bot_mentioned(event, text):
# 如果在群組中沒被 @,則靜默不回應
return
# 3. 處理被 @ 之後的邏輯
messaging_api = get_messaging_api()
if not messaging_api:
logger.warning("無法回覆訊息:未設定 LINE 憑證")
return
# 4. 判斷是否要求生成圖片 (偵測 "@create image" 關鍵字)
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}")
# 串接 Good Morning API 進行生圖
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:
# 使用同步的 httpx 進行 API 請求,因 handle_text_message 在背景執行緒中執行
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}")
# 發生錯誤時的備用圖片 (Unsplash)
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 模擬生成圖片!"
# 透過 push_message 發送生成好的圖片與早安語錄,並顯示以秒為單位的時間
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()
# 呼叫 Gemini API 取得回覆
reply_text = None
gemini_client = get_gemini_client()
if gemini_client:
try:
# 呼叫 Gemini 2.5 Flash 產生內容
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}")
# 嘗試使用備用金鑰 GEMINI_API_KEY1
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})"
# 若沒有設定 API Key 或是 SDK 不可用
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:
# 僅處理文字訊息事件
# 在 line-bot-sdk v3 中,事件類型與內容皆有對應的屬性
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")
# 驗證 Webhook 簽章
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")
# 將事件處理放入 FastAPI 背景任務,立刻向 LINE 伺服器回傳 HTTP 200,以防止 LINE 逾時重發
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
# 本地測試時執行 python app.py
port = int(os.getenv("PORT", 7860)) # Hugging Face Spaces 預設使用 7860 端口
uvicorn.run("app:app", host="0.0.0.0", port=port, reload=True)