//! Modulo de Chat em Tempo Real //! //! Este modulo implementa o sistema de chat entre agentes (dashboard web) //! e clientes (Raven desktop). Usa Server-Sent Events (SSE) como metodo //! primario para atualizacoes em tempo real, com fallback para HTTP polling. use convex::{ConvexClient, FunctionResult, Value}; use futures_util::StreamExt; use once_cell::sync::Lazy; use parking_lot::Mutex; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fs; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::async_runtime::JoinHandle; use tauri::{Emitter, Manager, WebviewWindowBuilder, WebviewUrl}; use tauri_plugin_notification::NotificationExt; // ============================================================================ // TYPES // ============================================================================ #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChatSession { pub session_id: String, pub ticket_id: String, pub ticket_ref: u64, pub ticket_subject: String, pub agent_name: String, pub agent_email: Option, pub agent_avatar_url: Option, pub unread_count: u32, pub last_activity_at: i64, pub started_at: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChatMessage { pub id: String, pub body: String, pub author_name: String, pub author_avatar_url: Option, pub is_from_machine: bool, pub created_at: i64, pub attachments: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChatAttachment { pub storage_id: String, pub name: String, pub size: Option, #[serde(rename = "type")] pub mime_type: Option, } #[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChatPollResponse { pub has_active_sessions: bool, pub sessions: Vec, pub total_unread: u32, } #[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChatSessionSummary { pub ticket_id: String, pub ticket_ref: u64, pub unread_count: u32, pub last_activity_at: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChatMessagesResponse { pub messages: Vec, pub has_session: bool, #[serde(default)] pub unread_count: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SendMessageResponse { pub message_id: String, pub created_at: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionStartedEvent { pub session: ChatSession, } // ============================================================================ // PERSISTENCIA DE ESTADO // ============================================================================ /// Estado persistido do chat para sobreviver a restarts #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct ChatPersistedState { last_unread_count: u32, sessions: Vec, saved_at: u64, // Unix timestamp em ms } const STATE_FILE_NAME: &str = "chat-state.json"; const STATE_MAX_AGE_MS: u64 = 3600_000; // 1 hora - ignorar estados mais antigos fn get_state_file_path() -> Option { dirs::data_local_dir().map(|p| p.join("Raven").join(STATE_FILE_NAME)) } fn save_chat_state(last_unread: u32, sessions: &[ChatSession]) { let Some(path) = get_state_file_path() else { return; }; // Criar diretorio se nao existir if let Some(parent) = path.parent() { let _ = fs::create_dir_all(parent); } let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); let state = ChatPersistedState { last_unread_count: last_unread, sessions: sessions.to_vec(), saved_at: now, }; if let Ok(json) = serde_json::to_string_pretty(&state) { let _ = fs::write(&path, json); crate::log_info!("[CHAT] Estado persistido: unread={}, sessions={}", last_unread, sessions.len()); } } fn load_chat_state() -> Option { let path = get_state_file_path()?; let json = fs::read_to_string(&path).ok()?; let state: ChatPersistedState = serde_json::from_str(&json).ok()?; // Verificar se estado nao esta muito antigo let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); if now.saturating_sub(state.saved_at) > STATE_MAX_AGE_MS { crate::log_info!("[CHAT] Estado persistido ignorado (muito antigo)"); return None; } crate::log_info!( "[CHAT] Estado restaurado: unread={}, sessions={}", state.last_unread_count, state.sessions.len() ); Some(state) } // ============================================================================ // HTTP CLIENT // ============================================================================ static CHAT_CLIENT: Lazy = Lazy::new(|| { Client::builder() .user_agent("raven-chat/1.0") .timeout(Duration::from_secs(15)) .use_rustls_tls() .build() .expect("failed to build chat http client") }); // ============================================================================ // API FUNCTIONS // ============================================================================ #[allow(dead_code)] pub async fn poll_chat_updates( base_url: &str, token: &str, last_checked_at: Option, ) -> Result { let url = format!("{}/api/machines/chat/poll", base_url); let mut payload = serde_json::json!({ "machineToken": token, }); if let Some(ts) = last_checked_at { payload["lastCheckedAt"] = serde_json::json!(ts); } let response = CHAT_CLIENT .post(&url) .json(&payload) .send() .await .map_err(|e| format!("Falha na requisicao de poll: {e}"))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("Poll falhou: status={}, body={}", status, body)); } response .json() .await .map_err(|e| format!("Falha ao parsear resposta de poll: {e}")) } pub async fn fetch_sessions(base_url: &str, token: &str) -> Result, String> { let url = format!("{}/api/machines/chat/sessions", base_url); let payload = serde_json::json!({ "machineToken": token, }); let response = CHAT_CLIENT .post(&url) .json(&payload) .send() .await .map_err(|e| format!("Falha na requisicao de sessions: {e}"))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("Sessions falhou: status={}, body={}", status, body)); } #[derive(Deserialize)] struct SessionsResponse { sessions: Vec, } let data: SessionsResponse = response .json() .await .map_err(|e| format!("Falha ao parsear resposta de sessions: {e}"))?; Ok(data.sessions) } pub async fn fetch_messages( base_url: &str, token: &str, ticket_id: &str, since: Option, ) -> Result { let url = format!("{}/api/machines/chat/messages", base_url); let mut payload = serde_json::json!({ "machineToken": token, "ticketId": ticket_id, "action": "list", "limit": 200, }); if let Some(ts) = since { payload["since"] = serde_json::json!(ts); } let response = CHAT_CLIENT .post(&url) .json(&payload) .send() .await .map_err(|e| format!("Falha na requisicao de messages: {e}"))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("Messages falhou: status={}, body={}", status, body)); } response .json() .await .map_err(|e| format!("Falha ao parsear resposta de messages: {e}")) } pub async fn send_message( base_url: &str, token: &str, ticket_id: &str, body: &str, attachments: Option>, ) -> Result { let url = format!("{}/api/machines/chat/messages", base_url); let mut payload = serde_json::json!({ "machineToken": token, "ticketId": ticket_id, "action": "send", "body": body, }); if let Some(atts) = attachments { payload["attachments"] = serde_json::to_value(atts).unwrap_or_default(); } let response = CHAT_CLIENT .post(&url) .json(&payload) .send() .await .map_err(|e| format!("Falha na requisicao de send: {e}"))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("Send falhou: status={}, body={}", status, body)); } response .json() .await .map_err(|e| format!("Falha ao parsear resposta de send: {e}")) } pub async fn mark_messages_read( base_url: &str, token: &str, ticket_id: &str, message_ids: &[String], ) -> Result<(), String> { let url = format!("{}/api/machines/chat/read", base_url); let payload = serde_json::json!({ "machineToken": token, "ticketId": ticket_id, "messageIds": message_ids, }); let response = CHAT_CLIENT .post(&url) .json(&payload) .send() .await .map_err(|e| format!("Falha na requisicao de mark read: {e}"))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("Mark read falhou: status={}, body={}", status, body)); } Ok(()) } // ============================================================================ // UPLOAD DE ARQUIVOS // ============================================================================ #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AttachmentPayload { pub storage_id: String, pub name: String, pub size: Option, #[serde(rename = "type")] pub mime_type: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UploadUrlResponse { pub upload_url: String, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UploadResult { pub storage_id: String, } // Extensoes permitidas const ALLOWED_EXTENSIONS: &[&str] = &[ ".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf", ".txt", ".doc", ".docx", ".xls", ".xlsx", ]; // Tamanho maximo: 10MB const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; pub fn is_allowed_file(file_name: &str, file_size: u64) -> Result<(), String> { let ext = file_name .to_lowercase() .rsplit('.') .next() .map(|e| format!(".{}", e)) .unwrap_or_default(); if !ALLOWED_EXTENSIONS.contains(&ext.as_str()) { return Err(format!( "Tipo de arquivo não permitido. Permitidos: {}", ALLOWED_EXTENSIONS.join(", ") )); } if file_size > MAX_FILE_SIZE { return Err(format!( "Arquivo muito grande. Máximo: {}MB", MAX_FILE_SIZE / 1024 / 1024 )); } Ok(()) } pub fn get_mime_type(file_name: &str) -> String { let lower = file_name.to_lowercase(); let ext = lower.rsplit('.').next().unwrap_or(""); match ext { "jpg" | "jpeg" => "image/jpeg", "png" => "image/png", "gif" => "image/gif", "webp" => "image/webp", "pdf" => "application/pdf", "txt" => "text/plain", "doc" => "application/msword", "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "xls" => "application/vnd.ms-excel", "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", _ => "application/octet-stream", } .to_string() } pub async fn generate_upload_url( base_url: &str, token: &str, file_name: &str, file_type: &str, file_size: u64, ) -> Result { let url = format!("{}/api/machines/chat/upload", base_url); let payload = serde_json::json!({ "machineToken": token, "fileName": file_name, "fileType": file_type, "fileSize": file_size, }); let response = CHAT_CLIENT .post(&url) .json(&payload) .send() .await .map_err(|e| format!("Falha na requisicao de upload URL: {e}"))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("Upload URL falhou: status={}, body={}", status, body)); } let data: UploadUrlResponse = response .json() .await .map_err(|e| format!("Falha ao parsear resposta de upload URL: {e}"))?; Ok(data.upload_url) } pub async fn upload_file( upload_url: &str, file_data: Vec, content_type: &str, ) -> Result { let response = CHAT_CLIENT .post(upload_url) .header("Content-Type", content_type) .body(file_data) .send() .await .map_err(|e| format!("Falha no upload: {e}"))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("Upload falhou: status={}, body={}", status, body)); } let data: UploadResult = response .json() .await .map_err(|e| format!("Falha ao parsear resposta de upload: {e}"))?; Ok(data.storage_id) } // ============================================================================ // CHAT RUNTIME // ============================================================================ struct ChatRealtimeHandle { stop_flag: Arc, join_handle: JoinHandle<()>, } impl ChatRealtimeHandle { fn stop(self) { self.stop_flag.store(true, Ordering::Relaxed); self.join_handle.abort(); } } #[derive(Default, Clone)] pub struct ChatRuntime { inner: Arc>>, last_sessions: Arc>>, last_unread_count: Arc>, is_connected: Arc, } impl ChatRuntime { pub fn new() -> Self { // Tentar restaurar estado persistido let (sessions, unread) = match load_chat_state() { Some(state) => (state.sessions, state.last_unread_count), None => (Vec::new(), 0), }; Self { inner: Arc::new(Mutex::new(None)), last_sessions: Arc::new(Mutex::new(sessions)), last_unread_count: Arc::new(Mutex::new(unread)), is_connected: Arc::new(AtomicBool::new(false)), } } /// Retorna true se conexao WS Convex esta ativa pub fn is_using_sse(&self) -> bool { self.is_connected.load(Ordering::Relaxed) } /// Inicia o sistema de atualizacoes de chat via WebSocket do Convex pub fn start_polling( &self, base_url: String, convex_url: String, token: String, app: tauri::AppHandle, ) -> Result<(), String> { let sanitized_base = base_url.trim().trim_end_matches('/').to_string(); if sanitized_base.is_empty() { return Err("URL base invalida".to_string()); } let sanitized_convex = convex_url.trim().trim_end_matches('/').to_string(); if sanitized_convex.is_empty() { return Err("URL do Convex inválida".to_string()); } // Para polling/SSE existente { let mut guard = self.inner.lock(); if let Some(handle) = guard.take() { handle.stop(); } } let stop_flag = Arc::new(AtomicBool::new(false)); let stop_clone = stop_flag.clone(); let base_clone = sanitized_base.clone(); let convex_clone = sanitized_convex.clone(); let token_clone = token.clone(); let last_sessions = self.last_sessions.clone(); let last_unread_count = self.last_unread_count.clone(); let is_connected = self.is_connected.clone(); let join_handle = tauri::async_runtime::spawn(async move { crate::log_info!("[CHAT DEBUG] Iniciando sistema de chat"); crate::log_info!("[CHAT DEBUG] Convex URL: {}", convex_clone); crate::log_info!("[CHAT DEBUG] API Base URL: {}", base_clone); let mut backoff_ms: u64 = 1_000; let max_backoff_ms: u64 = 30_000; let poll_interval = Duration::from_secs(5); let mut last_poll = Instant::now() - poll_interval; loop { if stop_clone.load(Ordering::Relaxed) { break; } crate::log_info!("[CHAT DEBUG] Tentando conectar ao Convex..."); let client_result = ConvexClient::new(&convex_clone).await; let mut client = match client_result { Ok(c) => { crate::log_info!("[CHAT DEBUG] Cliente Convex criado com sucesso"); c } Err(err) => { is_connected.store(false, Ordering::Relaxed); crate::log_warn!("[CHAT DEBUG] FALHA ao criar cliente Convex: {err:?}"); if last_poll.elapsed() >= poll_interval { poll_and_process_chat_update( &base_clone, &token_clone, &app, &last_sessions, &last_unread_count, ) .await; last_poll = Instant::now(); } tokio::time::sleep(Duration::from_millis(backoff_ms)).await; backoff_ms = backoff_ms.saturating_mul(2).min(max_backoff_ms); continue; } }; let mut args = BTreeMap::new(); args.insert("machineToken".to_string(), token_clone.clone().into()); crate::log_info!("[CHAT DEBUG] Assinando liveChat:checkMachineUpdates..."); let subscribe_result = client.subscribe("liveChat:checkMachineUpdates", args).await; let mut subscription = match subscribe_result { Ok(sub) => { is_connected.store(true, Ordering::Relaxed); backoff_ms = 1_000; crate::log_info!("[CHAT DEBUG] CONECTADO ao Convex WebSocket com sucesso!"); sub } Err(err) => { is_connected.store(false, Ordering::Relaxed); crate::log_warn!("[CHAT DEBUG] FALHA ao assinar checkMachineUpdates: {err:?}"); if last_poll.elapsed() >= poll_interval { poll_and_process_chat_update( &base_clone, &token_clone, &app, &last_sessions, &last_unread_count, ) .await; last_poll = Instant::now(); } tokio::time::sleep(Duration::from_millis(backoff_ms)).await; backoff_ms = backoff_ms.saturating_mul(2).min(max_backoff_ms); continue; } }; crate::log_info!("[CHAT DEBUG] Entrando no loop de escuta WebSocket..."); let mut update_count: u64 = 0; while let Some(next) = subscription.next().await { update_count += 1; if stop_clone.load(Ordering::Relaxed) { crate::log_info!("[CHAT DEBUG] Stop flag detectado, saindo do loop"); break; } match next { FunctionResult::Value(Value::Object(obj)) => { let has_active = obj .get("hasActiveSessions") .and_then(|v| match v { Value::Boolean(b) => Some(*b), _ => None, }) .unwrap_or(false); let total_unread = obj .get("totalUnread") .and_then(|v| match v { Value::Int64(i) => Some(*i as u32), Value::Float64(f) => Some(*f as u32), _ => None, }) .unwrap_or(0); crate::log_info!( "[CHAT DEBUG] UPDATE #{} recebido via WebSocket: hasActive={}, totalUnread={}", update_count, has_active, total_unread ); process_chat_update( &base_clone, &token_clone, &app, &last_sessions, &last_unread_count, has_active, total_unread, ) .await; } FunctionResult::ConvexError(err) => { crate::log_warn!("[CHAT DEBUG] Convex error em checkMachineUpdates: {err:?}"); } FunctionResult::ErrorMessage(msg) => { crate::log_warn!("[CHAT DEBUG] Erro em checkMachineUpdates: {msg}"); } FunctionResult::Value(other) => { crate::log_warn!("[CHAT DEBUG] Payload inesperado em checkMachineUpdates: {other:?}"); } } } is_connected.store(false, Ordering::Relaxed); if stop_clone.load(Ordering::Relaxed) { crate::log_info!("[CHAT DEBUG] Stop flag detectado apos loop"); break; } crate::log_warn!("[CHAT DEBUG] WebSocket DESCONECTADO! Aplicando fallback e tentando reconectar..."); if last_poll.elapsed() >= poll_interval { poll_and_process_chat_update( &base_clone, &token_clone, &app, &last_sessions, &last_unread_count, ) .await; last_poll = Instant::now(); } tokio::time::sleep(Duration::from_millis(backoff_ms)).await; backoff_ms = backoff_ms.saturating_mul(2).min(max_backoff_ms); } is_connected.store(false, Ordering::Relaxed); crate::log_info!("Chat encerrado (realtime finalizado)"); }); let mut guard = self.inner.lock(); *guard = Some(ChatRealtimeHandle { stop_flag, join_handle, }); Ok(()) } pub fn stop(&self) { let mut guard = self.inner.lock(); if let Some(handle) = guard.take() { handle.stop(); } self.is_connected.store(false, Ordering::Relaxed); } pub fn get_sessions(&self) -> Vec { self.last_sessions.lock().clone() } } // ============================================================================ // SHARED UPDATE PROCESSING // ============================================================================ async fn poll_and_process_chat_update( base_url: &str, token: &str, app: &tauri::AppHandle, last_sessions: &Arc>>, last_unread_count: &Arc>, ) { crate::log_info!("[CHAT DEBUG] Executando fallback HTTP polling..."); match poll_chat_updates(base_url, token, None).await { Ok(result) => { crate::log_info!( "[CHAT DEBUG] Polling OK: hasActive={}, totalUnread={}", result.has_active_sessions, result.total_unread ); process_chat_update( base_url, token, app, last_sessions, last_unread_count, result.has_active_sessions, result.total_unread, ) .await; } Err(err) => { crate::log_warn!("[CHAT DEBUG] Fallback poll FALHOU: {err}"); } } } async fn process_chat_update( base_url: &str, token: &str, app: &tauri::AppHandle, last_sessions: &Arc>>, last_unread_count: &Arc>, has_active_sessions: bool, total_unread: u32, ) { crate::log_info!( "[CHAT DEBUG] process_chat_update: hasActive={}, totalUnread={}", has_active_sessions, total_unread ); // Buscar sessoes completas para ter dados corretos let mut current_sessions = if has_active_sessions { let sessions = fetch_sessions(base_url, token).await.unwrap_or_default(); crate::log_info!("[CHAT DEBUG] Buscou {} sessoes ativas", sessions.len()); sessions } else { crate::log_info!("[CHAT DEBUG] Sem sessoes ativas"); Vec::new() }; // Ordenar por ultima atividade (mais recente primeiro) para consistencia em UI/tray. if current_sessions.len() > 1 { current_sessions.sort_by(|a, b| { b.last_activity_at .cmp(&a.last_activity_at) .then_with(|| b.started_at.cmp(&a.started_at)) }); } // Verificar sessoes anteriores let prev_sessions: Vec = last_sessions.lock().clone(); let prev_session_ids: Vec = prev_sessions.iter().map(|s| s.session_id.clone()).collect(); let current_session_ids: Vec = current_sessions.iter().map(|s| s.session_id.clone()).collect(); // Detectar novas sessoes for session in ¤t_sessions { if !prev_session_ids.contains(&session.session_id) { crate::log_info!( "Nova sessao de chat: ticket={}, session={}", session.ticket_id, session.session_id ); let _ = app.emit( "raven://chat/session-started", SessionStartedEvent { session: session.clone(), }, ); // NÃO abre janela aqui - só quando o agente enviar a primeira mensagem // O chat aparecerá minimizado com badge quando houver novas mensagens crate::log_info!( "Sessão de chat iniciada pelo agente {}. Aguardando primeira mensagem.", session.agent_name ); } } // Detectar sessoes encerradas for prev_session in &prev_sessions { if !current_session_ids.contains(&prev_session.session_id) { crate::log_info!( "Sessao de chat encerrada: ticket={}, session={}", prev_session.ticket_id, prev_session.session_id ); let _ = app.emit( "raven://chat/session-ended", serde_json::json!({ "sessionId": prev_session.session_id, "ticketId": prev_session.ticket_id }), ); } } // ========================================================================= // DETECCAO ROBUSTA DE NOVAS MENSAGENS // Usa DUAS estrategias: timestamp E contador (belt and suspenders) // ========================================================================= let prev_unread = *last_unread_count.lock(); // Estrategia 1: Detectar por lastActivityAt de cada sessao // Se alguma sessao teve atividade mais recente E tem mensagens nao lidas -> nova mensagem let mut detected_by_activity = false; let mut activity_details = String::new(); for session in ¤t_sessions { let prev_activity = prev_sessions .iter() .find(|s| s.session_id == session.session_id) .map(|s| s.last_activity_at) .unwrap_or(0); // Se lastActivityAt aumentou E ha mensagens nao lidas -> nova mensagem do agente if session.last_activity_at > prev_activity && session.unread_count > 0 { detected_by_activity = true; activity_details = format!( "sessao={} activity: {} -> {} unread={}", session.ticket_id, prev_activity, session.last_activity_at, session.unread_count ); break; } } // Estrategia 2: Fallback por contador total (metodo original) let detected_by_count = total_unread > prev_unread; // Nova mensagem se QUALQUER estrategia detectar let new_messages = detected_by_activity || detected_by_count; // Log detalhado para diagnostico crate::log_info!( "[CHAT] Deteccao: by_activity={} by_count={} (prev={} curr={}) resultado={}", detected_by_activity, detected_by_count, prev_unread, total_unread, new_messages ); if detected_by_activity { crate::log_info!("[CHAT] Detectado por atividade: {}", activity_details); } // Atualizar caches APOS deteccao (importante: manter ordem) *last_sessions.lock() = current_sessions.clone(); *last_unread_count.lock() = total_unread; // Persistir estado para sobreviver a restarts save_chat_state(total_unread, ¤t_sessions); // Sempre emitir unread-update let _ = app.emit( "raven://chat/unread-update", serde_json::json!({ "totalUnread": total_unread, "sessions": current_sessions }), ); // Notificar novas mensagens - mostrar chat minimizado com badge if new_messages && total_unread > 0 { let new_count = if total_unread > prev_unread { total_unread - prev_unread } else { 1 // Se detectou por activity mas contador nao mudou, assumir 1 nova }; crate::log_info!( "[CHAT] NOVAS MENSAGENS! count={}, total={}, metodo={}", new_count, total_unread, if detected_by_activity { "activity" } else { "count" } ); let _ = app.emit( "raven://chat/new-message", serde_json::json!({ "totalUnread": total_unread, "newCount": new_count, "sessions": current_sessions }), ); // Escolher qual sessao/ticket deve ser mostrado quando ha multiplas sessoes. // Preferencia: maior incremento de unread (delta) e, em empate, ultima atividade mais recente. let mut best_session: Option<&ChatSession> = None; let mut best_delta: u32 = 0; for session in ¤t_sessions { let prev_unread_for_ticket = prev_sessions .iter() .find(|s| s.ticket_id == session.ticket_id) .map(|s| s.unread_count) .unwrap_or(0); let delta = session.unread_count.saturating_sub(prev_unread_for_ticket); let is_better = if delta > best_delta { true } else if delta == best_delta { match best_session { Some(best) => session.last_activity_at > best.last_activity_at, None => true, } } else { false }; if is_better { best_delta = delta; best_session = Some(session); } } // Se ha multiplas sessoes ativas, usar o hub; senao, abrir janela do chat individual. // // Importante (UX): em multiplas sessoes, NAO fecha a janela ativa quando chega mensagem em outra conversa. // O hub + badge/notificacao sinalizam novas mensagens e o usuario decide quando alternar. if current_sessions.len() > 1 { let _ = open_hub_window(app); } else { // Uma sessao - nao precisa de hub let _ = close_hub_window(app); // Fallback: se nao conseguimos detectar delta, pega a sessao com mais unread e mais recente. let session_to_show = if best_delta > 0 { best_session } else { current_sessions.iter().max_by(|a, b| { a.unread_count .cmp(&b.unread_count) .then_with(|| a.last_activity_at.cmp(&b.last_activity_at)) }) }; // Mostrar janela de chat (sempre minimizada/nao intrusiva) if let Some(session) = session_to_show { let _ = open_chat_window_internal(app, &session.ticket_id, session.ticket_ref, true); } } // Notificacao nativa let notification_title = "Nova mensagem de suporte"; let notification_body = if new_count == 1 { "Você recebeu 1 nova mensagem no chat".to_string() } else { format!("Você recebeu {} novas mensagens no chat", new_count) }; let _ = app .notification() .builder() .title(notification_title) .body(¬ification_body) .show(); } else { // Log para debug quando NAO ha novas mensagens if total_unread == 0 { crate::log_info!("[CHAT DEBUG] Sem mensagens nao lidas (total=0)"); } else if !new_messages { crate::log_info!( "[CHAT DEBUG] Sem novas mensagens (prev={} >= total={})", prev_unread, total_unread ); } } } // ============================================================================ // WINDOW MANAGEMENT // ============================================================================ // Serializa operacoes de janela para evitar race/deadlock no Windows (winit/WebView2). static WINDOW_OP_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); fn resolve_chat_window_position( app: &tauri::AppHandle, window: Option<&tauri::WebviewWindow>, width: f64, height: f64, ) -> (f64, f64) { let margin = 20.0; let taskbar_height = 50.0; let monitor = window .and_then(|w| w.current_monitor().ok().flatten()) .or_else(|| { app.get_webview_window("main") .and_then(|w| w.current_monitor().ok().flatten()) }) .or_else(|| app.available_monitors().ok().and_then(|monitors| monitors.into_iter().next())); let Some(monitor) = monitor else { return (100.0, 100.0); }; let size = monitor.size(); let pos = monitor.position(); let scale = monitor.scale_factor(); // Converter coordenadas do monitor para coordenadas logicas (multi-monitor pode ter origem negativa). let monitor_x = pos.x as f64 / scale; let monitor_y = pos.y as f64 / scale; let monitor_width = size.width as f64 / scale; let monitor_height = size.height as f64 / scale; let max_x = monitor_x + monitor_width - width - margin; let max_y = monitor_y + monitor_height - height - margin - taskbar_height; let x = if max_x.is_finite() { max_x.max(monitor_x) } else { 100.0 }; let y = if max_y.is_finite() { max_y.max(monitor_y) } else { 100.0 }; (x, y) } fn open_chat_window_internal(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64, start_minimized: bool) -> Result<(), String> { let _guard = WINDOW_OP_LOCK.lock(); open_chat_window_with_state(app, ticket_id, ticket_ref, start_minimized) } /// Abre janela de chat com estado inicial de minimizacao configuravel fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64, start_minimized: bool) -> Result<(), String> { let label = format!("chat-{}", ticket_id); crate::log_info!( "[WINDOW] open_chat_window: label={} ticket_ref={} start_minimized={}", label, ticket_ref, start_minimized ); // Verificar se ja existe if let Some(window) = app.get_webview_window(&label) { let _ = window.set_ignore_cursor_events(false); crate::log_info!("[WINDOW] {}: window existe -> show()", label); window.show().map_err(|e| e.to_string())?; let _ = window.unminimize(); if !start_minimized { crate::log_info!("[WINDOW] {}: window existe -> set_focus()", label); window.set_focus().map_err(|e| e.to_string())?; } // Expandir a janela se estiver minimizada (quando clicado na lista) if !start_minimized { crate::log_info!("[WINDOW] {}: window existe -> set_chat_minimized(false)", label); let _ = set_chat_minimized_unlocked(app, ticket_id, false); } crate::log_info!("[WINDOW] {}: open_chat_window OK (reuso)", label); return Ok(()); } // Dimensoes baseadas no estado inicial let (width, height) = if start_minimized { (240.0, 52.0) // Tamanho minimizado (chip com badge) } else { (380.0, 520.0) // Tamanho expandido }; // Posicionar no canto inferior direito (acima da barra de tarefas). let (x, y) = resolve_chat_window_position(app, None, width, height); // Usar query param ao inves de path para compatibilidade com SPA let url_path = format!("index.html?view=chat&ticketId={}&ticketRef={}", ticket_id, ticket_ref); crate::log_info!( "[WINDOW] {}: build() inicio size={}x{} pos=({},{}) url={}", label, width, height, x, y, url_path ); let window = WebviewWindowBuilder::new( app, &label, WebviewUrl::App(url_path.into()), ) .title("Chat de Suporte") .inner_size(width, height) // Abre ja no tamanho correto .min_inner_size(240.0, 52.0) // Tamanho minimo para modo minimizado com badge .position(x, y) .decorations(false) // Sem decoracoes nativas - usa header customizado .transparent(true) // Permite fundo transparente .shadow(false) // Desabilitar sombra para transparencia funcionar corretamente .resizable(false) // Desabilitar redimensionamento manual // Mantem o chat acessivel mesmo ao trocar de janela/app (skip_taskbar=true). .always_on_top(true) .skip_taskbar(true) .focused(!start_minimized) .visible(true) .build() .map_err(|e| e.to_string())?; crate::log_info!("[WINDOW] {}: build() OK", label); // IMPORTANTE: Garantir que a janela receba eventos de cursor (evita click-through) let _ = window.set_ignore_cursor_events(false); crate::log_info!("[WINDOW] {}: pos-build set_chat_minimized({}) inicio", label, start_minimized); // Reaplica layout/posicao logo apos criar a janela. // Isso evita que a primeira abertura apareca no canto superior esquerdo em alguns ambientes. let _ = set_chat_minimized_unlocked(app, ticket_id, start_minimized); crate::log_info!("[WINDOW] {}: pos-build set_chat_minimized({}) fim", label, start_minimized); crate::log_info!("Janela de chat aberta (minimizada={}): {}", start_minimized, label); Ok(()) } pub fn open_chat_window(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64) -> Result<(), String> { // Quando chamado explicitamente (ex: clique no hub), abre expandida open_chat_window_internal(app, ticket_id, ticket_ref, false) } pub fn close_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> { let _guard = WINDOW_OP_LOCK.lock(); let label = format!("chat-{}", ticket_id); if let Some(window) = app.get_webview_window(&label) { window.close().map_err(|e| e.to_string())?; } Ok(()) } pub fn minimize_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> { let _guard = WINDOW_OP_LOCK.lock(); let label = format!("chat-{}", ticket_id); if let Some(window) = app.get_webview_window(&label) { window.hide().map_err(|e| e.to_string())?; } Ok(()) } /// Redimensiona a janela de chat para modo minimizado (chip) ou expandido fn set_chat_minimized_unlocked(app: &tauri::AppHandle, ticket_id: &str, minimized: bool) -> Result<(), String> { let label = format!("chat-{}", ticket_id); let window = app.get_webview_window(&label).ok_or("Janela não encontrada")?; // Tamanhos - chip minimizado com margem extra para badge (absolute -top-1 -right-1) let (width, height) = if minimized { (240.0, 52.0) // Tamanho com folga para "Ticket #XXX" e badge } else { (380.0, 520.0) // Tamanho expandido }; // Calcular posicao no canto inferior direito do monitor atual (com fallback seguro). let (x, y) = resolve_chat_window_position(app, Some(&window), width, height); // Aplicar novo tamanho e posicao crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_size inicio", label, minimized); window.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?; crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_size OK", label, minimized); crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_position inicio", label, minimized); window.set_position(tauri::LogicalPosition::new(x, y)).map_err(|e| e.to_string())?; crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_position OK", label, minimized); crate::log_info!("Chat {} -> minimized={}", ticket_id, minimized); Ok(()) } pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bool) -> Result<(), String> { let _guard = WINDOW_OP_LOCK.lock(); set_chat_minimized_unlocked(app, ticket_id, minimized) } // ============================================================================ // HUB WINDOW MANAGEMENT (Lista de todas as sessoes) // ============================================================================ const HUB_WINDOW_LABEL: &str = "chat-hub"; pub fn open_hub_window(app: &tauri::AppHandle) -> Result<(), String> { let _guard = WINDOW_OP_LOCK.lock(); open_hub_window_with_state(app, true) // Por padrao abre minimizada } fn open_hub_window_with_state(app: &tauri::AppHandle, start_minimized: bool) -> Result<(), String> { // Verificar se ja existe if let Some(window) = app.get_webview_window(HUB_WINDOW_LABEL) { let _ = window.set_ignore_cursor_events(false); window.show().map_err(|e| e.to_string())?; let _ = window.unminimize(); if !start_minimized { window.set_focus().map_err(|e| e.to_string())?; } return Ok(()); } // Dimensoes baseadas no estado inicial let (width, height) = if start_minimized { (200.0, 52.0) // Tamanho minimizado (chip) } else { (400.0, 520.0) // Tamanho expandido (igual ao web) }; // Posicionar no canto inferior direito let (x, y) = resolve_chat_window_position(app, None, width, height); // URL para modo hub let url_path = "index.html?view=chat&hub=true"; WebviewWindowBuilder::new( app, HUB_WINDOW_LABEL, WebviewUrl::App(url_path.into()), ) .title("Chats de Suporte") .inner_size(width, height) .min_inner_size(200.0, 52.0) .position(x, y) .decorations(false) .transparent(true) .shadow(false) .resizable(false) // Desabilitar redimensionamento manual // Mantem o hub acessivel mesmo ao trocar de janela/app (skip_taskbar=true). .always_on_top(true) .skip_taskbar(true) .focused(!start_minimized) .visible(true) .build() .map_err(|e| e.to_string())?; // IMPORTANTE: Garantir que a janela receba eventos de cursor (evita click-through) if let Some(hub) = app.get_webview_window(HUB_WINDOW_LABEL) { let _ = hub.set_ignore_cursor_events(false); if !start_minimized { let _ = hub.set_focus(); } } // REMOVIDO TEMPORARIAMENTE: set_hub_minimized logo apos build pode causar // "resize em cima do resize" no timing errado do WebView2 // let _ = set_hub_minimized(app, start_minimized); crate::log_info!("Hub window aberta (minimizada={})", start_minimized); Ok(()) } pub fn close_hub_window(app: &tauri::AppHandle) -> Result<(), String> { let _guard = WINDOW_OP_LOCK.lock(); if let Some(window) = app.get_webview_window(HUB_WINDOW_LABEL) { window.close().map_err(|e| e.to_string())?; } Ok(()) } pub fn set_hub_minimized(app: &tauri::AppHandle, minimized: bool) -> Result<(), String> { let _guard = WINDOW_OP_LOCK.lock(); let window = app.get_webview_window(HUB_WINDOW_LABEL).ok_or("Hub window não encontrada")?; let (width, height) = if minimized { (200.0, 52.0) // Chip minimizado } else { (400.0, 520.0) // Lista expandida (igual ao web) }; let (x, y) = resolve_chat_window_position(app, Some(&window), width, height); // IGUAL AO CHAT: primeiro size, depois position (ordem importa para hit-test no Windows) window.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?; window.set_position(tauri::LogicalPosition::new(x, y)).map_err(|e| e.to_string())?; // Foco apenas quando expandir (evita roubar foco ao minimizar apos abrir um chat). if !minimized { let _ = window.set_focus(); } crate::log_info!("Hub -> minimized={}, size={}x{}, pos=({},{})", minimized, width, height, x, y); Ok(()) }