Files
assistant/agent/webhooks/chatwoot_webhook.py

356 lines
11 KiB
Python
Raw Normal View History

"""
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
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
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) -> None:
"""Process incoming message from Chatwoot
Args:
payload: Webhook payload
"""
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"
logger.info(
"Processing incoming message",
conversation_id=conversation_id,
user_id=user_id,
message_length=len(content)
)
# Load conversation context from cache
cache = get_cache_manager()
await cache.connect()
context = await cache.get_context(conversation_id)
history = await cache.get_messages(conversation_id)
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
)
# 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()
# 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)
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}