500 lines
17 KiB
Python
500 lines
17 KiB
Python
import time
|
|
import json
|
|
import hmac
|
|
import hashlib
|
|
import re
|
|
from typing import Any, Dict, Optional, List
|
|
from hmac import compare_digest
|
|
|
|
from fastapi import APIRouter, Request, HTTPException
|
|
|
|
from app.analyzer import analyze
|
|
from app.config import settings
|
|
from app.chatwoot_client import add_private_note
|
|
from app.message_cleaner import extract_chatwoot_content
|
|
from app.persistence import (
|
|
get_recent_chatwoot_public_context,
|
|
get_state_for_conversation,
|
|
mark_raw_event_error,
|
|
mark_raw_event_processed,
|
|
save_raw_event,
|
|
)
|
|
from app.schemas import AnalyzeRequest
|
|
from app.task_service import auto_complete_task_from_outgoing_message
|
|
|
|
|
|
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
|
|
|
|
|
|
def as_str(value: Any) -> Optional[str]:
|
|
if value is None:
|
|
return None
|
|
return str(value)
|
|
|
|
|
|
def get_nested(data: Dict[str, Any], *keys: str) -> Any:
|
|
current: Any = data
|
|
|
|
for key in keys:
|
|
if not isinstance(current, dict):
|
|
return None
|
|
current = current.get(key)
|
|
|
|
return current
|
|
|
|
|
|
def compact_text(value: Any, limit: int = 450) -> str:
|
|
"""Normaliza texto para contexto LLM compacto."""
|
|
text = str(value or "")
|
|
text = re.sub(r"<[^>]+>", " ", text)
|
|
text = re.sub(r"\s+", " ", text).strip()
|
|
if len(text) > limit:
|
|
return text[: limit - 1].rstrip() + "…"
|
|
return text
|
|
|
|
|
|
def extract_email_subject(payload: Dict[str, Any], message: Dict[str, Any], conversation: Dict[str, Any]) -> str:
|
|
"""Tenta extrair o assunto do email a partir das variantes comuns do Chatwoot."""
|
|
candidates = [
|
|
get_nested(message, "content_attributes", "email", "subject"),
|
|
get_nested(payload, "content_attributes", "email", "subject"),
|
|
get_nested(message, "content_attributes", "subject"),
|
|
get_nested(payload, "content_attributes", "subject"),
|
|
get_nested(conversation, "additional_attributes", "mail_subject"),
|
|
get_nested(payload, "conversation", "additional_attributes", "mail_subject"),
|
|
conversation.get("subject") if isinstance(conversation, dict) else None,
|
|
payload.get("subject"),
|
|
]
|
|
|
|
# Em alguns webhooks o assunto vem dentro de uma mensagem do array conversation.messages.
|
|
messages = conversation.get("messages") if isinstance(conversation, dict) else None
|
|
if isinstance(messages, list):
|
|
for item in messages:
|
|
if isinstance(item, dict):
|
|
candidates.append(get_nested(item, "content_attributes", "email", "subject"))
|
|
candidates.append(get_nested(item, "content_attributes", "subject"))
|
|
|
|
for candidate in candidates:
|
|
cleaned = compact_text(candidate, limit=180)
|
|
if cleaned:
|
|
return cleaned
|
|
return ""
|
|
|
|
|
|
def message_role(message: Dict[str, Any]) -> str:
|
|
raw_type = str(
|
|
message.get("message_type")
|
|
or message.get("direction")
|
|
or message.get("sender_type")
|
|
or ""
|
|
).lower()
|
|
if raw_type in {"outgoing", "outbound", "1"}:
|
|
return "BLIF"
|
|
return "Cliente"
|
|
|
|
|
|
def message_timestamp(message: Dict[str, Any]) -> Any:
|
|
return (
|
|
message.get("created_at")
|
|
or message.get("updated_at")
|
|
or message.get("timestamp")
|
|
or message.get("id")
|
|
or 0
|
|
)
|
|
|
|
|
|
def extract_message_content_for_context(message: Dict[str, Any]) -> str:
|
|
try:
|
|
content = extract_chatwoot_content(message, message)
|
|
except Exception:
|
|
content = message.get("content") or message.get("text") or message.get("body") or ""
|
|
return compact_text(content, limit=360)
|
|
|
|
|
|
def extract_recent_conversation_context(
|
|
payload: Dict[str, Any],
|
|
current_message_id: Optional[str],
|
|
max_messages: int = 2,
|
|
) -> List[str]:
|
|
"""Extrai as últimas mensagens relevantes antes da mensagem atual.
|
|
|
|
Não envia a thread inteira ao LLM: usa no máximo `max_messages` linhas compactas.
|
|
"""
|
|
conversation = (
|
|
payload.get("conversation")
|
|
if isinstance(payload.get("conversation"), dict)
|
|
else get_nested(payload, "message", "conversation")
|
|
if isinstance(get_nested(payload, "message", "conversation"), dict)
|
|
else {}
|
|
)
|
|
messages = conversation.get("messages") if isinstance(conversation, dict) else None
|
|
if not isinstance(messages, list):
|
|
return []
|
|
|
|
normalized: List[Dict[str, Any]] = []
|
|
for item in messages:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
if item.get("private") or item.get("content_type") == "input_select":
|
|
continue
|
|
item_id = as_str(item.get("id") or item.get("message_id"))
|
|
if current_message_id and item_id == current_message_id:
|
|
continue
|
|
content = extract_message_content_for_context(item)
|
|
if not content:
|
|
continue
|
|
normalized.append({
|
|
"sort": message_timestamp(item),
|
|
"role": message_role(item),
|
|
"content": content,
|
|
})
|
|
|
|
try:
|
|
normalized.sort(key=lambda row: row["sort"])
|
|
except Exception:
|
|
pass
|
|
|
|
recent = normalized[-max_messages:]
|
|
return [f"- {row['role']}: {row['content']}" for row in recent]
|
|
|
|
|
|
def build_triage_previous_context(payload: Dict[str, Any], extracted: Dict[str, Any]) -> str:
|
|
message = payload.get("message") if isinstance(payload.get("message"), dict) else payload
|
|
conversation = (
|
|
payload.get("conversation")
|
|
if isinstance(payload.get("conversation"), dict)
|
|
else message.get("conversation") if isinstance(message.get("conversation"), dict)
|
|
else {}
|
|
)
|
|
|
|
lines: List[str] = ["Origem: webhook Chatwoot."]
|
|
|
|
subject = extract_email_subject(payload, message, conversation)
|
|
if subject:
|
|
lines.append(f"Assunto: {subject}")
|
|
|
|
recent_lines = extract_recent_conversation_context(
|
|
payload=payload,
|
|
current_message_id=extracted.get("source_event_id"),
|
|
max_messages=2,
|
|
)
|
|
if not recent_lines:
|
|
recent_lines = get_recent_chatwoot_public_context(
|
|
extracted.get("conversation_id"),
|
|
current_source_event_id=extracted.get("source_event_id"),
|
|
max_messages=2,
|
|
)
|
|
|
|
if recent_lines:
|
|
lines.append("Últimos 2 emails públicos antes da mensagem atual, apenas para contexto:")
|
|
lines.extend(recent_lines)
|
|
else:
|
|
lines.append("Últimos 2 emails públicos: não disponível no webhook nem em raw_events.")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def extract_chatwoot_event(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
event_type = (
|
|
payload.get("event")
|
|
or payload.get("webhook_event")
|
|
or payload.get("event_type")
|
|
or payload.get("name")
|
|
or "unknown"
|
|
)
|
|
|
|
message = payload.get("message") if isinstance(payload.get("message"), dict) else payload
|
|
|
|
conversation = (
|
|
payload.get("conversation")
|
|
if isinstance(payload.get("conversation"), dict)
|
|
else message.get("conversation") if isinstance(message.get("conversation"), dict)
|
|
else {}
|
|
)
|
|
|
|
sender = (
|
|
payload.get("sender")
|
|
if isinstance(payload.get("sender"), dict)
|
|
else message.get("sender") if isinstance(message.get("sender"), dict)
|
|
else {}
|
|
)
|
|
|
|
contact = (
|
|
payload.get("contact")
|
|
if isinstance(payload.get("contact"), dict)
|
|
else conversation.get("contact") if isinstance(conversation.get("contact"), dict)
|
|
else get_nested(conversation, "contact_inbox", "contact")
|
|
or sender
|
|
or {}
|
|
)
|
|
|
|
content = extract_chatwoot_content(payload, message)
|
|
|
|
conversation_id = (
|
|
payload.get("conversation_id")
|
|
or message.get("conversation_id")
|
|
or conversation.get("id")
|
|
or get_nested(payload, "conversation", "id")
|
|
)
|
|
|
|
contact_id = (
|
|
payload.get("contact_id")
|
|
or contact.get("id")
|
|
or sender.get("id")
|
|
or get_nested(conversation, "contact_inbox", "contact", "id")
|
|
)
|
|
|
|
source_event_id = (
|
|
payload.get("id")
|
|
or message.get("id")
|
|
or payload.get("message_id")
|
|
)
|
|
|
|
message_type = (
|
|
message.get("message_type")
|
|
or payload.get("message_type")
|
|
or message.get("direction")
|
|
or payload.get("direction")
|
|
or ""
|
|
)
|
|
|
|
message_type_text = str(message_type).lower()
|
|
is_outgoing = message_type_text in {"outgoing", "outbound", "1"}
|
|
|
|
is_private = bool(
|
|
message.get("private")
|
|
or payload.get("private")
|
|
or message.get("content_type") == "input_select"
|
|
)
|
|
|
|
sender_name = (
|
|
sender.get("name")
|
|
or sender.get("available_name")
|
|
or sender.get("display_name")
|
|
or sender.get("email")
|
|
)
|
|
|
|
sender_type = (
|
|
sender.get("type")
|
|
or sender.get("role")
|
|
or sender.get("account_role")
|
|
or ""
|
|
)
|
|
|
|
return {
|
|
"event_type": str(event_type),
|
|
"source_event_id": as_str(source_event_id),
|
|
"conversation_id": as_str(conversation_id),
|
|
"contact_id": as_str(contact_id),
|
|
"content": str(content or "").strip(),
|
|
"message_type": message_type_text,
|
|
"is_outgoing": is_outgoing,
|
|
"is_private": is_private,
|
|
"sender_name": as_str(sender_name),
|
|
"sender_type": as_str(sender_type),
|
|
}
|
|
|
|
|
|
def validate_chatwoot_webhook_secret(request: Request) -> None:
|
|
expected = (settings.clientflow_webhook_secret or "").strip()
|
|
|
|
# Em desenvolvimento mantém compatibilidade; em produção exige segredo.
|
|
if not expected:
|
|
if str(settings.env or "").strip().lower() in {"prod", "production", "staging"}:
|
|
raise HTTPException(status_code=500, detail="CLIENTFLOW_WEBHOOK_SECRET not configured")
|
|
return
|
|
|
|
received = (
|
|
request.query_params.get("token")
|
|
or request.headers.get("X-ClientFlow-Webhook-Secret")
|
|
or request.headers.get("X-Webhook-Secret")
|
|
or ""
|
|
).strip()
|
|
|
|
if not received or not compare_digest(received, expected):
|
|
raise HTTPException(status_code=401, detail="invalid webhook secret")
|
|
|
|
|
|
|
|
def validate_chatwoot_signature(request: Request, raw_body: bytes) -> None:
|
|
secret = (settings.clientflow_webhook_secret or "").strip()
|
|
|
|
# Em desenvolvimento mantém compatibilidade; em produção exige assinatura.
|
|
if not secret:
|
|
if str(settings.env or "").strip().lower() in {"prod", "production", "staging"}:
|
|
raise HTTPException(status_code=500, detail="CLIENTFLOW_WEBHOOK_SECRET not configured")
|
|
return
|
|
|
|
received_signature = request.headers.get("X-Chatwoot-Signature", "")
|
|
timestamp = request.headers.get("X-Chatwoot-Timestamp", "")
|
|
|
|
if not received_signature or not timestamp:
|
|
raise HTTPException(status_code=401, detail="missing chatwoot signature")
|
|
|
|
try:
|
|
ts = int(timestamp)
|
|
except ValueError:
|
|
raise HTTPException(status_code=401, detail="invalid chatwoot timestamp")
|
|
|
|
# Anti-replay: rejeita eventos com mais de 5 minutos.
|
|
if abs(int(time.time()) - ts) > 300:
|
|
raise HTTPException(status_code=401, detail="expired chatwoot signature")
|
|
|
|
signed_payload = timestamp.encode("utf-8") + b"." + raw_body
|
|
expected_signature = "sha256=" + hmac.new(
|
|
secret.encode("utf-8"),
|
|
signed_payload,
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
|
|
if not hmac.compare_digest(expected_signature, received_signature):
|
|
raise HTTPException(status_code=401, detail="invalid chatwoot signature")
|
|
|
|
async def process_saved_chatwoot_raw_event(raw_event_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Processa um raw_event Chatwoot já persistido.
|
|
|
|
v4928.1.5.25: o Chatwoot email webhook coloca o sentido da mensagem
|
|
em ``payload.message_type``. Nos emails recebidos o ``sender.type`` pode
|
|
vir vazio, por isso a ingestão não pode depender de ``sender.type`` do contacto.
|
|
Esta função é partilhada pelo webhook online e pelo script de recovery para
|
|
evitar que raw_events fiquem em ``processed=false`` sem erro visível.
|
|
"""
|
|
extracted = extract_chatwoot_event(payload)
|
|
|
|
if not extracted["content"]:
|
|
mark_raw_event_processed(
|
|
raw_event_id=raw_event_id,
|
|
ignored=True,
|
|
error="ignored: empty content",
|
|
)
|
|
return {
|
|
"status": "ignored",
|
|
"reason": "empty_content",
|
|
"raw_event_id": raw_event_id,
|
|
"message_type": extracted.get("message_type"),
|
|
}
|
|
|
|
if extracted["is_outgoing"]:
|
|
auto_complete_result = auto_complete_task_from_outgoing_message(
|
|
conversation_id=extracted["conversation_id"],
|
|
contact_id=extracted["contact_id"],
|
|
outgoing_message_id=extracted["source_event_id"],
|
|
outgoing_content=extracted["content"],
|
|
sender_name=extracted.get("sender_name"),
|
|
sender_type=extracted.get("sender_type"),
|
|
is_private=bool(extracted.get("is_private")),
|
|
)
|
|
|
|
completed = auto_complete_result.get("status") == "auto_completed"
|
|
|
|
# Mensagens enviadas manualmente no Chatwoot sem task pendente são
|
|
# normais. Devem ser ignoradas como informação operacional, não como
|
|
# processing_error, para não poluir System health.
|
|
mark_raw_event_processed(
|
|
raw_event_id=raw_event_id,
|
|
ignored=not completed,
|
|
error=None,
|
|
)
|
|
|
|
return {
|
|
"status": "outgoing_processed" if completed else "ignored",
|
|
"reason": auto_complete_result.get("status"),
|
|
"raw_event_id": raw_event_id,
|
|
"conversation_id": extracted["conversation_id"],
|
|
"contact_id": extracted["contact_id"],
|
|
"auto_complete": auto_complete_result,
|
|
}
|
|
|
|
current_state = get_state_for_conversation(extracted["conversation_id"])
|
|
|
|
analyze_request = AnalyzeRequest(
|
|
last_customer_message=extracted["content"],
|
|
previous_context=build_triage_previous_context(payload, extracted),
|
|
current_state=current_state,
|
|
source="chatwoot",
|
|
conversation_id=extracted["conversation_id"] or raw_event_id,
|
|
contact_id=extracted["contact_id"],
|
|
)
|
|
|
|
response = await analyze(analyze_request, raw_event_id=raw_event_id)
|
|
|
|
chatwoot_note_result = {
|
|
"status": "skipped",
|
|
"reason": "no conversation_id",
|
|
}
|
|
|
|
if extracted["conversation_id"]:
|
|
chatwoot_note_result = await add_private_note(
|
|
conversation_id=extracted["conversation_id"],
|
|
action_result=response.action_result,
|
|
)
|
|
|
|
mark_raw_event_processed(
|
|
raw_event_id=raw_event_id,
|
|
action_run_id=response.action_run_id,
|
|
message_id=response.message_id,
|
|
ignored=False,
|
|
error=None,
|
|
)
|
|
|
|
return {
|
|
"status": "processed",
|
|
"raw_event_id": raw_event_id,
|
|
"action_run_id": response.action_run_id,
|
|
"message_id": response.message_id,
|
|
"task_id": response.task_id,
|
|
"conversation_id": extracted["conversation_id"],
|
|
"contact_id": extracted["contact_id"],
|
|
"message_type": extracted.get("message_type"),
|
|
"action_decision": response.action_decision.model_dump(),
|
|
"action_result": response.action_result.model_dump(),
|
|
"needs_review": response.needs_review,
|
|
"usage": response.usage.model_dump(),
|
|
"chatwoot_note": chatwoot_note_result,
|
|
}
|
|
|
|
|
|
@router.post("/chatwoot")
|
|
async def chatwoot_webhook(request: Request) -> Dict[str, Any]:
|
|
|
|
raw_body = await request.body()
|
|
validate_chatwoot_signature(request, raw_body)
|
|
|
|
try:
|
|
payload = json.loads(raw_body.decode('utf-8') or '{}')
|
|
except json.JSONDecodeError:
|
|
raise HTTPException(status_code=400, detail='invalid json payload')
|
|
extracted = extract_chatwoot_event(payload)
|
|
if not isinstance(payload, dict) or not payload:
|
|
raise HTTPException(status_code=422, detail="empty chatwoot payload")
|
|
if not extracted.get("conversation_id") and not extracted.get("source_event_id"):
|
|
raise HTTPException(status_code=422, detail="chatwoot payload without conversation/message id")
|
|
if not extracted.get("content"):
|
|
raise HTTPException(status_code=422, detail="chatwoot payload without message content")
|
|
|
|
raw_event_info = save_raw_event(
|
|
source_system="chatwoot",
|
|
event_type=extracted["event_type"],
|
|
source_event_id=extracted["source_event_id"],
|
|
conversation_id=extracted["conversation_id"],
|
|
contact_id=extracted["contact_id"],
|
|
payload=payload,
|
|
)
|
|
raw_event_id = raw_event_info["id"]
|
|
|
|
if raw_event_info.get("processed"):
|
|
return {
|
|
"status": "duplicate_ignored",
|
|
"raw_event_id": raw_event_id,
|
|
"message_id": raw_event_info.get("message_id"),
|
|
"action_run_id": raw_event_info.get("action_run_id"),
|
|
}
|
|
|
|
try:
|
|
return await process_saved_chatwoot_raw_event(raw_event_id, payload)
|
|
except Exception as exc:
|
|
# Não deixar eventos em pending silencioso. O recovery script pode
|
|
# reprocessar com --include-errors depois de corrigida a causa.
|
|
mark_raw_event_error(raw_event_id=raw_event_id, error=f"processing_exception: {type(exc).__name__}: {exc}")
|
|
raise
|