228 lines
7.8 KiB
Python
228 lines
7.8 KiB
Python
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()}
|