from typing import Dict import httpx from app.config import settings from app.posting_policy import can_post_private_note from app.schemas import ActionResult import os def build_private_note(action_result: ActionResult) -> str: return f"""🤖 ClientFlow Ação: {action_result.action} Resumo: {action_result.note} """ async def add_private_note( conversation_id: str, action_result: ActionResult, ) -> Dict: if os.getenv("CLIENTFLOW_DISABLE_CHATWOOT_PRIVATE_NOTES", "true").lower() in {"1", "true", "yes", "sim"}: return { "enabled": False, "status": "disabled_by_env", "reason": "CLIENTFLOW_DISABLE_CHATWOOT_PRIVATE_NOTES=true", } if not settings.chatwoot_write_enabled: return { "enabled": False, "status": "skipped", "reason": "CHATWOOT_WRITE_ENABLED=false", "action_result": action_result.model_dump(), } if not can_post_private_note(action_result): return { "enabled": True, "status": "skipped", "reason": "posting_policy_blocked", "action_result": action_result.model_dump(), } if not settings.chatwoot_base_url or not settings.chatwoot_account_id or not settings.chatwoot_api_token: return { "enabled": True, "status": "skipped", "reason": "missing Chatwoot config", "action_result": action_result.model_dump(), } url = ( settings.chatwoot_base_url.rstrip("/") + f"/api/v1/accounts/{settings.chatwoot_account_id}" + f"/conversations/{conversation_id}/messages" ) payload = { "content": build_private_note(action_result), "message_type": "outgoing", "private": True, "content_type": "text", "content_attributes": {}, } headers = { "Content-Type": "application/json", "api_access_token": settings.chatwoot_api_token, } async with httpx.AsyncClient(timeout=30) as client: response = await client.post(url, headers=headers, json=payload) if response.status_code >= 400: return { "enabled": True, "status": "failed", "status_code": response.status_code, "body": response.text, "action_result": action_result.model_dump(), } return { "enabled": True, "status": "sent", "status_code": response.status_code, "response": response.json(), "action_result": action_result.model_dump(), } def _chatwoot_message_url(conversation_id: str) -> str: return ( settings.chatwoot_base_url.rstrip("/") + f"/api/v1/accounts/{settings.chatwoot_account_id}" + f"/conversations/{conversation_id}/messages" ) def _chatwoot_ready() -> Dict: if not settings.chatwoot_write_enabled: return {"enabled": False, "status": "skipped", "reason": "CHATWOOT_WRITE_ENABLED=false"} if not settings.chatwoot_base_url or not settings.chatwoot_account_id or not settings.chatwoot_api_token: return {"enabled": True, "status": "skipped", "reason": "missing Chatwoot config"} return {"enabled": True, "status": "ready"} def format_chatwoot_public_message(content: str) -> str: """Prepare operator-written text for Chatwoot public messages. Chatwoot renders public message content as Markdown-like text. In that renderer, blank-line paragraph breaks are preserved but single newlines inside a paragraph can be collapsed into spaces. That breaks operator replies with price lists written as separate lines using "–" bullets. The textarea remains plain text in ClientFlow. Before sending, we add Markdown hard-break markers (two trailing spaces before line breaks) to non-empty lines, so the text keeps the same visual line breaks in Chatwoot without requiring operators to know Markdown. """ text = str(content or "").replace("\r\n", "\n").replace("\r", "\n").strip() if not text: return "" formatted_lines: list[str] = [] for line in text.split("\n"): clean_line = line.rstrip() if not clean_line: formatted_lines.append("") continue formatted_lines.append(clean_line + " ") return "\n".join(formatted_lines).strip() async def send_public_message(conversation_id: str, content: str) -> Dict: """Send a public outgoing Chatwoot message. This is used by the ClientFlow reply assistant. It is deliberately gated by CHATWOOT_WRITE_ENABLED and returns a structured result instead of raising for normal configuration skips. """ conversation_id = str(conversation_id or "").strip() content = format_chatwoot_public_message(content) if not conversation_id: return {"enabled": settings.chatwoot_write_enabled, "status": "failed", "reason": "missing conversation_id"} if not content: return {"enabled": settings.chatwoot_write_enabled, "status": "failed", "reason": "empty content"} ready = _chatwoot_ready() if ready.get("status") != "ready": return ready payload = { "content": content, "message_type": "outgoing", "private": False, "content_type": "text", "content_attributes": {}, } headers = { "Content-Type": "application/json", "api_access_token": settings.chatwoot_api_token, } async with httpx.AsyncClient(timeout=30) as client: response = await client.post(_chatwoot_message_url(conversation_id), headers=headers, json=payload) if response.status_code >= 400: return {"enabled": True, "status": "failed", "status_code": response.status_code, "body": response.text} return {"enabled": True, "status": "sent", "status_code": response.status_code, "response": response.json()} async def send_public_message_with_attachments( conversation_id: str, content: str, *, attachments: list[dict] | None = None, ) -> Dict: """Send a public message and optional PDF attachments through Chatwoot. ``attachments`` items must contain filename, content bytes and content_type. Chatwoot expects multipart/form-data with ``attachments[]`` fields when files are present. """ attachments = attachments or [] if not attachments: return await send_public_message(conversation_id, content) conversation_id = str(conversation_id or "").strip() content = format_chatwoot_public_message(content) if not conversation_id: return {"enabled": settings.chatwoot_write_enabled, "status": "failed", "reason": "missing conversation_id"} if not content: return {"enabled": settings.chatwoot_write_enabled, "status": "failed", "reason": "empty content"} ready = _chatwoot_ready() if ready.get("status") != "ready": return ready data = { "content": content, "message_type": "outgoing", "private": "false", "content_type": "text", } files = [] for attachment in attachments: filename = str(attachment.get("filename") or "documento.pdf") file_bytes = attachment.get("content") or b"" content_type = str(attachment.get("content_type") or "application/pdf") files.append(("attachments[]", (filename, file_bytes, content_type))) headers = {"api_access_token": settings.chatwoot_api_token} async with httpx.AsyncClient(timeout=60) as client: response = await client.post(_chatwoot_message_url(conversation_id), headers=headers, data=data, files=files) if response.status_code >= 400: return {"enabled": True, "status": "failed", "status_code": response.status_code, "body": response.text} return {"enabled": True, "status": "sent", "status_code": response.status_code, "response": response.json()}