Files
clientflow_backend/scripts/process_outbox.py

325 lines
9.9 KiB
Python

import asyncio
import os
import sys
from pathlib import Path
from typing import Any, Dict, Literal
import httpx
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT))
os.chdir(PROJECT_ROOT)
from app.config import settings
from app.db import init_db
from app.integration_outbox_service import (
claim_pending_outbox,
recover_stale_processing_outbox,
mark_outbox_blocked,
mark_outbox_dry_run,
mark_outbox_failed,
mark_outbox_sent,
)
Outcome = Literal["processed", "skipped"]
def env_bool(name: str, default: bool = False) -> bool:
fallback = "true" if default else "false"
value = os.getenv(name, fallback).strip().lower()
return value in {"true", "1", "yes", "on"}
def is_dry_run() -> bool:
return env_bool("OUTBOX_DRY_RUN", True)
def integration_enabled(target_system: str) -> bool:
key = f"{str(target_system or '').upper()}_OUTBOX_ENABLED"
return env_bool(key, False)
def build_chatwoot_note(payload: Dict[str, Any]) -> str:
event_type = payload.get("event_type", "")
action = payload.get("action", "")
note = payload.get("note", "")
conversation_id = payload.get("conversation_id", "")
return f"""🤖 ClientFlow
Evento:
{event_type}
Ação:
{action}
Nota:
{note}
Conversa:
{conversation_id}
"""
async def process_chatwoot_add_private_note(item: Dict[str, Any]) -> Outcome:
payload = item.get("payload") or {}
conversation_id = payload.get("conversation_id")
if not conversation_id:
raise RuntimeError("conversation_id em falta no payload")
if is_dry_run():
message = f"DRY-RUN chatwoot.add_private_note conversation_id={conversation_id}"
print(message)
mark_outbox_dry_run(item["id"], message)
return "processed"
if not settings.chatwoot_write_enabled:
raise RuntimeError("CHATWOOT_WRITE_ENABLED=false")
if not settings.chatwoot_base_url or not settings.chatwoot_account_id or not settings.chatwoot_api_token:
raise RuntimeError("Configuração Chatwoot incompleta")
url = (
settings.chatwoot_base_url.rstrip("/")
+ f"/api/v1/accounts/{settings.chatwoot_account_id}"
+ f"/conversations/{conversation_id}/messages"
)
body = {
"content": build_chatwoot_note(payload),
"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=body)
if response.status_code >= 400:
raise RuntimeError(f"Chatwoot error {response.status_code}: {response.text}")
mark_outbox_sent(item["id"])
return "processed"
async def process_mautic_add_tag(item: Dict[str, Any]) -> Outcome:
payload = item.get("payload") or {}
if is_dry_run():
message = (
"DRY-RUN mautic.add_tag "
f"conversation_id={payload.get('conversation_id')} "
f"tag={payload.get('tag')}"
)
print(message)
mark_outbox_dry_run(item["id"], message)
return "processed"
from app.mautic_client import add_tag_from_outbox_payload
add_tag_from_outbox_payload(payload)
mark_outbox_sent(item["id"])
return "processed"
async def process_mautic_remove_tag(item: Dict[str, Any]) -> Outcome:
payload = item.get("payload") or {}
if is_dry_run():
message = (
"DRY-RUN mautic.remove_tag "
f"conversation_id={payload.get('conversation_id')} "
f"tag={payload.get('tag')}"
)
print(message)
mark_outbox_dry_run(item["id"], message)
return "processed"
from app.mautic_client import remove_tag_from_outbox_payload
remove_tag_from_outbox_payload(payload)
mark_outbox_sent(item["id"])
return "processed"
async def process_packlink_create_shipment(item: Dict[str, Any]) -> Outcome:
payload = item.get("payload") or {}
opportunity_id = payload.get("opportunity_id")
if is_dry_run():
message = f"DRY-RUN packlink.create_shipment opportunity_id={opportunity_id}"
print(message)
mark_outbox_dry_run(item["id"], message)
return "processed"
if not settings.packlink_enabled:
raise RuntimeError("PACKLINK_ENABLED=false")
from app.packlink_service import create_shipment_from_outbox_payload
result = await create_shipment_from_outbox_payload(payload)
print(f"Packlink shipment created reference={result.get('reference')}")
mark_outbox_sent(item["id"])
return "processed"
async def process_jasmin_create_quotation(item: Dict[str, Any]) -> Outcome:
payload = item.get("payload") or {}
opportunity_id = payload.get("opportunity_id")
if is_dry_run():
message = f"DRY-RUN jasmin.create_quotation opportunity_id={opportunity_id}"
print(message)
mark_outbox_dry_run(item["id"], message)
return "processed"
if not settings.jasmin_enabled:
raise RuntimeError("JASMIN_ENABLED=false")
from app.jasmin_service import process_create_quotation_outbox
result = await process_create_quotation_outbox(payload)
print(f"Jasmin quotation created id={result.get('quotation_id')}")
mark_outbox_sent(item["id"])
return "processed"
async def process_jasmin_convert_invoice(item: Dict[str, Any]) -> Outcome:
payload = item.get("payload") or {}
opportunity_id = payload.get("opportunity_id")
if is_dry_run():
message = f"DRY-RUN jasmin.convert_quotation_to_invoice opportunity_id={opportunity_id}"
print(message)
mark_outbox_dry_run(item["id"], message)
return "processed"
if not settings.jasmin_enabled:
raise RuntimeError("JASMIN_ENABLED=false")
from app.jasmin_service import process_convert_invoice_outbox
result = await process_convert_invoice_outbox(payload)
print(f"Jasmin invoice created id={result.get('invoice_id')}")
mark_outbox_sent(item["id"])
return "processed"
async def process_item(item: Dict[str, Any]) -> Outcome:
target_system = item.get("target_system")
action_type = item.get("action_type")
print(f"Processing {item['id']} {target_system}.{action_type}")
if not integration_enabled(target_system):
message = f"Integração desativada: {target_system}.{action_type}. Ative {str(target_system or '').upper()}_OUTBOX_ENABLED=true para processar."
print(f"BLOCKED {message}")
mark_outbox_blocked(item["id"], message)
return "skipped"
if target_system == "chatwoot" and action_type == "add_private_note":
return await process_chatwoot_add_private_note(item)
if target_system == ("t" + "wenty"):
print(f"SKIP legacy external CRM outbox item {item.get('id')}: integração removida")
return "skipped"
if target_system == "mautic" and action_type == "add_tag":
return await process_mautic_add_tag(item)
if target_system == "mautic" and action_type == "remove_tag":
return await process_mautic_remove_tag(item)
if target_system == "packlink" and action_type == "create_shipment":
return await process_packlink_create_shipment(item)
if target_system == "jasmin" and action_type == "create_quotation":
return await process_jasmin_create_quotation(item)
if target_system == "jasmin" and action_type == "convert_quotation_to_invoice":
return await process_jasmin_convert_invoice(item)
if is_dry_run():
message = f"DRY-RUN unsupported-now {target_system}.{action_type}"
print(message)
mark_outbox_dry_run(item["id"], message)
return "processed"
raise RuntimeError(f"Handler não implementado: {target_system}.{action_type}")
async def main() -> int:
# Ensure outbox schema compatibility before claiming rows. This makes the
# systemd worker safe on upgraded installs where the API has not yet run
# schema self-healing for newly selected columns such as sent_at.
init_db()
# Nota: as notas privadas do Chatwoot podem estar desligadas sem bloquear
# outras integrações como Packlink, Mautic ou Jasmin. A decisão de processar
# cada target_system fica em integration_enabled() e no handler específico.
if os.getenv("CLIENTFLOW_DISABLE_CHATWOOT_PRIVATE_NOTES", "true").lower() in {"1", "true", "yes", "sim"}:
print("ClientFlow Chatwoot private notes disabled by env; non-Chatwoot outbox will still run.")
limit = int(os.getenv("OUTBOX_LIMIT", "50"))
target_system = os.getenv("OUTBOX_TARGET_SYSTEM", "").strip() or None
worker_id = os.getenv("OUTBOX_WORKER_ID", f"process_outbox:{os.getpid()}")
if env_bool("OUTBOX_RECOVER_STALE_BEFORE_PROCESS", True):
recovered = recover_stale_processing_outbox(
mode=os.getenv("OUTBOX_STALE_RECOVERY_MODE", "manual_only"),
actor=worker_id,
)
if recovered:
print(f"Recovered stale processing outbox items: {len(recovered)}")
items = claim_pending_outbox(
limit=limit,
target_system=target_system,
lock_owner=worker_id,
)
print(f"Claimed outbox items: {len(items)}")
print(f"OUTBOX_WORKER_ID={worker_id}")
print(f"OUTBOX_DRY_RUN={is_dry_run()}")
print(f"OUTBOX_TARGET_SYSTEM={target_system or 'all'}")
processed = 0
skipped = 0
failed = 0
for item in items:
try:
outcome = await process_item(item)
if outcome == "processed":
processed += 1
else:
skipped += 1
except Exception as exc:
failed += 1
print(f"FAILED {item.get('id')}: {exc}")
mark_outbox_failed(item["id"], str(exc))
print(f"Processed: {processed}")
print(f"Skipped: {skipped}")
print(f"Failed: {failed}")
return 0 if failed == 0 else 1
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))