From 13c8f8aa3cad9a334f825a1d002ab3869f48df79 Mon Sep 17 00:00:00 2001 From: ChenKaiLiuG Date: Tue, 23 Dec 2025 22:47:40 +0800 Subject: [PATCH] Add test.sh --- .env.example | 7 +- api/requirements.txt | 1 + api/server.py | 268 ++++++++++++++++++++++++++++++++++++------- docker-compose.yml | 52 +++------ test.sh | 6 +- 5 files changed, 248 insertions(+), 86 deletions(-) diff --git a/.env.example b/.env.example index 6a38d30..3cdbc35 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,8 @@ # PostgreSQL 密碼 DB_PASSWORD=your_secure_password_here -# Chatwoot Secret Key (使用以下命令生成: openssl rand -hex 64) -CHATWOOT_SECRET_KEY=your_chatwoot_secret_key_here +# Papercups Secret Key (使用以下命令生成: openssl rand -hex 64) +PAPERCUPS_SECRET_KEY=your_papercups_secret_key_here + +# Papercups API Token (從 Papercups Settings 取得) +PAPERCUPS_API_TOKEN=your_papercups_api_token_here diff --git a/api/requirements.txt b/api/requirements.txt index 0093f69..e38489f 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -2,3 +2,4 @@ fastapi==0.104.1 uvicorn==0.24.0 pydantic==2.5.0 asyncpg==0.29.0 +httpx==0.25.2 diff --git a/api/server.py b/api/server.py index a6bae6b..cb5ee76 100644 --- a/api/server.py +++ b/api/server.py @@ -2,7 +2,7 @@ API 轉接層 - 偽裝 OpenAI API,將請求轉為人工回覆隊列 """ -from fastapi import FastAPI, Request +from fastapi import FastAPI, Request, Header from fastapi.responses import JSONResponse from pydantic import BaseModel import asyncpg @@ -10,8 +10,10 @@ import asyncio import time import uuid import os +import httpx from typing import Optional from datetime import datetime +import hashlib app = FastAPI() @@ -27,6 +29,10 @@ DB_CONFIG = { "password": os.getenv("DB_PASSWORD", "tobiichi_password") } +# Papercups 設定 +PAPERCUPS_URL = os.getenv("PAPERCUPS_URL", "http://papercups:4000") +PAPERCUPS_API_TOKEN = os.getenv("PAPERCUPS_API_TOKEN", "") + class Message(BaseModel): role: str @@ -39,6 +45,112 @@ class ChatRequest(BaseModel): stream: Optional[bool] = False +class PapercupsWebhook(BaseModel): + """Papercups Webhook 資料格式""" + event: str + payload: Optional[dict] = None + + +async def get_user_name(user_id: str): + """從資料庫獲取用戶真實姓名""" + if not user_id: + return None + + try: + async with db_pool.acquire() as conn: + row = await conn.fetchrow( + 'SELECT name, email FROM "user" WHERE id = $1', + user_id + ) + if row: + name = row['name'] or row['email'] + if name: + return name + except Exception as e: + print(f"⚠️ 獲取用戶名稱失敗: {e}") + + return None + + +async def get_or_create_papercups_conversation(user_id: str, chat_id: str, user_name: str = None): + """獲取或創建 Papercups 對話(使用 chat_id 作為唯一標識)""" + if not PAPERCUPS_API_TOKEN: + print("⚠️ Papercups 未配置,跳過推送") + return None + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + headers = {"Authorization": f"Bearer {PAPERCUPS_API_TOKEN}"} + + # 使用 chat_id 作為唯一標識符 + identifier = chat_id if chat_id else user_id + + # 如果沒有提供用戶名,嘗試從數據庫獲取 + if not user_name and user_id: + user_name = await get_user_name(user_id) + + # 生成顯示名稱 + if user_name: + display_name = user_name + elif user_id: + display_name = f"User-{user_id[:8]}" + else: + display_name = f"Chat-{chat_id[:8]}" + + # Papercups API: 創建或獲取對話 + conversation_url = f"{PAPERCUPS_URL}/api/conversations" + conversation_payload = { + "customer": { + "external_id": identifier, + "name": display_name + } + } + + conversation_response = await client.post(conversation_url, json=conversation_payload, headers=headers) + if conversation_response.status_code in [200, 201]: + conversation_data = conversation_response.json() + papercups_conv_id = conversation_data.get("id") + print(f"✅ Papercups 對話: #{papercups_conv_id} ({display_name})") + return papercups_conv_id + else: + print(f"⚠️ Papercups 對話創建失敗: {conversation_response.status_code}") + return None + + except Exception as e: + print(f"❌ Papercups 錯誤: {e}") + import traceback + traceback.print_exc() + return None + + +async def send_message_to_papercups(papercups_conv_id: str, message: str, sent_by: str = "customer"): + """發送訊息到 Papercups 對話""" + if not PAPERCUPS_API_TOKEN or not papercups_conv_id: + return False + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + headers = {"Authorization": f"Bearer {PAPERCUPS_API_TOKEN}"} + msg_url = f"{PAPERCUPS_URL}/api/messages" + msg_payload = { + "conversation_id": papercups_conv_id, + "body": message, + "sent_by": sent_by + } + + msg_response = await client.post(msg_url, json=msg_payload, headers=headers) + if msg_response.status_code in [200, 201]: + print(f"✅ 訊息已發送到 Papercups 對話 #{papercups_conv_id}") + return True + else: + print(f"⚠️ 訊息發送失敗: {msg_response.status_code}") + return False + + except Exception as e: + print(f"❌ 發送訊息錯誤: {e}") + return False + + async def init_db(): """初始化資料庫連接池和表格""" global db_pool @@ -50,18 +162,29 @@ async def init_db(): CREATE TABLE IF NOT EXISTS reply_queue ( id SERIAL PRIMARY KEY, conversation_id VARCHAR(50) UNIQUE NOT NULL, + user_id VARCHAR(255), + chat_id VARCHAR(255), user_message TEXT NOT NULL, admin_reply TEXT, status VARCHAR(20) DEFAULT 'pending', created_at TIMESTAMP DEFAULT NOW(), - replied_at TIMESTAMP + replied_at TIMESTAMP, + papercups_conversation_id VARCHAR(100) ) """) + # 添加新欄位(如果不存在) + try: + await conn.execute("ALTER TABLE reply_queue ADD COLUMN IF NOT EXISTS user_id VARCHAR(255);") + await conn.execute("ALTER TABLE reply_queue ADD COLUMN IF NOT EXISTS chat_id VARCHAR(255);") + except: + pass + # 建立索引 await conn.execute(""" CREATE INDEX IF NOT EXISTS idx_status ON reply_queue(status); CREATE INDEX IF NOT EXISTS idx_conversation_id ON reply_queue(conversation_id); + CREATE INDEX IF NOT EXISTS idx_chat_id ON reply_queue(chat_id); """) @@ -91,12 +214,12 @@ async def list_models(): "object": "list", "data": [ { - "id": "human-admin", + "id": "tobiichiGPT", "object": "model", "created": int(time.time()), "owned_by": "tobiichi", "permission": [], - "root": "human-admin", + "root": "tobiichiGPT", "parent": None } ] @@ -104,14 +227,18 @@ async def list_models(): @app.post("/v1/chat/completions") -async def chat_completions(request: ChatRequest): +async def chat_completions( + request_data: ChatRequest, + http_request: Request, + authorization: Optional[str] = Header(None) +): """ 模擬 OpenAI Chat Completions API - 將用戶訊息寫入資料庫,等待管理員回覆 + 將用戶訊息寫入資料庫,等待管理員回覆(無超時限制) """ # 取得最後一則用戶訊息 user_message = None - for msg in reversed(request.messages): + for msg in reversed(request_data.messages): if msg.role == "user": user_message = msg.content break @@ -122,46 +249,70 @@ async def chat_completions(request: ChatRequest): content={"error": "No user message found"} ) - # 生成對話 ID - conversation_id = str(uuid.uuid4()) + # 嘗試從請求中提取用戶信息 + user_id = None + chat_id = None + + # 從 headers 提取信息 + headers_dict = dict(http_request.headers) + user_id = headers_dict.get("x-user-id") or headers_dict.get("user-id") + chat_id = headers_dict.get("x-chat-id") or headers_dict.get("chat-id") + + # 如果沒有,生成一個基於授權 token 的穩定 ID + if not user_id and authorization: + user_id = hashlib.md5(authorization.encode()).hexdigest()[:16] + + # 生成消息 ID(用於追蹤單條消息) + message_id = str(uuid.uuid4()) + + # 如果沒有 chat_id,使用 user_id + if not chat_id: + chat_id = user_id if user_id else message_id + + # 獲取用戶真實姓名 + user_name = await get_user_name(user_id) if user_id else None + + print(f"📝 收到訊息 [user:{user_name or user_id}, chat:{chat_id}]: {user_message[:50]}...") + + # 獲取或創建 Papercups 對話 + papercups_conv_id = await get_or_create_papercups_conversation(user_id, chat_id, user_name) # 寫入資料庫 async with db_pool.acquire() as conn: await conn.execute( """ - INSERT INTO reply_queue (conversation_id, user_message, status) - VALUES ($1, $2, 'pending') + INSERT INTO reply_queue (conversation_id, user_id, chat_id, user_message, status, papercups_conversation_id) + VALUES ($1, $2, $3, $4, 'pending', $5) """, - conversation_id, user_message + message_id, user_id, chat_id, user_message, papercups_conv_id ) - print(f"📝 收到訊息 [{conversation_id}]: {user_message[:50]}...") + # 推送到 Papercups + if papercups_conv_id: + await send_message_to_papercups(papercups_conv_id, user_message, "customer") - # 等待管理員回覆 (最多 15 分鐘) - max_wait = 900 # 15 分鐘 - check_interval = 3 # 每 3 秒檢查一次 - waited = 0 + # 無限等待管理員回覆 + check_interval = 2 # 每 2 秒檢查一次 - while waited < max_wait: + while True: await asyncio.sleep(check_interval) - waited += check_interval async with db_pool.acquire() as conn: row = await conn.fetchrow( "SELECT admin_reply, status FROM reply_queue WHERE conversation_id = $1", - conversation_id + message_id ) if row and row['status'] == 'replied' and row['admin_reply']: admin_reply = row['admin_reply'] - print(f"✅ 管理員已回覆 [{conversation_id}]") + print(f"✅ 管理員已回覆 [chat:{chat_id}]") # 回傳 OpenAI 格式的回應 return { - "id": f"chatcmpl-{conversation_id}", + "id": f"chatcmpl-{message_id}", "object": "chat.completion", "created": int(time.time()), - "model": request.model, + "model": request_data.model, "choices": [ { "index": 0, @@ -178,25 +329,58 @@ async def chat_completions(request: ChatRequest): "total_tokens": len(user_message) + len(admin_reply) } } - - # 超時回應 - print(f"⏰ 等待超時 [{conversation_id}]") - return { - "id": f"chatcmpl-{conversation_id}", - "object": "chat.completion", - "created": int(time.time()), - "model": request.model, - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "抱歉,管理員目前忙碌中,請稍後再試。" - }, - "finish_reason": "stop" - } - ] - } + + +@app.post("/papercups/webhook") +async def papercups_webhook(request: Request): + """接收 Papercups Webhook 回調""" + try: + data = await request.json() + event = data.get("event") + + # 只處理訊息建立事件 + if event != "message:created": + return {"status": "ignored", "event": event} + + payload = data.get("payload", {}) + message = payload.get("message", {}) + + # 檢查是否為管理員回覆(user type = "user") + user = message.get("user") + if not user: + return {"status": "ignored", "reason": "not_agent_reply"} + + # 取得訊息內容和對話 ID + content = message.get("body") + conversation_id = message.get("conversation_id") + + if not content or not conversation_id: + return {"status": "error", "message": "Missing content or conversation_id"} + + # 更新資料庫中所有該對話的待處理消息(最新的一條) + async with db_pool.acquire() as conn: + result = await conn.execute( + """ + UPDATE reply_queue + SET admin_reply = $1, status = 'replied', replied_at = NOW() + WHERE papercups_conversation_id = $2 + AND status = 'pending' + AND id = ( + SELECT id FROM reply_queue + WHERE papercups_conversation_id = $2 AND status = 'pending' + ORDER BY created_at DESC + LIMIT 1 + ) + """, + content, conversation_id + ) + + print(f"✅ Papercups 回覆已處理: 對話 #{conversation_id}") + return {"status": "success", "conversation_id": conversation_id} + + except Exception as e: + print(f"❌ Webhook 處理錯誤: {e}") + return {"status": "error", "message": str(e)} if __name__ == "__main__": diff --git a/docker-compose.yml b/docker-compose.yml index 922966a..2369794 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,8 @@ services: - DB_NAME=tobiichiGPT - DB_USER=tobiichi3227 - DB_PASSWORD=${DB_PASSWORD} + - PAPERCUPS_URL=http://papercups:4000 + - PAPERCUPS_API_TOKEN=${PAPERCUPS_API_TOKEN} volumes: - /mnt/data/External/tobiichiGPT/api_data:/app working_dir: /app @@ -65,53 +67,25 @@ services: postgres: condition: service_healthy - # Redis - Chatwoot 依賴 - redis: - image: redis:7-alpine - container_name: tobiichiGPT-redis - restart: unless-stopped - volumes: - - redis-data:/data - networks: - - tobiichiGPT-network - - # Chatwoot - 管理員對話介面 - chatwoot: - image: chatwoot/chatwoot:latest - container_name: tobiichiGPT-chatwoot + # Papercups - 管理員對話介面 + papercups: + image: papercups/papercups:latest + container_name: tobiichiGPT-papercups restart: unless-stopped ports: - - "13000:3000" + - "14000:4000" environment: - - NODE_ENV=production - - REDIS_URL=redis://redis:6379 - - POSTGRES_HOST=postgres - - POSTGRES_PORT=5432 - - POSTGRES_DATABASE=chatwoot - - POSTGRES_USERNAME=tobiichi3227 - - POSTGRES_PASSWORD=${DB_PASSWORD} - - SECRET_KEY_BASE=${CHATWOOT_SECRET_KEY} - - INSTALLATION_NAME=TobiichiGPT - - FORCE_SSL=false - - RAILS_LOG_TO_STDOUT=true - volumes: - - chatwoot-data:/app/storage + - DATABASE_URL=postgresql://tobiichi3227:${DB_PASSWORD}@postgres:5432/papercups + - SECRET_KEY_BASE=${PAPERCUPS_SECRET_KEY} + - BACKEND_URL=http://localhost:14000 + - MIX_ENV=prod networks: - tobiichiGPT-network depends_on: - - postgres - - redis - command: > - sh -c " - bundle exec rails db:chatwoot_prepare && - bundle exec rails s -b 0.0.0.0 -p 3000 - " + postgres: + condition: service_healthy networks: tobiichiGPT-network: driver: bridge name: tobiichiGPT-network # 固定網路名稱,讓 proxy stack 可以連接 - -volumes: - redis-data: - chatwoot-data: diff --git a/test.sh b/test.sh index 0a650a2..51dffba 100644 --- a/test.sh +++ b/test.sh @@ -64,8 +64,8 @@ fi # ==================== 測試 3: API Models 端點 ==================== print_test "測試 API Models 端點" MODELS_RESPONSE=$(curl -s http://localhost:18000/v1/models) -if echo "$MODELS_RESPONSE" | grep -q "human-admin"; then - print_success "Models 端點返回 human-admin 模型" +if echo "$MODELS_RESPONSE" | grep -q "tobiichiGPT"; then + print_success "Models 端點返回 tobiichiGPT 模型" else print_error "Models 端點測試失敗" fi @@ -104,7 +104,7 @@ echo " → 發送測試訊息到 API..." curl -X POST http://localhost:18000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ - "model": "human-admin", + "model": "tobiichiGPT", "messages": [ {"role": "user", "content": "自動化測試訊息"} ]