147 lines
5.9 KiB
Python
Executable File
147 lines
5.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Audita lacunas Chatwoot -> ClientFlow usando payload.message_type.
|
|
|
|
A versão anterior gerava falsos positivos porque assumia sender.type=contact;
|
|
nos webhooks de email do Chatwoot, inbound chega como message_type=incoming e
|
|
sender.type pode estar vazio.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from sqlalchemy import text
|
|
|
|
from app.db import engine
|
|
|
|
OUT_CSV = Path("/tmp/clientflow_chatwoot_ingestion_gap.csv")
|
|
OUT_MD = Path("/tmp/clientflow_chatwoot_ingestion_gap.md")
|
|
|
|
|
|
def clean(value: Any, limit: int = 240) -> str:
|
|
txt = " ".join(str(value or "").replace("\r", " ").replace("\n", " ").split())
|
|
return txt if len(txt) <= limit else txt[: limit - 1].rstrip() + "…"
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--hours", type=int, default=168)
|
|
parser.add_argument("--include-ok", action="store_true")
|
|
args = parser.parse_args()
|
|
|
|
with engine.begin() as conn:
|
|
rows = conn.execute(text("""
|
|
SELECT
|
|
re.id::text AS raw_event_id,
|
|
re.source_event_id,
|
|
re.created_at,
|
|
re.processed,
|
|
re.ignored,
|
|
re.processing_error,
|
|
re.payload #>> '{message_type}' AS message_type,
|
|
re.payload #>> '{conversation,id}' AS conversation_id,
|
|
re.payload #>> '{sender,email}' AS sender_email,
|
|
re.payload #>> '{conversation,meta,sender,email}' AS meta_sender_email,
|
|
left(coalesce(re.payload #>> '{content}', ''), 600) AS content,
|
|
m.id IS NOT NULL AS has_message,
|
|
c.id IS NOT NULL AS has_communication,
|
|
o.id IS NOT NULL AS has_opportunity
|
|
FROM raw_events re
|
|
LEFT JOIN messages m
|
|
ON m.source_system = 'chatwoot'
|
|
AND m.source_event_id = re.source_event_id
|
|
LEFT JOIN communications c
|
|
ON c.source_system = 'chatwoot'
|
|
AND c.source_message_id = re.source_event_id
|
|
LEFT JOIN opportunities o
|
|
ON o.conversation_id = re.payload #>> '{conversation,id}'
|
|
WHERE re.source_system = 'chatwoot'
|
|
AND re.event_type = 'message_created'
|
|
AND re.created_at >= now() - (:hours * interval '1 hour')
|
|
ORDER BY re.created_at DESC
|
|
"""), {"hours": int(args.hours)}).mappings().all()
|
|
|
|
report: list[dict[str, Any]] = []
|
|
counts: dict[str, int] = {}
|
|
|
|
for row in rows:
|
|
message_type = str(row.get("message_type") or "").lower()
|
|
if message_type == "outgoing":
|
|
status = "OUTGOING_IGNORED_FOR_INBOUND_AUDIT"
|
|
if not args.include_ok:
|
|
continue
|
|
elif message_type != "incoming":
|
|
status = "NON_INCOMING_OR_UNKNOWN"
|
|
if not args.include_ok:
|
|
continue
|
|
elif not row.get("processed") and not row.get("ignored") and not row.get("processing_error"):
|
|
status = "PENDING_INCOMING_RAW_EVENT"
|
|
elif row.get("processing_error"):
|
|
status = "INCOMING_PROCESSING_ERROR"
|
|
elif row.get("ignored"):
|
|
status = "INCOMING_IGNORED"
|
|
elif not row.get("has_message") and not row.get("has_communication"):
|
|
status = "PROCESSED_BUT_NOT_VISIBLE"
|
|
elif not row.get("has_opportunity"):
|
|
status = "VISIBLE_BUT_UNLINKED_CONVERSATION"
|
|
else:
|
|
status = "OK"
|
|
if not args.include_ok:
|
|
continue
|
|
|
|
counts[status] = counts.get(status, 0) + 1
|
|
report.append({
|
|
"status": status,
|
|
"created_at": row.get("created_at"),
|
|
"raw_event_id": row.get("raw_event_id"),
|
|
"source_event_id": row.get("source_event_id"),
|
|
"conversation_id": row.get("conversation_id"),
|
|
"email": row.get("sender_email") or row.get("meta_sender_email") or "",
|
|
"processed": row.get("processed"),
|
|
"ignored": row.get("ignored"),
|
|
"processing_error": row.get("processing_error") or "",
|
|
"has_message": row.get("has_message"),
|
|
"has_communication": row.get("has_communication"),
|
|
"has_opportunity": row.get("has_opportunity"),
|
|
"content": clean(row.get("content")),
|
|
})
|
|
|
|
fieldnames = list(report[0].keys()) if report else [
|
|
"status", "created_at", "raw_event_id", "source_event_id", "conversation_id", "email",
|
|
"processed", "ignored", "processing_error", "has_message", "has_communication", "has_opportunity", "content",
|
|
]
|
|
with OUT_CSV.open("w", encoding="utf-8", newline="") as f:
|
|
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
|
writer.writeheader()
|
|
writer.writerows(report)
|
|
|
|
with OUT_MD.open("w", encoding="utf-8") as f:
|
|
f.write("# Auditoria Chatwoot → ClientFlow\n\n")
|
|
f.write(f"Janela: últimas {args.hours} horas\n\n")
|
|
f.write("## Resumo\n\n")
|
|
for status, total in sorted(counts.items(), key=lambda item: (-item[1], item[0])):
|
|
f.write(f"- {status}: {total}\n")
|
|
f.write("\n## Casos\n\n")
|
|
for row in report[:200]:
|
|
f.write(f"### {row['status']} · conv #{row['conversation_id']} · msg {row['source_event_id']}\n\n")
|
|
f.write(f"- Email: {row['email'] or '-'}\n")
|
|
f.write(f"- Data: {row['created_at']}\n")
|
|
f.write(f"- Erro: {row['processing_error'] or '-'}\n")
|
|
f.write(f"- Excerto: {row['content'] or '-'}\n\n")
|
|
|
|
print("Auditoria Chatwoot inbound concluída.")
|
|
print(f"CSV: {OUT_CSV}")
|
|
print(f"Markdown: {OUT_MD}")
|
|
print("Resumo:")
|
|
if not counts:
|
|
print("OK: sem gaps inbound encontrados")
|
|
for status, total in sorted(counts.items(), key=lambda item: (-item[1], item[0])):
|
|
print(f"{status}: {total}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|