Add test.sh

This commit is contained in:
ChenKaiLiuG
2025-12-23 22:47:40 +08:00
parent a8f57ec60b
commit 13c8f8aa3c
5 changed files with 248 additions and 86 deletions

View File

@@ -1,5 +1,8 @@
# PostgreSQL 密碼 # PostgreSQL 密碼
DB_PASSWORD=your_secure_password_here DB_PASSWORD=your_secure_password_here
# Chatwoot Secret Key (使用以下命令生成: openssl rand -hex 64) # Papercups Secret Key (使用以下命令生成: openssl rand -hex 64)
CHATWOOT_SECRET_KEY=your_chatwoot_secret_key_here PAPERCUPS_SECRET_KEY=your_papercups_secret_key_here
# Papercups API Token (從 Papercups Settings 取得)
PAPERCUPS_API_TOKEN=your_papercups_api_token_here

View File

@@ -2,3 +2,4 @@ fastapi==0.104.1
uvicorn==0.24.0 uvicorn==0.24.0
pydantic==2.5.0 pydantic==2.5.0
asyncpg==0.29.0 asyncpg==0.29.0
httpx==0.25.2

View File

@@ -2,7 +2,7 @@
API 轉接層 - 偽裝 OpenAI API將請求轉為人工回覆隊列 API 轉接層 - 偽裝 OpenAI API將請求轉為人工回覆隊列
""" """
from fastapi import FastAPI, Request from fastapi import FastAPI, Request, Header
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
import asyncpg import asyncpg
@@ -10,8 +10,10 @@ import asyncio
import time import time
import uuid import uuid
import os import os
import httpx
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime
import hashlib
app = FastAPI() app = FastAPI()
@@ -27,6 +29,10 @@ DB_CONFIG = {
"password": os.getenv("DB_PASSWORD", "tobiichi_password") "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): class Message(BaseModel):
role: str role: str
@@ -39,6 +45,112 @@ class ChatRequest(BaseModel):
stream: Optional[bool] = False 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(): async def init_db():
"""初始化資料庫連接池和表格""" """初始化資料庫連接池和表格"""
global db_pool global db_pool
@@ -50,18 +162,29 @@ async def init_db():
CREATE TABLE IF NOT EXISTS reply_queue ( CREATE TABLE IF NOT EXISTS reply_queue (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
conversation_id VARCHAR(50) UNIQUE NOT NULL, conversation_id VARCHAR(50) UNIQUE NOT NULL,
user_id VARCHAR(255),
chat_id VARCHAR(255),
user_message TEXT NOT NULL, user_message TEXT NOT NULL,
admin_reply TEXT, admin_reply TEXT,
status VARCHAR(20) DEFAULT 'pending', status VARCHAR(20) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT NOW(), 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(""" await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_status ON reply_queue(status); 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_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", "object": "list",
"data": [ "data": [
{ {
"id": "human-admin", "id": "tobiichiGPT",
"object": "model", "object": "model",
"created": int(time.time()), "created": int(time.time()),
"owned_by": "tobiichi", "owned_by": "tobiichi",
"permission": [], "permission": [],
"root": "human-admin", "root": "tobiichiGPT",
"parent": None "parent": None
} }
] ]
@@ -104,14 +227,18 @@ async def list_models():
@app.post("/v1/chat/completions") @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 模擬 OpenAI Chat Completions API
將用戶訊息寫入資料庫,等待管理員回覆 將用戶訊息寫入資料庫,等待管理員回覆(無超時限制)
""" """
# 取得最後一則用戶訊息 # 取得最後一則用戶訊息
user_message = None user_message = None
for msg in reversed(request.messages): for msg in reversed(request_data.messages):
if msg.role == "user": if msg.role == "user":
user_message = msg.content user_message = msg.content
break break
@@ -122,46 +249,70 @@ async def chat_completions(request: ChatRequest):
content={"error": "No user message found"} 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: async with db_pool.acquire() as conn:
await conn.execute( await conn.execute(
""" """
INSERT INTO reply_queue (conversation_id, user_message, status) INSERT INTO reply_queue (conversation_id, user_id, chat_id, user_message, status, papercups_conversation_id)
VALUES ($1, $2, 'pending') 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 = 2 # 每 2 秒檢查一次
check_interval = 3 # 每 3 秒檢查一次
waited = 0
while waited < max_wait: while True:
await asyncio.sleep(check_interval) await asyncio.sleep(check_interval)
waited += check_interval
async with db_pool.acquire() as conn: async with db_pool.acquire() as conn:
row = await conn.fetchrow( row = await conn.fetchrow(
"SELECT admin_reply, status FROM reply_queue WHERE conversation_id = $1", "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']: if row and row['status'] == 'replied' and row['admin_reply']:
admin_reply = row['admin_reply'] admin_reply = row['admin_reply']
print(f"✅ 管理員已回覆 [{conversation_id}]") print(f"✅ 管理員已回覆 [chat:{chat_id}]")
# 回傳 OpenAI 格式的回應 # 回傳 OpenAI 格式的回應
return { return {
"id": f"chatcmpl-{conversation_id}", "id": f"chatcmpl-{message_id}",
"object": "chat.completion", "object": "chat.completion",
"created": int(time.time()), "created": int(time.time()),
"model": request.model, "model": request_data.model,
"choices": [ "choices": [
{ {
"index": 0, "index": 0,
@@ -179,24 +330,57 @@ async def chat_completions(request: ChatRequest):
} }
} }
# 超時回應
print(f"⏰ 等待超時 [{conversation_id}]") @app.post("/papercups/webhook")
return { async def papercups_webhook(request: Request):
"id": f"chatcmpl-{conversation_id}", """接收 Papercups Webhook 回調"""
"object": "chat.completion", try:
"created": int(time.time()), data = await request.json()
"model": request.model, event = data.get("event")
"choices": [
{ # 只處理訊息建立事件
"index": 0, if event != "message:created":
"message": { return {"status": "ignored", "event": event}
"role": "assistant",
"content": "抱歉,管理員目前忙碌中,請稍後再試。" payload = data.get("payload", {})
}, message = payload.get("message", {})
"finish_reason": "stop"
} # 檢查是否為管理員回覆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__": if __name__ == "__main__":

View File

@@ -33,6 +33,8 @@ services:
- DB_NAME=tobiichiGPT - DB_NAME=tobiichiGPT
- DB_USER=tobiichi3227 - DB_USER=tobiichi3227
- DB_PASSWORD=${DB_PASSWORD} - DB_PASSWORD=${DB_PASSWORD}
- PAPERCUPS_URL=http://papercups:4000
- PAPERCUPS_API_TOKEN=${PAPERCUPS_API_TOKEN}
volumes: volumes:
- /mnt/data/External/tobiichiGPT/api_data:/app - /mnt/data/External/tobiichiGPT/api_data:/app
working_dir: /app working_dir: /app
@@ -65,53 +67,25 @@ services:
postgres: postgres:
condition: service_healthy condition: service_healthy
# Redis - Chatwoot 依賴 # Papercups - 管理員對話介面
redis: papercups:
image: redis:7-alpine image: papercups/papercups:latest
container_name: tobiichiGPT-redis container_name: tobiichiGPT-papercups
restart: unless-stopped
volumes:
- redis-data:/data
networks:
- tobiichiGPT-network
# Chatwoot - 管理員對話介面
chatwoot:
image: chatwoot/chatwoot:latest
container_name: tobiichiGPT-chatwoot
restart: unless-stopped restart: unless-stopped
ports: ports:
- "13000:3000" - "14000:4000"
environment: environment:
- NODE_ENV=production - DATABASE_URL=postgresql://tobiichi3227:${DB_PASSWORD}@postgres:5432/papercups
- REDIS_URL=redis://redis:6379 - SECRET_KEY_BASE=${PAPERCUPS_SECRET_KEY}
- POSTGRES_HOST=postgres - BACKEND_URL=http://localhost:14000
- POSTGRES_PORT=5432 - MIX_ENV=prod
- 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
networks: networks:
- tobiichiGPT-network - tobiichiGPT-network
depends_on: depends_on:
- postgres postgres:
- redis condition: service_healthy
command: >
sh -c "
bundle exec rails db:chatwoot_prepare &&
bundle exec rails s -b 0.0.0.0 -p 3000
"
networks: networks:
tobiichiGPT-network: tobiichiGPT-network:
driver: bridge driver: bridge
name: tobiichiGPT-network # 固定網路名稱,讓 proxy stack 可以連接 name: tobiichiGPT-network # 固定網路名稱,讓 proxy stack 可以連接
volumes:
redis-data:
chatwoot-data:

View File

@@ -64,8 +64,8 @@ fi
# ==================== 測試 3: API Models 端點 ==================== # ==================== 測試 3: API Models 端點 ====================
print_test "測試 API Models 端點" print_test "測試 API Models 端點"
MODELS_RESPONSE=$(curl -s http://localhost:18000/v1/models) MODELS_RESPONSE=$(curl -s http://localhost:18000/v1/models)
if echo "$MODELS_RESPONSE" | grep -q "human-admin"; then if echo "$MODELS_RESPONSE" | grep -q "tobiichiGPT"; then
print_success "Models 端點返回 human-admin 模型" print_success "Models 端點返回 tobiichiGPT 模型"
else else
print_error "Models 端點測試失敗" print_error "Models 端點測試失敗"
fi fi
@@ -104,7 +104,7 @@ echo " → 發送測試訊息到 API..."
curl -X POST http://localhost:18000/v1/chat/completions \ curl -X POST http://localhost:18000/v1/chat/completions \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"model": "human-admin", "model": "tobiichiGPT",
"messages": [ "messages": [
{"role": "user", "content": "自動化測試訊息"} {"role": "user", "content": "自動化測試訊息"}
] ]