#!/usr/bin/env python3 """ sync_chatwoot_response_times.py ================================ Sincroniza TEMPO DE RESPOSTA real do Chatwoot PostgreSQL → relatórios HTML. O que faz: 1. Consulta o tempo médio de primeira resposta por agente no Chatwoot 2. Atualiza o card "Tempo de Resposta do Corretor" em cada relatorio_*.html Uso: python3 sync_chatwoot_response_times.py # todos os relatórios python3 sync_chatwoot_response_times.py dry=1 # só mostra o que mudaria python3 sync_chatwoot_response_times.py force=1 # força update mesmo se igual Cron suggested (a cada 30 min): */30 * * * * docker exec root-postgres-1 psql -U postgres -d chatwoot_production -t --csv -c "SELECT u.name, COUNT(*), AVG(EXTRACT(EPOCH FROM (c.first_reply_created_at - c.created_at))) FROM conversations c JOIN users u ON u.id = c.assignee_id WHERE c.first_reply_created_at IS NOT NULL AND c.assignee_id IS NOT NULL GROUP BY u.id, u.name ORDER BY u.name;" > /tmp/cw_response_times.csv 2>&1 Depends: psycopg2-binary (ou usa subprocess + docker exec) VPS: root@31.97.243.106 """ import os, sys, re, subprocess from datetime import datetime # ─── CONFIG ────────────────────────────────────────────────────────────────── VPS_HOST = "31.97.243.106" VPS_PASS = "Marcia19671951@" REPORTS_DIR = "/var/www/hermes-insights" DRY_MODE = "dry=1" in sys.argv or os.getenv("DRY") == "1" FORCE = "force=1" in sys.argv or os.getenv("FORCE") == "1" # ─── HELPERS ───────────────────────────────────────────────────────────────── def ssh(cmd, timeout=30): """Executa comando no VPS via sshpass.""" full = ["sshpass", "-p", VPS_PASS, "ssh", "-o", "StrictHostKeyChecking=no", "-o", f"ConnectTimeout={timeout}", f"root@{VPS_HOST}", cmd] r = subprocess.run(full, capture_output=True, text=True, timeout=timeout + 5) return r.stdout, r.stderr, r.returncode def fmt_time(sec): """Formata segundos em string legível: 58s, 3min, 2.5h, 1.2d.""" if sec is None or sec <= 0 or (sec != sec): # NaN check return "—" if sec < 60: return f"{sec:.0f}s" if sec < 3600: return f"{sec/60:.0f}min" if sec < 86400: return f"{sec/3600:.1f}h" return f"{sec/86400:.1f}d" def get_chatwoot_response_times(): """Busca tempo médio de resposta por agente direto do PostgreSQL do Chatwoot.""" sql = ( "SELECT u.name, COUNT star," " AVG(EXTRACT(EPOCH FROM c.first_reply_created_at - c.created_at)) " "FROM conversations c " "JOIN users u ON u.id = c.assignee_id " "WHERE c.first_reply_created_at IS NOT NULL " " AND c.assignee_id IS NOT NULL " " AND c.created_at > '2026-01-01' " "GROUP BY u.id, u.name ORDER BY u.name;" ).replace("COUNT star", "COUNT(*)") # Use single-string SSH to avoid subprocess list issues with parentheses cmd = ( f"sshpass -p '{VPS_PASS}' ssh -o StrictHostKeyChecking=no " f"root@{VPS_HOST} \"docker exec root-postgres-1 psql -U postgres " f"-d chatwoot_production -t --csv -c \\\"{sql}\\\"\"" ) r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=40) stdout = r.stdout stderr = r.stderr rc = r.returncode if rc != 0: print(f"[ERRO] DB: {r.stderr[:200]}") return {} times = {} for line in stdout.strip().split("\n"): line = line.strip() if not line: continue parts = line.split(",") if len(parts) >= 3: name = parts[0].strip('"') try: avg_sec = float(parts[2].strip()) times[name] = avg_sec except (ValueError, IndexError): pass return times def get_report_agents(): """Lista todos os relatorio_*.html e extrai o nome do agente.""" import glob agents = {} for path in glob.glob(f"{REPORTS_DIR}/relatorio_*.html"): fname = os.path.basename(path) # relatorio_NOME.html m = re.match(r"relatorio_(.+)\.html$", fname) if not m: continue agent_slug = m.group(1).lower().replace("_", " ").replace("%20", " ") # Read agent name from HTML

try: with open(path) as f: c = f.read() h1 = re.search(r"]*>Relatório Comercial - ([^<]+)

", c) if h1: agent_name = h1.group(1).strip() else: # Try alternate format h1 = re.search(r"]*>Relatorio Comercial - ([^<]+)", c) agent_name = h1.group(1).strip() if h1 else agent_slug except Exception: agent_name = agent_slug agents[agent_name] = {"slug": m.group(1), "path": path} return agents def name_match(report_name, chatwoot_name): """Match flexível entre nome do relatório e nome do Chatwoot.""" r = report_name.lower().strip() c = chatwoot_name.lower().strip() # Exact match if r == c: return True # Slug comparison (remove accents, spaces, etc.) def slug(n): return re.sub(r"[^a-z]", "", n.replace(" ", "").replace("-", "")) if slug(r) == slug(c): return True # Partial match (one contains the other, and length is similar) if len(r) > 3 and len(c) > 3: if r in c or c in r: return True return False def update_report_tempo(report_path, new_value): """Atualiza o card de Tempo de Resposta em um relatório HTML.""" with open(report_path) as f: c = f.read() original = c # Find the "Tempo de Resposta do Corretor" card block # It's a
# followed by font-size:32px value, then the label, then the description # Find the tempo card by looking for the label label_pos = c.find("Tempo de Resposta do Corretor") if label_pos < 0: label_pos = c.find("Tempo de Resposta Médio") if label_pos < 0: label_pos = c.find("Tempo Medio") if label_pos < 0: return False, "Label não encontrado" # Walk backward to find the start of the card div card_start = c.rfind('
', 0, label_pos) if card_start < 0: return False, "Card div não encontrado" # Walk forward to find the end of this card (next card or closing
) # Find the next occurrence of same pattern or end of grid search_from = label_pos # The card ends at the next
', search_from + 1) grid_end = c.find("
", label_pos) # Whichever comes first if next_card > 0 and (grid_end < 0 or next_card < grid_end): card_end = next_card elif grid_end > 0: card_end = grid_end + 6 # len("
") else: card_end = label_pos + 500 old_card = c[card_start:card_end] # Replace the numeric value (font-size:32px) in the card # Pattern:
VALUE
new_card = re.sub( r'(
)[^<]+(
)', r'\g<1>' + new_value + r'\g<2>', old_card ) if old_card == new_card: return False, "Valor já está correto" c = c[:card_start] + new_card + c[card_end:] with open(report_path, 'w') as f: f.write(c) return True, "OK" # ─── MAIN ───────────────────────────────────────────────────────────────────── def main(): print(f"[{datetime.now().strftime('%H:%M:%S')}] === Chatwoot Response Time Sync ===") print(f"Modo: {'DRY (só mostra)' if DRY_MODE else 'LIVE'}") print() # Step 1: Get real times from Chatwoot print("1. Consultando Chatwoot PostgreSQL...") times = get_chatwoot_response_times() if not times: print(" AVISO: Nenhum dado retornado do Chatwoot") else: print(f" {len(times)} agentes encontrados:") for name, sec in sorted(times.items(), key=lambda x: x[1]): print(f" {name:30s} → {fmt_time(sec)}") # Step 2: Get all reports print() print("2. Verificando relatórios...") agents = get_report_agents() print(f" {len(agents)} relatórios encontrados") # Step 3: Match and update print() print("3. Atualizando tempos de resposta...") changes = [] for agent_name, info in agents.items(): path = info["path"] # Find matching Chatwoot agent matched_cw_name = None for cw_name, sec in times.items(): if name_match(agent_name, cw_name): matched_cw_name = cw_name matched_sec = sec break if matched_cw_name: new_value = fmt_time(matched_sec) else: new_value = None # Read current value with open(path) as f: c = f.read() label_pos = c.find("Tempo de Resposta") if label_pos < 0: print(f" — {agent_name}: sem card de tempo de resposta") continue # Extract current value card_search = c[max(0, label_pos-500):label_pos+100] current_m = re.search(r'font-size:32px;font-weight:800;color:#c9a96e">([^<]+)<', card_search) current_value = current_m.group(1) if current_m else "?" if new_value is None: action = f" — {agent_name}: sem dado no Chatwoot (mantém: {current_value})" print(action) continue if current_value == new_value and not FORCE: action = f" ✓ {agent_name}: já correto ({new_value})" print(action) else: if DRY_MODE: action = f" [DRY] {agent_name}: {current_value} → {new_value}" else: ok, msg = update_report_tempo(path, new_value) if ok: action = f" ✓ {agent_name}: {current_value} → {new_value}" changes.append((agent_name, current_value, new_value)) else: action = f" ✗ {agent_name}: {msg}" print(action) # Step 4: Upload changed files to VPS if not DRY_MODE and changes: print() print("4. Fazendo upload para VPS...") import glob for agent_name, _, _ in changes: # Find path for this agent for an, info in agents.items(): if an == agent_name: slug = info["slug"] local = info["path"] remote = f"root@{VPS_HOST}:/var/www/hermes-insights/relatorio_{slug}.html" r = subprocess.run( ["sshpass", "-p", VPS_PASS, "scp", "-o", "StrictHostKeyChecking=no", local, remote], capture_output=True, text=True, timeout=20 ) if r.returncode == 0: print(f" ✓ relatorio_{slug}.html uploaded") else: print(f" ✗ relatorio_{slug}.html: {r.stderr[:100]}") break print() print(f"[OK] {len(changes)} relatórios atualizados") elif DRY_MODE and changes: print() print(f"[DRY] {len(changes)} relatórios precisam de atualização") print(" Rode sem dry=1 para aplicar.") else: print() print("[OK] Nenhuma mudança necessária") if __name__ == "__main__": main()