Files
clientflow_backend/app/chatwoot_client.py

228 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()}