Import ClientFlow production v4928.1.5.132.4

This commit is contained in:
plx
2026-07-29 13:11:01 +00:00
parent 6445044ac6
commit 261d342057
405 changed files with 48373 additions and 1401 deletions

View File

@@ -92,3 +92,136 @@ async def add_private_note(
"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()}