Release v4928.1.4.2 stable
This commit is contained in:
318
scripts/process_outbox.py
Normal file
318
scripts/process_outbox.py
Normal file
@@ -0,0 +1,318 @@
|
||||
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.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:
|
||||
|
||||
# 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()))
|
||||
Reference in New Issue
Block a user