Import ClientFlow production v4928.1.5.132.4
This commit is contained in:
505
scripts/audit_chatwoot_remote_missing_messages.py
Executable file
505
scripts/audit_chatwoot_remote_missing_messages.py
Executable file
@@ -0,0 +1,505 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Audita mensagens que existem no Chatwoot mas não aparecem no ClientFlow.
|
||||
|
||||
Caso de uso: ClientFlow esteve em baixo e o Chatwoot não conseguiu entregar
|
||||
webhooks. Nessa situação não existe raw_event para auditar; por isso este script
|
||||
lê conversas/mensagens diretamente da API do Chatwoot e compara cada mensagem
|
||||
inbound pública com raw_events/messages/communications no PostgreSQL.
|
||||
|
||||
Uso seguro:
|
||||
PYTHONPATH=. python scripts/audit_chatwoot_remote_missing_messages.py --hours 72
|
||||
PYTHONPATH=. python scripts/audit_chatwoot_remote_missing_messages.py --hours 72 --include-ok
|
||||
PYTHONPATH=. python scripts/audit_chatwoot_remote_missing_messages.py --hours 72 --post-missing --limit 25
|
||||
|
||||
Variáveis necessárias:
|
||||
CHATWOOT_BASE_URL
|
||||
CHATWOOT_ACCOUNT_ID
|
||||
CHATWOOT_API_TOKEN
|
||||
DATABASE_URL
|
||||
|
||||
Para --post-missing também:
|
||||
CLIENTFLOW_WEBHOOK_SECRET
|
||||
CLIENTFLOW_WEBHOOK_URL opcional; por defeito http://127.0.0.1:8020/webhooks/chatwoot
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
os.chdir(PROJECT_ROOT)
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import engine
|
||||
|
||||
OUT_CSV = Path("/tmp/clientflow_chatwoot_remote_missing_messages.csv")
|
||||
OUT_MD = Path("/tmp/clientflow_chatwoot_remote_missing_messages.md")
|
||||
|
||||
|
||||
def env(name: str, default: str = "") -> str:
|
||||
return os.getenv(name, default).strip()
|
||||
|
||||
|
||||
CHATWOOT_BASE_URL = env("CHATWOOT_BASE_URL").rstrip("/")
|
||||
CHATWOOT_ACCOUNT_ID = env("CHATWOOT_ACCOUNT_ID")
|
||||
CHATWOOT_API_TOKEN = env("CHATWOOT_API_TOKEN")
|
||||
CLIENTFLOW_WEBHOOK_SECRET = env("CLIENTFLOW_WEBHOOK_SECRET")
|
||||
CLIENTFLOW_WEBHOOK_URL = env("CLIENTFLOW_WEBHOOK_URL", "http://127.0.0.1:8020/webhooks/chatwoot")
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemoteMessage:
|
||||
conversation: dict[str, Any]
|
||||
message: dict[str, Any]
|
||||
recent_context: list[dict[str, Any]]
|
||||
|
||||
|
||||
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 ts_to_datetime(value: Any) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
if isinstance(value, (int, float)):
|
||||
return datetime.fromtimestamp(float(value), tz=timezone.utc)
|
||||
s = str(value).strip().replace("Z", "+00:00")
|
||||
if not s:
|
||||
return None
|
||||
return datetime.fromisoformat(s).astimezone(timezone.utc)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def dt_to_display(value: Any) -> str:
|
||||
dt = ts_to_datetime(value)
|
||||
return dt.isoformat() if dt else str(value or "")
|
||||
|
||||
|
||||
def message_created_at(message: dict[str, Any]) -> datetime:
|
||||
return ts_to_datetime(message.get("created_at")) or datetime.fromtimestamp(0, tz=timezone.utc)
|
||||
|
||||
|
||||
def conversation_last_activity(conversation: dict[str, Any]) -> datetime | None:
|
||||
for key in ["last_activity_at", "updated_at", "created_at"]:
|
||||
dt = ts_to_datetime(conversation.get(key))
|
||||
if dt:
|
||||
return dt
|
||||
return None
|
||||
|
||||
|
||||
def request_json(method: str, url: str, *, body: dict[str, Any] | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]:
|
||||
data = None
|
||||
final_headers = dict(headers or {})
|
||||
if body is not None:
|
||||
data = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
final_headers["Content-Type"] = "application/json"
|
||||
|
||||
req = urllib.request.Request(url, data=data, headers=final_headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=45) as resp:
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
parsed: Any = json.loads(raw) if raw else {}
|
||||
except json.JSONDecodeError:
|
||||
parsed = {}
|
||||
return {"ok": 200 <= resp.status < 300, "status": resp.status, "json": parsed, "raw": raw}
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode("utf-8", errors="replace")
|
||||
return {"ok": False, "status": e.code, "json": None, "raw": raw}
|
||||
except urllib.error.URLError as e:
|
||||
return {"ok": False, "status": 0, "json": None, "raw": str(e)}
|
||||
|
||||
|
||||
def chatwoot_headers() -> dict[str, str]:
|
||||
return {"api_access_token": CHATWOOT_API_TOKEN, "Accept": "application/json"}
|
||||
|
||||
|
||||
def payload_list(data: Any) -> list[dict[str, Any]]:
|
||||
if isinstance(data, list):
|
||||
return [item for item in data if isinstance(item, dict)]
|
||||
if not isinstance(data, dict):
|
||||
return []
|
||||
|
||||
candidates = [
|
||||
data.get("payload"),
|
||||
data.get("data", {}).get("payload") if isinstance(data.get("data"), dict) else None,
|
||||
data.get("data"),
|
||||
data.get("messages"),
|
||||
]
|
||||
for item in candidates:
|
||||
if isinstance(item, list):
|
||||
return [row for row in item if isinstance(row, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def fetch_conversations(status: str, *, max_pages: int) -> list[dict[str, Any]]:
|
||||
all_items: list[dict[str, Any]] = []
|
||||
for page in range(1, max_pages + 1):
|
||||
query = urllib.parse.urlencode({"status": status, "page": page})
|
||||
url = f"{CHATWOOT_BASE_URL}/api/v1/accounts/{CHATWOOT_ACCOUNT_ID}/conversations?{query}"
|
||||
result = request_json("GET", url, headers=chatwoot_headers())
|
||||
if not result["ok"]:
|
||||
print(f"ERRO Chatwoot conversations status={status} page={page}: {result['status']} {result['raw'][:300]}")
|
||||
break
|
||||
items = payload_list(result["json"])
|
||||
if not items:
|
||||
break
|
||||
all_items.extend(items)
|
||||
if len(items) < 10:
|
||||
break
|
||||
return all_items
|
||||
|
||||
|
||||
def fetch_messages(conversation_id: str) -> list[dict[str, Any]]:
|
||||
url = f"{CHATWOOT_BASE_URL}/api/v1/accounts/{CHATWOOT_ACCOUNT_ID}/conversations/{conversation_id}/messages"
|
||||
result = request_json("GET", url, headers=chatwoot_headers())
|
||||
if not result["ok"]:
|
||||
print(f"ERRO Chatwoot messages conversation={conversation_id}: {result['status']} {result['raw'][:300]}")
|
||||
return []
|
||||
return payload_list(result["json"])
|
||||
|
||||
|
||||
def is_incoming(message: dict[str, Any]) -> bool:
|
||||
mt = message.get("message_type")
|
||||
return mt == "incoming" or mt == 0 or str(mt).lower() == "incoming"
|
||||
|
||||
|
||||
def message_content(message: dict[str, Any]) -> str:
|
||||
return str(message.get("content") or message.get("text") or message.get("body") or "").strip()
|
||||
|
||||
|
||||
def message_id(message: dict[str, Any]) -> str:
|
||||
return str(message.get("id") or message.get("message_id") or "").strip()
|
||||
|
||||
|
||||
def sender_from_conversation(conversation: dict[str, Any], message: dict[str, Any]) -> dict[str, Any]:
|
||||
sender: dict[str, Any] = {}
|
||||
meta = conversation.get("meta") or {}
|
||||
if isinstance(meta, dict) and isinstance(meta.get("sender"), dict):
|
||||
sender.update(meta.get("sender") or {})
|
||||
if isinstance(conversation.get("contact"), dict):
|
||||
sender.update({k: v for k, v in conversation["contact"].items() if v is not None})
|
||||
if isinstance(message.get("sender"), dict):
|
||||
sender.update({k: v for k, v in message["sender"].items() if v is not None})
|
||||
return sender
|
||||
|
||||
|
||||
def iter_remote_messages(
|
||||
*,
|
||||
statuses: Iterable[str],
|
||||
cutoff: datetime,
|
||||
max_pages: int,
|
||||
max_context_messages: int,
|
||||
) -> list[RemoteMessage]:
|
||||
seen_conversations: set[str] = set()
|
||||
selected: list[RemoteMessage] = []
|
||||
|
||||
for status in statuses:
|
||||
conversations = fetch_conversations(status, max_pages=max_pages)
|
||||
print(f"--- status={status} conversations={len(conversations)}")
|
||||
for conv in conversations:
|
||||
conv_id = str(conv.get("id") or "").strip()
|
||||
if not conv_id or conv_id in seen_conversations:
|
||||
continue
|
||||
seen_conversations.add(conv_id)
|
||||
|
||||
last_activity = conversation_last_activity(conv)
|
||||
if last_activity and last_activity < cutoff:
|
||||
continue
|
||||
|
||||
messages = fetch_messages(conv_id)
|
||||
messages.sort(key=message_created_at)
|
||||
public_messages = [m for m in messages if not m.get("private") and message_content(m)]
|
||||
|
||||
for index, msg in enumerate(public_messages):
|
||||
if not is_incoming(msg):
|
||||
continue
|
||||
if message_created_at(msg) < cutoff:
|
||||
continue
|
||||
mid = message_id(msg)
|
||||
if not mid:
|
||||
continue
|
||||
start = max(0, index - max_context_messages)
|
||||
context = public_messages[start : index + 1]
|
||||
selected.append(RemoteMessage(conversation=conv, message=msg, recent_context=context))
|
||||
|
||||
selected.sort(key=lambda item: message_created_at(item.message))
|
||||
return selected
|
||||
|
||||
|
||||
def load_clientflow_status(source_event_ids: list[str]) -> dict[str, dict[str, Any]]:
|
||||
if not source_event_ids:
|
||||
return {}
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(text("""
|
||||
WITH ids AS (
|
||||
SELECT unnest(CAST(:ids AS text[])) AS source_event_id
|
||||
)
|
||||
SELECT
|
||||
ids.source_event_id,
|
||||
re.id::text AS raw_event_id,
|
||||
re.created_at AS raw_event_created_at,
|
||||
re.processed,
|
||||
re.ignored,
|
||||
re.processing_error,
|
||||
re.message_id::text AS raw_event_message_id,
|
||||
re.action_run_id::text AS raw_event_action_run_id,
|
||||
m.id::text AS message_id,
|
||||
c.id::text AS communication_id,
|
||||
o.id::text AS opportunity_id
|
||||
FROM ids
|
||||
LEFT JOIN raw_events re
|
||||
ON re.source_system = 'chatwoot'
|
||||
AND re.source_event_id = ids.source_event_id
|
||||
LEFT JOIN messages m
|
||||
ON (
|
||||
(re.message_id IS NOT NULL AND m.id = re.message_id)
|
||||
OR (re.id IS NOT NULL AND m.raw_event_id = re.id)
|
||||
OR (m.source_system = 'chatwoot' AND m.source_event_id = ids.source_event_id)
|
||||
)
|
||||
LEFT JOIN communications c
|
||||
ON c.source_system = 'chatwoot'
|
||||
AND c.source_message_id = ids.source_event_id
|
||||
LEFT JOIN opportunities o
|
||||
ON o.conversation_id = COALESCE(re.conversation_id, m.conversation_id)
|
||||
"""), {"ids": source_event_ids}).mappings().all()
|
||||
return {str(row["source_event_id"]): dict(row) for row in rows}
|
||||
|
||||
|
||||
def classify(row: dict[str, Any] | None) -> str:
|
||||
row = row or {}
|
||||
visible_message_id = row.get("message_id") or row.get("raw_event_message_id")
|
||||
if not row.get("raw_event_id") and not visible_message_id and not row.get("communication_id"):
|
||||
return "MISSING_IN_CLIENTFLOW"
|
||||
if row.get("processing_error"):
|
||||
return "RAW_PROCESSING_ERROR"
|
||||
if row.get("raw_event_id") and not row.get("processed") and not row.get("ignored"):
|
||||
return "RAW_PENDING"
|
||||
if row.get("ignored"):
|
||||
return "RAW_IGNORED"
|
||||
if row.get("processed") and not visible_message_id and not row.get("communication_id"):
|
||||
return "PROCESSED_BUT_NOT_VISIBLE"
|
||||
if not row.get("opportunity_id"):
|
||||
return "VISIBLE_BUT_UNLINKED_CONVERSATION"
|
||||
return "OK"
|
||||
|
||||
|
||||
def build_webhook_payload(remote: RemoteMessage) -> dict[str, Any]:
|
||||
conv = remote.conversation
|
||||
msg = remote.message
|
||||
conv_id = str(conv.get("id") or msg.get("conversation_id") or "")
|
||||
sender = sender_from_conversation(conv, msg)
|
||||
contact_id = str(sender.get("id") or conv.get("contact_id") or conv.get("contact", {}).get("id") or msg.get("contact_id") or "")
|
||||
|
||||
normalized_message = dict(msg)
|
||||
normalized_message["id"] = message_id(msg)
|
||||
normalized_message["content"] = message_content(msg)
|
||||
normalized_message["message_type"] = "incoming"
|
||||
normalized_message["conversation_id"] = conv_id
|
||||
normalized_message["sender"] = sender
|
||||
normalized_message["private"] = bool(msg.get("private"))
|
||||
|
||||
conversation_payload = dict(conv)
|
||||
conversation_payload["id"] = conv_id
|
||||
conversation_payload["messages"] = remote.recent_context
|
||||
conversation_payload["contact"] = conv.get("contact") if isinstance(conv.get("contact"), dict) else sender
|
||||
meta = conversation_payload.get("meta") if isinstance(conversation_payload.get("meta"), dict) else {}
|
||||
meta = dict(meta)
|
||||
meta.setdefault("sender", sender)
|
||||
conversation_payload["meta"] = meta
|
||||
|
||||
return {
|
||||
"event": "message_created",
|
||||
"id": normalized_message["id"],
|
||||
"message_id": normalized_message["id"],
|
||||
"conversation_id": conv_id,
|
||||
"contact_id": contact_id,
|
||||
"content": normalized_message["content"],
|
||||
"message_type": "incoming",
|
||||
"private": bool(msg.get("private")),
|
||||
"message": normalized_message,
|
||||
"conversation": conversation_payload,
|
||||
"sender": sender,
|
||||
"backfill": {
|
||||
"source": "chatwoot_remote_missing_messages",
|
||||
"reason": "clientflow_down_or_webhook_not_delivered",
|
||||
"audited_at": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def sign_body(body_raw: str) -> dict[str, str]:
|
||||
ts = str(int(time.time()))
|
||||
msg = ts.encode("utf-8") + b"." + body_raw.encode("utf-8")
|
||||
sig = "sha256=" + hmac.new(CLIENTFLOW_WEBHOOK_SECRET.encode("utf-8"), msg, hashlib.sha256).hexdigest()
|
||||
return {"Content-Type": "application/json", "X-Chatwoot-Timestamp": ts, "X-Chatwoot-Signature": sig}
|
||||
|
||||
|
||||
def post_to_clientflow(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
body_raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
headers = sign_body(body_raw)
|
||||
req = urllib.request.Request(CLIENTFLOW_WEBHOOK_URL, data=body_raw.encode("utf-8"), headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
return {"ok": 200 <= resp.status < 300, "status": resp.status, "raw": raw}
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode("utf-8", errors="replace")
|
||||
return {"ok": False, "status": e.code, "raw": raw}
|
||||
except urllib.error.URLError as e:
|
||||
return {"ok": False, "status": 0, "raw": str(e)}
|
||||
|
||||
|
||||
def write_reports(rows: list[dict[str, Any]], counts: dict[str, int], *, hours: int) -> None:
|
||||
fieldnames = list(rows[0].keys()) if rows else [
|
||||
"status", "chatwoot_created_at", "conversation_id", "source_event_id", "email", "content",
|
||||
"raw_event_id", "processed", "ignored", "processing_error", "message_id", "communication_id", "opportunity_id", "post_status",
|
||||
]
|
||||
with OUT_CSV.open("w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
with OUT_MD.open("w", encoding="utf-8") as f:
|
||||
f.write("# Auditoria remota Chatwoot → ClientFlow\n\n")
|
||||
f.write(f"Janela: últimas {hours} horas\n\n")
|
||||
f.write("## Resumo\n\n")
|
||||
if not counts:
|
||||
f.write("- OK: sem gaps inbound encontrados\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 rows[:300]:
|
||||
f.write(f"### {row['status']} · conv #{row['conversation_id']} · msg {row['source_event_id']}\n\n")
|
||||
f.write(f"- Data Chatwoot: {row['chatwoot_created_at']}\n")
|
||||
f.write(f"- Email: {row['email'] or '-'}\n")
|
||||
f.write(f"- Raw event: {row['raw_event_id'] or '-'}\n")
|
||||
f.write(f"- Erro: {row['processing_error'] or '-'}\n")
|
||||
if row.get("post_status"):
|
||||
f.write(f"- Post recovery: {row['post_status']}\n")
|
||||
f.write(f"- Excerto: {row['content'] or '-'}\n\n")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--hours", type=int, default=int(env("CHATWOOT_AUDIT_HOURS", "168")))
|
||||
parser.add_argument("--statuses", default=env("CHATWOOT_AUDIT_STATUSES", "open,pending,resolved,snoozed"))
|
||||
parser.add_argument("--max-pages", type=int, default=int(env("CHATWOOT_AUDIT_MAX_PAGES", "50")))
|
||||
parser.add_argument("--include-ok", action="store_true")
|
||||
parser.add_argument("--post-missing", action="store_true", help="Reenvia para /webhooks/chatwoot apenas MISSING_IN_CLIENTFLOW")
|
||||
parser.add_argument("--limit", type=int, default=0, help="Limita mensagens a auditar/repostar; 0 = sem limite")
|
||||
parser.add_argument("--max-context-messages", type=int, default=5)
|
||||
args = parser.parse_args()
|
||||
|
||||
required = {
|
||||
"CHATWOOT_BASE_URL": CHATWOOT_BASE_URL,
|
||||
"CHATWOOT_ACCOUNT_ID": CHATWOOT_ACCOUNT_ID,
|
||||
"CHATWOOT_API_TOKEN": CHATWOOT_API_TOKEN,
|
||||
}
|
||||
if args.post_missing:
|
||||
required["CLIENTFLOW_WEBHOOK_SECRET"] = CLIENTFLOW_WEBHOOK_SECRET
|
||||
missing = [k for k, v in required.items() if not v]
|
||||
if missing:
|
||||
raise SystemExit(f"Faltam variáveis: {', '.join(missing)}")
|
||||
|
||||
statuses = [s.strip() for s in str(args.statuses or "").split(",") if s.strip()]
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=int(args.hours))
|
||||
|
||||
print(f"CHATWOOT_AUDIT_HOURS={args.hours}")
|
||||
print(f"CHATWOOT_AUDIT_STATUSES={statuses}")
|
||||
print(f"CHATWOOT_AUDIT_MAX_PAGES={args.max_pages}")
|
||||
print(f"CUTOFF={cutoff.isoformat()}")
|
||||
|
||||
remote_messages = iter_remote_messages(
|
||||
statuses=statuses,
|
||||
cutoff=cutoff,
|
||||
max_pages=int(args.max_pages),
|
||||
max_context_messages=max(1, int(args.max_context_messages)),
|
||||
)
|
||||
if args.limit and args.limit > 0:
|
||||
remote_messages = remote_messages[: int(args.limit)]
|
||||
|
||||
ids = [message_id(item.message) for item in remote_messages]
|
||||
status_map = load_clientflow_status(ids)
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
counts: dict[str, int] = {}
|
||||
posted = 0
|
||||
failed = 0
|
||||
|
||||
for item in remote_messages:
|
||||
msg = item.message
|
||||
conv = item.conversation
|
||||
mid = message_id(msg)
|
||||
cf = status_map.get(mid, {})
|
||||
status = classify(cf)
|
||||
|
||||
if status == "OK" and not args.include_ok:
|
||||
continue
|
||||
|
||||
post_status = ""
|
||||
if args.post_missing and status == "MISSING_IN_CLIENTFLOW":
|
||||
result = post_to_clientflow(build_webhook_payload(item))
|
||||
if result["ok"]:
|
||||
post_status = f"POSTED {result['status']}"
|
||||
posted += 1
|
||||
else:
|
||||
post_status = f"FAILED {result['status']}: {clean(result['raw'], 300)}"
|
||||
failed += 1
|
||||
|
||||
counts[status] = counts.get(status, 0) + 1
|
||||
sender = sender_from_conversation(conv, msg)
|
||||
rows.append({
|
||||
"status": status,
|
||||
"chatwoot_created_at": dt_to_display(msg.get("created_at")),
|
||||
"conversation_id": str(conv.get("id") or msg.get("conversation_id") or ""),
|
||||
"source_event_id": mid,
|
||||
"email": str(sender.get("email") or ""),
|
||||
"content": clean(message_content(msg)),
|
||||
"raw_event_id": cf.get("raw_event_id") or "",
|
||||
"processed": cf.get("processed") if cf.get("raw_event_id") else "",
|
||||
"ignored": cf.get("ignored") if cf.get("raw_event_id") else "",
|
||||
"processing_error": cf.get("processing_error") or "",
|
||||
"message_id": cf.get("message_id") or cf.get("raw_event_message_id") or "",
|
||||
"communication_id": cf.get("communication_id") or "",
|
||||
"opportunity_id": cf.get("opportunity_id") or "",
|
||||
"post_status": post_status,
|
||||
})
|
||||
|
||||
write_reports(rows, counts, hours=int(args.hours))
|
||||
|
||||
print("Auditoria remota Chatwoot inbound concluída.")
|
||||
print(f"Mensagens inbound públicas lidas do Chatwoot: {len(remote_messages)}")
|
||||
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}")
|
||||
if args.post_missing:
|
||||
print(f"posted={posted}")
|
||||
print(f"failed={failed}")
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user