#!/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())