Release v4928.1.4.2 stable

This commit is contained in:
2026-06-09 22:55:58 +01:00
commit 6445044ac6
280 changed files with 41775 additions and 0 deletions

459
app/webhooks_chatwoot.py Normal file
View File

@@ -0,0 +1,459 @@
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_state_for_conversation,
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 = 4,
) -> 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=4,
)
if recent_lines:
lines.append("Histórico recente, apenas para contexto:")
lines.extend(recent_lines)
else:
lines.append("Histórico recente: não disponível no webhook.")
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")
@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)
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"),
}
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,
}
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"
mark_raw_event_processed(
raw_event_id=raw_event_id,
ignored=not completed,
error=None if completed else f"outgoing: {auto_complete_result.get('status')}",
)
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"],
"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,
}