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