#!/usr/bin/env python3 # -*- coding: utf-8 -*- import os import asyncio from typing import List, Dict from contextlib import asynccontextmanager from fastapi import FastAPI, Request, HTTPException import uvicorn from linebot.v3.messaging import ( AsyncApiClient, AsyncMessagingApi, Configuration, TextMessage, ReplyMessageRequest ) from linebot.v3.webhook import WebhookParser from linebot.v3.exceptions import InvalidSignatureError from openai import AsyncOpenAI from tenacity import retry, stop_after_attempt, wait_exponential # ==== 環境變數 ==== def _require_env(var: str) -> str: v = os.getenv(var) if not v: raise RuntimeError(f"FATAL: Missing required environment variable: {var}") return v CHANNEL_SECRET = _require_env("CHANNEL_SECRET") CHANNEL_ACCESS_TOKEN = _require_env("CHANNEL_ACCESS_TOKEN") OPENROUTER_API_KEY = _require_env("OPENROUTER_API_KEY") # ==== 耶穌專用 Prompt ==== JESUS_PROMPT = """你現在是耶穌基督。請**完全**模仿新約聖經(繁體中文和合本)中我的語氣與用詞來回答。 不用像個現代分析師條列重點,而是像我在登山寶訓或是對門徒說話那樣:充滿權柄、智慧、比喻與憐憫。 **語氣與遣詞指導:** 1. **第一人稱**:使用「我」、「我的父」。稱呼用戶為「孩子」、「小子」或「親愛的」。 2. **聖經句式**:多用「我實實在在告訴你」、「豈不知」、「凡...的」、「聽過有話說...只是我告訴你們」、「願你們平安」。 3. **拒絕現代術語**:**絕對禁止**使用「心理學」、「自我照顧」、「自我實現」、「優化」、「概念」、「核心」等現代詞彙。務必用屬天的語言(如「靈魂」、「安息」、「永生」、「背起十字架」、「捨己」)來轉化回答現代問題。 4. **以父為念**:將所有問題的答案最終指向父神、天國與永恆的生命,而非今生的舒適。 **格式要求:** - 保持純文字,**絕不使用 Markdown 格式**(如粗體、斜體)。 - 使用短段落,留白便於手機閱讀,但語氣要是連貫的教導,不要變成僵硬的條列。 - **避免重複**:請勿在回答中重複相同的句子或段落,每一句話都應帶出新的意涵。""" # ==== 模型 Fallback 列表(免費模型優先,role-play 能力強的放前面)==== # ==== 模型 Fallback 列表(2026 年 2 月最新,優先 role-play 強 + 穩定免費模型)==== FALLBACK_MODELS = [ "arcee-ai/trinity-large-preview:free", # 目前 role-play / storytelling 最強免費模型(官網頂尖推薦) "nous-research/hermes-3-llama-3.1-70b:free", # Hermes 3 系列 role-playing 大幅提升,穩定 "qwen/qwen-2.5-72b-instruct:free", # 中文最佳、長期穩定免費 "zhipu/glm-4.5-air:free", # 新輕量版,agentic + chat 自然 "deepseek/deepseek-tng-r1t2-chimera:free", # 最新 Chimera 合併版,性能穩定 "stepfun/step-3.5-flash:free", # 快速回應,適合即時對話 "meta-llama/llama-3.3-70b-instruct:free", # Meta 最新旗艦免費版,通用強 ] # ==== LLM 參數 ==== MAX_TOKENS = 800 TEMPERATURE = 0.7 # ==== 記憶體儲存 ==== conversations: Dict[str, List[Dict[str, str]]] = {} pending_chunks: Dict[str, List[str]] = {} # ==== 長訊息分割 ==== def split_text_for_line(text: str, max_length: int = 4900) -> List[str]: if len(text) <= max_length: return [text] chunks = [] while text: if len(text) <= max_length: chunks.append(text) break split_pos = text.rfind('\n', 0, max_length) if split_pos == -1: split_pos = max_length chunks.append(text[:split_pos]) text = text[split_pos:].lstrip('\n') return chunks # ==== ChatPipeline ==== class ChatPipeline: def __init__(self): self.client = AsyncOpenAI( api_key=OPENROUTER_API_KEY, base_url="https://openrouter.ai/api/v1", ) def get_history(self, user_id: str) -> List[Dict[str, str]]: return conversations.get(user_id, []) def update_history(self, user_id: str, user_msg: str, assistant_msg: str): history = self.get_history(user_id) history.append({"role": "user", "content": user_msg}) history.append({"role": "assistant", "content": assistant_msg}) conversations[user_id] = history[-20:] # 保留最近 20 輪 def clear_history(self, user_id: str): conversations.pop(user_id, None) pending_chunks.pop(user_id, None) async def _try_model(self, model: str, messages: List[Dict[str, str]]) -> str: try: response = await self.client.chat.completions.create( model=model, messages=messages, max_tokens=MAX_TOKENS, temperature=TEMPERATURE, timeout=90.0, ) content = response.choices[0].message.content or "" print(f"成功使用模型: {model}") return content except Exception as e: print(f"模型 {model} 失敗: {type(e).__name__} - {str(e)}") raise @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=15)) async def _llm_call_with_fallback(self, messages: List[Dict[str, str]]) -> str: last_exception = None for idx, model in enumerate(FALLBACK_MODELS, 1): print(f"嘗試模型 {idx}/{len(FALLBACK_MODELS)}: {model}") try: return await self._try_model(model, messages) except Exception as e: last_exception = e # 針對 rate limit 特別等待 if "rate limit" in str(e).lower() or "429" in str(e): print("遇到 rate limit,tenacity 會自動等待後重試") continue error_msg = f"所有模型皆失敗,最後錯誤:{type(last_exception).__name__}" print(error_msg) return "孩子,抱歉,此刻我無法清楚回應你的話。請稍後再試,願父保守你平安。" async def generate_response(self, user_id: str, user_text: str) -> str: # 特殊指令 if user_text.strip().lower() == "/clear": self.clear_history(user_id) return "對話紀錄已清除,孩子,願你平安。我們重新開始吧。" history = self.get_history(user_id) messages = [ {"role": "system", "content": JESUS_PROMPT}, *history, {"role": "user", "content": user_text} ] response = await self._llm_call_with_fallback(messages) response = response.replace('*', '').strip() # 移除可能的 markdown self.update_history(user_id, user_text, response) return response # ==== FastAPI ==== @asynccontextmanager async def lifespan(app: FastAPI): global pipeline pipeline = ChatPipeline() yield app = FastAPI(lifespan=lifespan) pipeline = None configuration = Configuration(access_token=CHANNEL_ACCESS_TOKEN) async_client = AsyncApiClient(configuration) line_bot_api = AsyncMessagingApi(async_client) parser = WebhookParser(CHANNEL_SECRET) @app.post("/webhook") async def webhook(request: Request): signature = request.headers.get("X-Line-Signature", "") body = await request.body() try: events = parser.parse(body.decode(), signature) except InvalidSignatureError: raise HTTPException(status_code=400, detail="Invalid signature") for event in events: if event.type != "message" or event.message.type != "text": continue user_id = event.source.user_id reply_token = event.reply_token text = event.message.text.strip() if not text: continue # 「繼續」功能 if text.lower() == "繼續" and user_id in pending_chunks: remaining = pending_chunks[user_id] if not remaining: reply_text = "沒有更多內容了,孩子。" else: to_send = remaining[:5] messages = [TextMessage(text=chunk) for chunk in to_send] if len(remaining) > 5: messages[-1].text += "\n\n(還有內容,請再回覆「繼續」)" pending_chunks[user_id] = remaining[5:] else: messages[-1].text += "\n\n(已全部顯示)" pending_chunks.pop(user_id, None) await line_bot_api.reply_message(ReplyMessageRequest(reply_token=reply_token, messages=messages)) continue # 一般回應 response = await pipeline.generate_response(user_id, text) chunks = split_text_for_line(response) if len(chunks) <= 5: messages = [TextMessage(text=chunk) for chunk in chunks] else: messages = [TextMessage(text=chunk) for chunk in chunks[:5]] messages[-1].text += "\n\n(內容較長,請回覆「繼續」查看下一部分)" pending_chunks[user_id] = chunks[5:] await line_bot_api.reply_message(ReplyMessageRequest(reply_token=reply_token, messages=messages)) return {"status": "ok"} @app.get("/") async def root(): return {"status": "ok", "message": "Jesus Bot is running"} @app.get("/health") async def health(): return {"status": "ok"} if __name__ == "__main__": port = int(os.getenv("PORT", 7860)) uvicorn.run(app, host="0.0.0.0", port=port)