Files
assistant/agent/webhooks/chatwoot_webhook.py
wangliang e093995368 feat: 增强 Agent 系统和完善项目结构
主要改进:
- Agent 增强: 订单查询、售后支持、客服路由等功能优化
- 新增语言检测和 Token 管理模块
- 改进 Chatwoot webhook 处理和用户标识
- MCP 服务器增强: 订单 MCP 和 Strapi MCP 功能扩展
- 新增商城客户端、知识库、缓存和同步模块
- 添加多语言提示词系统 (YAML)
- 完善项目结构: 整理文档、脚本和测试文件
- 新增调试和测试工具脚本

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 16:28:47 +08:00

398 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Chatwoot Webhook Handler
"""
import hmac
import hashlib
from typing import Any, Optional
from fastapi import APIRouter, Request, HTTPException, BackgroundTasks
from pydantic import BaseModel
from config import settings
from core.graph import process_message
from integrations.chatwoot import get_chatwoot_client, ConversationStatus
from utils.cache import get_cache_manager
from utils.logger import get_logger
from utils.token_manager import TokenManager
logger = get_logger(__name__)
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
# ============ Webhook Payload Models ============
class WebhookSender(BaseModel):
"""Webhook sender information"""
id: Optional[int] = None
name: Optional[str] = None
email: Optional[str] = None
type: Optional[str] = None # "contact" or "user"
class WebhookMessage(BaseModel):
"""Webhook message content"""
id: int
content: Optional[str] = None
message_type: str # "incoming" or "outgoing"
content_type: Optional[str] = None
private: bool = False
sender: Optional[WebhookSender] = None
class WebhookConversation(BaseModel):
"""Webhook conversation information"""
id: int
inbox_id: int
status: str
account_id: Optional[int] = None # Chatwoot may not always include this
contact_inbox: Optional[dict] = None
messages: Optional[list] = None
additional_attributes: Optional[dict] = None
can_reply: Optional[bool] = None
channel: Optional[str] = None
meta: Optional[dict] = None # Contains sender info including custom_attributes
class WebhookContact(BaseModel):
"""Webhook contact information"""
id: int
name: Optional[str] = None
email: Optional[str] = None
phone_number: Optional[str] = None
custom_attributes: Optional[dict] = None
class ChatwootWebhookPayload(BaseModel):
"""Chatwoot webhook payload structure"""
event: str
id: Optional[int] = None
content: Optional[str] = None
message_type: Optional[str] = None
content_type: Optional[str] = None
private: Optional[bool] = False
conversation: Optional[WebhookConversation] = None
sender: Optional[WebhookSender] = None
contact: Optional[WebhookContact] = None
account: Optional[dict] = None
# ============ Signature Verification ============
def verify_webhook_signature(payload: bytes, signature: str) -> bool:
"""Verify Chatwoot webhook signature
Args:
payload: Raw request body
signature: X-Chatwoot-Signature header value
Returns:
True if signature is valid
"""
# TODO: Re-enable signature verification after configuring Chatwoot properly
# For now, skip verification to test webhook functionality
logger.debug("Skipping webhook signature verification for testing")
return True
if not settings.chatwoot_webhook_secret:
logger.warning("Webhook secret not configured, skipping verification")
return True
if not signature:
logger.warning("No signature provided in request")
return True
expected = hmac.new(
settings.chatwoot_webhook_secret.encode(),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
# ============ Message Processing ============
async def handle_incoming_message(payload: ChatwootWebhookPayload, cookie_token: str = None) -> None:
"""Process incoming message from Chatwoot
Args:
payload: Webhook payload
cookie_token: User token from request cookies
"""
conversation = payload.conversation
if not conversation:
logger.warning("No conversation in payload")
return
conversation_id = str(conversation.id)
content = payload.content
if not content:
logger.debug("Empty message content, skipping")
return
# Get user/contact info
contact = payload.contact or payload.sender
user_id = str(contact.id) if contact else "unknown"
# Get account_id from payload (top-level account object)
# Chatwoot webhook includes account info at the top level
account_obj = payload.account
account_id = str(account_obj.get("id")) if account_obj else "1"
# 优先使用 Cookie 中的 token
user_token = cookie_token
# 如果 Cookie 中没有,尝试从多个来源提取 token
if not user_token:
# 1. 尝试从 contact/custom_attributes 获取
if contact:
contact_dict = contact.model_dump() if hasattr(contact, 'model_dump') else contact.__dict__
user_token = TokenManager.extract_token_from_contact(contact_dict)
logger.debug("Extracted token from contact", has_token=bool(user_token))
# 2. 尝试从 conversation.meta.sender.custom_attributes 获取Chatwoot SDK setUser 设置的位置)
if not user_token and conversation:
# 记录 conversation 的类型和内容用于调试
logger.debug("Conversation object type", type=str(type(conversation)))
if hasattr(conversation, 'model_dump'):
conv_dict = conversation.model_dump()
logger.debug("Conversation dict keys", keys=list(conv_dict.keys()))
logger.debug("Has meta", has_meta='meta' in conv_dict)
meta_sender = conv_dict.get('meta', {}).get('sender', {})
if meta_sender.get('custom_attributes'):
user_token = TokenManager.extract_token_from_contact({'custom_attributes': meta_sender['custom_attributes']})
logger.info("Token found in conversation.meta.sender.custom_attributes", token_prefix=user_token[:20] if user_token else None)
if user_token:
logger.info("JWT token found", user_id=user_id, source="cookie" if cookie_token else "contact")
logger.info(
"Processing incoming message",
conversation_id=conversation_id,
user_id=user_id,
has_token=bool(user_token),
message_length=len(content)
)
# Load conversation context from cache
cache = get_cache_manager()
await cache.connect()
context = await cache.get_context(conversation_id) or {}
history = await cache.get_messages(conversation_id)
# Add token to context if available
if user_token:
context["user_token"] = user_token
try:
# Process message through agent workflow
final_state = await process_message(
conversation_id=conversation_id,
user_id=user_id,
account_id=account_id,
message=content,
history=history,
context=context,
user_token=user_token
)
# Get response
response = final_state.get("response")
if not response:
response = "抱歉,我暂时无法处理您的请求。请稍后重试或联系人工客服。"
# Send response to Chatwoot
# Create client with correct account_id from webhook
from integrations.chatwoot import ChatwootClient
chatwoot = ChatwootClient(account_id=int(account_id))
await chatwoot.send_message(
conversation_id=conversation.id,
content=response
)
await chatwoot.close()
# Handle human handoff
if final_state.get("requires_human"):
await chatwoot.update_conversation_status(
conversation_id=conversation.id,
status=ConversationStatus.OPEN
)
# Add label for routing
await chatwoot.add_labels(
conversation_id=conversation.id,
labels=["needs_human", final_state.get("intent", "unknown")]
)
# Update cache
await cache.add_message(conversation_id, "user", content)
await cache.add_message(conversation_id, "assistant", response)
# Save context
new_context = final_state.get("context", {})
new_context["last_intent"] = final_state.get("intent")
await cache.set_context(conversation_id, new_context)
logger.info(
"Message processed successfully",
conversation_id=conversation_id,
intent=final_state.get("intent"),
requires_human=final_state.get("requires_human")
)
except Exception as e:
logger.error(
"Message processing failed",
conversation_id=conversation_id,
error=str(e)
)
# Send error response
chatwoot = get_chatwoot_client()
await chatwoot.send_message(
conversation_id=conversation.id,
content="抱歉,处理您的消息时遇到了问题。我们的客服团队将尽快为您服务。"
)
# Transfer to human
await chatwoot.update_conversation_status(
conversation_id=conversation.id,
status=ConversationStatus.OPEN
)
async def handle_conversation_created(payload: ChatwootWebhookPayload) -> None:
"""Handle new conversation created
Args:
payload: Webhook payload
"""
conversation = payload.conversation
if not conversation:
return
conversation_id = str(conversation.id)
logger.info(
"New conversation created",
conversation_id=conversation_id
)
# Initialize conversation context
cache = get_cache_manager()
await cache.connect()
context = {
"created": True,
"inbox_id": conversation.inbox_id
}
# Add contact info to context
contact = payload.contact
if contact:
context["contact_name"] = contact.name
context["contact_email"] = contact.email
if contact.custom_attributes:
context.update(contact.custom_attributes)
await cache.set_context(conversation_id, context)
# Send welcome message
chatwoot = get_chatwoot_client()
await chatwoot.send_message(
conversation_id=conversation.id,
content="您好!我是 AI 智能助手,很高兴为您服务。请问有什么可以帮您的?\n\n您可以询问我关于订单、商品、售后等问题,我会尽力为您解答。"
)
async def handle_conversation_status_changed(payload: ChatwootWebhookPayload) -> None:
"""Handle conversation status change
Args:
payload: Webhook payload
"""
conversation = payload.conversation
if not conversation:
return
conversation_id = str(conversation.id)
new_status = conversation.status
logger.info(
"Conversation status changed",
conversation_id=conversation_id,
status=new_status
)
# If resolved, clean up context
if new_status == "resolved":
cache = get_cache_manager()
await cache.connect()
await cache.delete_context(conversation_id)
await cache.clear_messages(conversation_id)
# ============ Webhook Endpoint ============
@router.post("/chatwoot")
async def chatwoot_webhook(
request: Request,
background_tasks: BackgroundTasks
):
"""Chatwoot webhook endpoint
Receives events from Chatwoot and processes them asynchronously.
"""
# Get raw body for signature verification
body = await request.body()
# 尝试从请求 Cookie 中获取用户 Token
user_token = request.cookies.get("token") # 从 Cookie 读取 token
if user_token:
logger.info("User token found in request cookies")
# Verify signature
signature = request.headers.get("X-Chatwoot-Signature", "")
if not verify_webhook_signature(body, signature):
logger.warning("Invalid webhook signature")
raise HTTPException(status_code=401, detail="Invalid signature")
# Parse payload
try:
payload = ChatwootWebhookPayload.model_validate_json(body)
except Exception as e:
logger.error("Failed to parse webhook payload", error=str(e))
raise HTTPException(status_code=400, detail="Invalid payload")
event = payload.event
logger.debug(f"Webhook received: {event}")
# Filter out bot's own messages
if payload.message_type == "outgoing":
return {"status": "ignored", "reason": "outgoing message"}
# Filter private messages
if payload.private:
return {"status": "ignored", "reason": "private message"}
# Route by event type
if event == "message_created":
# Only process incoming messages from contacts
if payload.message_type == "incoming":
background_tasks.add_task(handle_incoming_message, payload, user_token)
elif event == "conversation_created":
background_tasks.add_task(handle_conversation_created, payload)
elif event == "conversation_status_changed":
background_tasks.add_task(handle_conversation_status_changed, payload)
elif event == "conversation_updated":
# Handle other conversation updates if needed
pass
return {"status": "accepted", "event": event}