#!/usr/bin/env python3 import hashlib import hmac import json import os import time import urllib.error import urllib.parse import urllib.request from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional 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") BACKFILL_DAYS = int(env("BACKFILL_DAYS", "5")) BACKFILL_TO_CLIENTFLOW = env("BACKFILL_TO_CLIENTFLOW", "false").lower() == "true" BACKFILL_STATUSES = [s.strip() for s in env("BACKFILL_STATUSES", "open,pending").split(",") if s.strip()] BACKFILL_MAX_PAGES = int(env("BACKFILL_MAX_PAGES", "20")) def request_json(method: str, url: str, body: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]: data = None final_headers = headers.copy() if headers else {} if body is not None: data = json.dumps(body, ensure_ascii=False).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") return { "ok": 200 <= resp.status < 300, "status": resp.status, "json": json.loads(raw) if raw else {}, "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, } 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 data 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 item return [] def ts_to_datetime(value: Any) -> Optional[datetime]: if value is None: return None try: if isinstance(value, (int, float)): return datetime.fromtimestamp(float(value), tz=timezone.utc) s = str(value).replace("Z", "+00:00") return datetime.fromisoformat(s).astimezone(timezone.utc) except Exception: return None def conversation_last_activity(conversation: Dict[str, Any]) -> Optional[datetime]: for key in ["last_activity_at", "updated_at", "created_at"]: dt = ts_to_datetime(conversation.get(key)) if dt: return dt return None def fetch_conversations(status: str) -> List[Dict[str, Any]]: all_items: List[Dict[str, Any]] = [] for page in range(1, BACKFILL_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_created_at(message: Dict[str, Any]) -> datetime: return ts_to_datetime(message.get("created_at")) or datetime.fromtimestamp(0, tz=timezone.utc) def sender_from_conversation(conversation: Dict[str, Any], message: Dict[str, Any]) -> Dict[str, Any]: sender = {} 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 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]: # Importante: assinar e enviar exatamente o mesmo body_raw. # Se o JSON for reformatado depois da assinatura, o webhook rejeita com 401. 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=45) as resp: raw = resp.read().decode("utf-8", errors="replace") return { "ok": 200 <= resp.status < 300, "status": resp.status, "json": json.loads(raw) if raw else {}, "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, } def main() -> int: required = { "CHATWOOT_BASE_URL": CHATWOOT_BASE_URL, "CHATWOOT_ACCOUNT_ID": CHATWOOT_ACCOUNT_ID, "CHATWOOT_API_TOKEN": CHATWOOT_API_TOKEN, "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)}") cutoff = datetime.now(timezone.utc) - timedelta(days=BACKFILL_DAYS) print(f"BACKFILL_DAYS={BACKFILL_DAYS}") print(f"BACKFILL_STATUSES={BACKFILL_STATUSES}") print(f"BACKFILL_TO_CLIENTFLOW={BACKFILL_TO_CLIENTFLOW}") print(f"CUTOFF={cutoff.isoformat()}") seen_conversations = set() selected = 0 posted = 0 failed = 0 skipped = 0 for status in BACKFILL_STATUSES: conversations = fetch_conversations(status) print(f"--- status={status} conversations={len(conversations)}") for conv in conversations: conv_id = str(conv.get("id") or "") 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: skipped += 1 continue messages = fetch_messages(conv_id) incoming_messages = [ m for m in messages if is_incoming(m) and not m.get("private") and str(m.get("content") or "").strip() and message_created_at(m) >= cutoff ] if not incoming_messages: skipped += 1 continue incoming_messages.sort(key=message_created_at) last_msg = incoming_messages[-1] content = str(last_msg.get("content") or "").strip() sender = sender_from_conversation(conv, last_msg) contact_id = str(sender.get("id") or conv.get("contact_id") or conv.get("contact", {}).get("id") or "") selected += 1 payload = { "event": "message_created", "message": { "id": str(last_msg.get("id") or f"backfill-{conv_id}"), "content": content, "message_type": "incoming", "conversation_id": conv_id, "sender": sender, "created_at": last_msg.get("created_at"), }, "conversation": { "id": conv_id, "status": conv.get("status") or status, "contact": sender, "meta": { "sender": sender, }, }, "backfill": { "source": "chatwoot_inbox_last_days", "days": BACKFILL_DAYS, "status": status, "last_activity_at": conv.get("last_activity_at"), }, } print("---") print(f"conversation={conv_id} contact={contact_id} msg={payload['message']['id']}") print(f"content={content[:160].replace(chr(10), ' ')}") if not BACKFILL_TO_CLIENTFLOW: print("DRY_RUN") continue result = post_to_clientflow(payload) if result["ok"]: print(f"POSTED status={result['status']}") posted += 1 else: print(f"FAILED status={result['status']} body={result['raw'][:500]}") failed += 1 print("---") print(f"selected={selected}") print(f"posted={posted}") print(f"failed={failed}") print(f"skipped={skipped}") return 0 if failed == 0 else 1 if __name__ == "__main__": raise SystemExit(main())