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()))