339 lines
10 KiB
Python
Executable File
339 lines
10 KiB
Python
Executable File
#!/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())
|