Implementa sistema de chat em tempo real entre agente e cliente

- Adiciona tabela liveChatSessions no schema Convex
- Cria convex/liveChat.ts com mutations e queries para chat
- Adiciona API routes para maquinas (sessions, messages, poll)
- Cria modulo chat.rs no Tauri com ChatRuntime e polling
- Adiciona comandos de chat no lib.rs (start/stop polling, open/close window)
- Cria componentes React do chat widget (ChatWidget, types)
- Adiciona botao "Iniciar Chat" no dashboard (ticket-chat-panel)
- Implementa menu de chat no system tray
- Polling de 2 segundos para maior responsividade
- Janela de chat flutuante, frameless, always-on-top

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
esdrasrenan 2025-12-07 01:00:27 -03:00
parent 0c8d53c0b6
commit ba91c1e0f5
15 changed files with 2004 additions and 15 deletions

View file

@ -0,0 +1,482 @@
//! Modulo de Chat em Tempo Real
//!
//! Este modulo implementa o sistema de chat entre agentes (dashboard web)
//! e clientes (Raven desktop). Inclui polling de mensagens, gerenciamento
//! de janelas de chat e emissao de eventos.
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration;
use tauri::async_runtime::JoinHandle;
use tauri::{Emitter, Manager, WebviewWindowBuilder, WebviewUrl};
use tokio::sync::Notify;
// ============================================================================
// 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<String>,
pub agent_avatar_url: Option<String>,
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<String>,
pub is_from_machine: bool,
pub created_at: i64,
pub attachments: Vec<ChatAttachment>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChatAttachment {
pub storage_id: String,
pub name: String,
pub size: Option<u64>,
#[serde(rename = "type")]
pub mime_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChatPollResponse {
pub has_active_sessions: bool,
pub sessions: Vec<ChatSessionSummary>,
pub total_unread: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChatSessionSummary {
pub ticket_id: String,
pub unread_count: u32,
pub last_activity_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChatMessagesResponse {
pub messages: Vec<ChatMessage>,
pub has_session: bool,
}
#[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,
}
// ============================================================================
// HTTP CLIENT
// ============================================================================
static CHAT_CLIENT: Lazy<Client> = 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
// ============================================================================
pub async fn poll_chat_updates(
base_url: &str,
token: &str,
last_checked_at: Option<i64>,
) -> Result<ChatPollResponse, String> {
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<Vec<ChatSession>, 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<ChatSession>,
}
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<i64>,
) -> Result<ChatMessagesResponse, String> {
let url = format!("{}/api/machines/chat/messages", base_url);
let mut payload = serde_json::json!({
"machineToken": token,
"ticketId": ticket_id,
"action": "list",
});
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,
) -> Result<SendMessageResponse, String> {
let url = format!("{}/api/machines/chat/messages", base_url);
let payload = serde_json::json!({
"machineToken": token,
"ticketId": ticket_id,
"action": "send",
"body": body,
});
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}"))
}
// ============================================================================
// CHAT RUNTIME
// ============================================================================
struct ChatPollerHandle {
stop_signal: Arc<Notify>,
join_handle: JoinHandle<()>,
}
impl ChatPollerHandle {
fn stop(self) {
self.stop_signal.notify_waiters();
self.join_handle.abort();
}
}
#[derive(Default, Clone)]
pub struct ChatRuntime {
inner: Arc<Mutex<Option<ChatPollerHandle>>>,
last_sessions: Arc<Mutex<Vec<ChatSession>>>,
}
impl ChatRuntime {
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(None)),
last_sessions: Arc::new(Mutex::new(Vec::new())),
}
}
pub fn start_polling(
&self,
base_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());
}
// Para polling existente
{
let mut guard = self.inner.lock();
if let Some(handle) = guard.take() {
handle.stop();
}
}
let stop_signal = Arc::new(Notify::new());
let stop_clone = stop_signal.clone();
let base_clone = sanitized_base.clone();
let token_clone = token.clone();
let last_sessions = self.last_sessions.clone();
let join_handle = tauri::async_runtime::spawn(async move {
crate::log_info!("Chat polling iniciado");
let mut last_checked_at: Option<i64> = None;
let poll_interval = Duration::from_secs(2); // Intervalo reduzido para maior responsividade
loop {
tokio::select! {
_ = stop_clone.notified() => {
crate::log_info!("Chat polling encerrado");
break;
}
_ = tokio::time::sleep(poll_interval) => {
match poll_chat_updates(&base_clone, &token_clone, last_checked_at).await {
Ok(result) => {
last_checked_at = Some(chrono::Utc::now().timestamp_millis());
if result.has_active_sessions {
// Verificar novas sessoes
let prev_sessions: Vec<String> = {
last_sessions.lock().iter().map(|s| s.session_id.clone()).collect()
};
// Buscar detalhes das sessoes
if let Ok(sessions) = fetch_sessions(&base_clone, &token_clone).await {
for session in &sessions {
if !prev_sessions.contains(&session.session_id) {
// Nova sessao! Emitir evento
crate::log_info!(
"Nova sessao de chat: ticket={}",
session.ticket_id
);
let _ = app.emit(
"raven://chat/session-started",
SessionStartedEvent {
session: session.clone(),
},
);
// Abrir janela de chat automaticamente
if let Err(e) = open_chat_window_internal(
&app,
&session.ticket_id,
) {
crate::log_error!(
"Falha ao abrir janela de chat: {e}"
);
}
}
}
// Atualizar cache
*last_sessions.lock() = sessions;
}
// Verificar mensagens nao lidas e emitir evento
if result.total_unread > 0 {
crate::log_info!(
"Chat: {} mensagens nao lidas",
result.total_unread
);
let _ = app.emit(
"raven://chat/unread-update",
serde_json::json!({
"totalUnread": result.total_unread,
"sessions": result.sessions
}),
);
}
} else {
// Sem sessoes ativas
*last_sessions.lock() = Vec::new();
}
}
Err(e) => {
crate::log_warn!("Falha no polling de chat: {e}");
}
}
}
}
}
});
let mut guard = self.inner.lock();
*guard = Some(ChatPollerHandle {
stop_signal,
join_handle,
});
Ok(())
}
pub fn stop(&self) {
let mut guard = self.inner.lock();
if let Some(handle) = guard.take() {
handle.stop();
}
}
pub fn get_sessions(&self) -> Vec<ChatSession> {
self.last_sessions.lock().clone()
}
}
// ============================================================================
// WINDOW MANAGEMENT
// ============================================================================
fn open_chat_window_internal(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> {
let label = format!("chat-{}", ticket_id);
// Verificar se ja existe
if let Some(window) = app.get_webview_window(&label) {
window.show().map_err(|e| e.to_string())?;
window.set_focus().map_err(|e| e.to_string())?;
return Ok(());
}
// Obter tamanho da tela para posicionar no canto inferior direito
let monitors = app.available_monitors().map_err(|e| e.to_string())?;
let primary = monitors.into_iter().next();
let (x, y) = if let Some(monitor) = primary {
let size = monitor.size();
let scale = monitor.scale_factor();
let width = 380.0;
let height = 520.0;
let margin = 20.0;
let taskbar_height = 50.0;
(
(size.width as f64 / scale) - width - margin,
(size.height as f64 / scale) - height - margin - taskbar_height,
)
} else {
(100.0, 100.0)
};
let url_path = format!("/chat?ticketId={}", ticket_id);
WebviewWindowBuilder::new(
app,
&label,
WebviewUrl::App(url_path.into()),
)
.title("Chat de Suporte")
.inner_size(380.0, 520.0)
.min_inner_size(300.0, 400.0)
.position(x, y)
.decorations(false) // Frameless
.always_on_top(true)
.skip_taskbar(true)
.focused(true)
.visible(true)
.build()
.map_err(|e| e.to_string())?;
crate::log_info!("Janela de chat aberta: {}", label);
Ok(())
}
pub fn open_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> {
open_chat_window_internal(app, ticket_id)
}
pub fn close_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> {
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 label = format!("chat-{}", ticket_id);
if let Some(window) = app.get_webview_window(&label) {
window.hide().map_err(|e| e.to_string())?;
}
Ok(())
}