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:
parent
0c8d53c0b6
commit
ba91c1e0f5
15 changed files with 2004 additions and 15 deletions
|
|
@ -11,6 +11,9 @@
|
||||||
"gen:icon": "node ./scripts/build-icon.mjs"
|
"gen:icon": "node ./scripts/build-icon.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@assistant-ui/react": "^0.7.0",
|
||||||
|
"@assistant-ui/react-markdown": "^0.3.0",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
|
@ -19,7 +22,8 @@
|
||||||
"@tauri-apps/plugin-updater": "^2",
|
"@tauri-apps/plugin-updater": "^2",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"remark-gfm": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
|
|
|
||||||
482
apps/desktop/src-tauri/src/chat.rs
Normal file
482
apps/desktop/src-tauri/src/chat.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
mod agent;
|
mod agent;
|
||||||
|
mod chat;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
mod rustdesk;
|
mod rustdesk;
|
||||||
mod usb_control;
|
mod usb_control;
|
||||||
|
|
||||||
use agent::{collect_inventory_plain, collect_profile, AgentRuntime, MachineProfile};
|
use agent::{collect_inventory_plain, collect_profile, AgentRuntime, MachineProfile};
|
||||||
|
use chat::{ChatRuntime, ChatSession, ChatMessagesResponse, SendMessageResponse};
|
||||||
use chrono::Local;
|
use chrono::Local;
|
||||||
use usb_control::{UsbPolicy, UsbPolicyResult};
|
use usb_control::{UsbPolicy, UsbPolicyResult};
|
||||||
use tauri::{Emitter, Manager, WindowEvent};
|
use tauri::{Emitter, Manager, WindowEvent};
|
||||||
|
|
@ -222,10 +224,76 @@ fn refresh_usb_policy() -> Result<(), String> {
|
||||||
usb_control::refresh_group_policy().map_err(|e| e.to_string())
|
usb_control::refresh_group_policy().map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// COMANDOS DE CHAT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn start_chat_polling(
|
||||||
|
state: tauri::State<ChatRuntime>,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
base_url: String,
|
||||||
|
token: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state.start_polling(base_url, token, app)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn stop_chat_polling(state: tauri::State<ChatRuntime>) -> Result<(), String> {
|
||||||
|
state.stop();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_chat_sessions(state: tauri::State<ChatRuntime>) -> Vec<ChatSession> {
|
||||||
|
state.get_sessions()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn fetch_chat_sessions(base_url: String, token: String) -> Result<Vec<ChatSession>, String> {
|
||||||
|
chat::fetch_sessions(&base_url, &token).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn fetch_chat_messages(
|
||||||
|
base_url: String,
|
||||||
|
token: String,
|
||||||
|
ticket_id: String,
|
||||||
|
since: Option<i64>,
|
||||||
|
) -> Result<ChatMessagesResponse, String> {
|
||||||
|
chat::fetch_messages(&base_url, &token, &ticket_id, since).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn send_chat_message(
|
||||||
|
base_url: String,
|
||||||
|
token: String,
|
||||||
|
ticket_id: String,
|
||||||
|
body: String,
|
||||||
|
) -> Result<SendMessageResponse, String> {
|
||||||
|
chat::send_message(&base_url, &token, &ticket_id, &body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn open_chat_window(app: tauri::AppHandle, ticket_id: String) -> Result<(), String> {
|
||||||
|
chat::open_chat_window(&app, &ticket_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn close_chat_window(app: tauri::AppHandle, ticket_id: String) -> Result<(), String> {
|
||||||
|
chat::close_chat_window(&app, &ticket_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn minimize_chat_window(app: tauri::AppHandle, ticket_id: String) -> Result<(), String> {
|
||||||
|
chat::minimize_chat_window(&app, &ticket_id)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.manage(AgentRuntime::new())
|
.manage(AgentRuntime::new())
|
||||||
|
.manage(ChatRuntime::new())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(StorePluginBuilder::default().build())
|
.plugin(StorePluginBuilder::default().build())
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
|
|
@ -249,13 +317,14 @@ pub fn run() {
|
||||||
setup_raven_autostart();
|
setup_raven_autostart();
|
||||||
setup_tray(&app.handle())?;
|
setup_tray(&app.handle())?;
|
||||||
|
|
||||||
// Tenta iniciar o agente em background se houver credenciais salvas
|
// Tenta iniciar o agente e chat em background se houver credenciais salvas
|
||||||
let app_handle = app.handle().clone();
|
let app_handle = app.handle().clone();
|
||||||
let runtime = app.state::<AgentRuntime>().inner().clone();
|
let agent_runtime = app.state::<AgentRuntime>().inner().clone();
|
||||||
|
let chat_runtime = app.state::<ChatRuntime>().inner().clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
// Aguarda um pouco para o app estabilizar
|
// Aguarda um pouco para o app estabilizar
|
||||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||||
if let Err(e) = try_start_background_agent(&app_handle, runtime).await {
|
if let Err(e) = try_start_background_agent(&app_handle, agent_runtime, chat_runtime).await {
|
||||||
log_warn!("Agente nao iniciado em background: {e}");
|
log_warn!("Agente nao iniciado em background: {e}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -272,7 +341,17 @@ pub fn run() {
|
||||||
ensure_rustdesk_and_emit,
|
ensure_rustdesk_and_emit,
|
||||||
apply_usb_policy,
|
apply_usb_policy,
|
||||||
get_usb_policy,
|
get_usb_policy,
|
||||||
refresh_usb_policy
|
refresh_usb_policy,
|
||||||
|
// Chat commands
|
||||||
|
start_chat_polling,
|
||||||
|
stop_chat_polling,
|
||||||
|
get_chat_sessions,
|
||||||
|
fetch_chat_sessions,
|
||||||
|
fetch_chat_messages,
|
||||||
|
send_chat_message,
|
||||||
|
open_chat_window,
|
||||||
|
close_chat_window,
|
||||||
|
minimize_chat_window
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|
@ -333,9 +412,10 @@ fn setup_raven_autostart() {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn setup_tray(app: &tauri::AppHandle) -> tauri::Result<()> {
|
fn setup_tray(app: &tauri::AppHandle) -> tauri::Result<()> {
|
||||||
let show_item = MenuItemBuilder::with_id("show", "Mostrar").build(app)?;
|
let show_item = MenuItemBuilder::with_id("show", "Mostrar").build(app)?;
|
||||||
|
let chat_item = MenuItemBuilder::with_id("chat", "Abrir Chat").build(app)?;
|
||||||
let quit_item = MenuItemBuilder::with_id("quit", "Sair").build(app)?;
|
let quit_item = MenuItemBuilder::with_id("quit", "Sair").build(app)?;
|
||||||
let menu = MenuBuilder::new(app)
|
let menu = MenuBuilder::new(app)
|
||||||
.items(&[&show_item, &quit_item])
|
.items(&[&show_item, &chat_item, &quit_item])
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
let mut builder = TrayIconBuilder::new()
|
let mut builder = TrayIconBuilder::new()
|
||||||
|
|
@ -348,6 +428,17 @@ fn setup_tray(app: &tauri::AppHandle) -> tauri::Result<()> {
|
||||||
let _ = win.set_focus();
|
let _ = win.set_focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"chat" => {
|
||||||
|
// Abrir janela de chat se houver sessao ativa
|
||||||
|
if let Some(chat_runtime) = tray.app_handle().try_state::<ChatRuntime>() {
|
||||||
|
let sessions = chat_runtime.get_sessions();
|
||||||
|
if let Some(session) = sessions.first() {
|
||||||
|
if let Err(e) = chat::open_chat_window(tray.app_handle(), &session.ticket_id) {
|
||||||
|
log_error!("Falha ao abrir janela de chat: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
"quit" => {
|
"quit" => {
|
||||||
tray.app_handle().exit(0);
|
tray.app_handle().exit(0);
|
||||||
}
|
}
|
||||||
|
|
@ -376,7 +467,8 @@ fn setup_tray(app: &tauri::AppHandle) -> tauri::Result<()> {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
async fn try_start_background_agent(
|
async fn try_start_background_agent(
|
||||||
app: &tauri::AppHandle,
|
app: &tauri::AppHandle,
|
||||||
runtime: AgentRuntime,
|
agent_runtime: AgentRuntime,
|
||||||
|
chat_runtime: ChatRuntime,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
log_info!("Verificando credenciais salvas para iniciar agente...");
|
log_info!("Verificando credenciais salvas para iniciar agente...");
|
||||||
|
|
||||||
|
|
@ -422,7 +514,7 @@ async fn try_start_background_agent(
|
||||||
interval
|
interval
|
||||||
);
|
);
|
||||||
|
|
||||||
runtime
|
agent_runtime
|
||||||
.start_heartbeat(
|
.start_heartbeat(
|
||||||
api_base_url.to_string(),
|
api_base_url.to_string(),
|
||||||
token.to_string(),
|
token.to_string(),
|
||||||
|
|
@ -432,5 +524,17 @@ async fn try_start_background_agent(
|
||||||
.map_err(|e| format!("Falha ao iniciar heartbeat: {e}"))?;
|
.map_err(|e| format!("Falha ao iniciar heartbeat: {e}"))?;
|
||||||
|
|
||||||
log_info!("Agente iniciado com sucesso em background");
|
log_info!("Agente iniciado com sucesso em background");
|
||||||
|
|
||||||
|
// Iniciar chat polling
|
||||||
|
if let Err(e) = chat_runtime.start_polling(
|
||||||
|
api_base_url.to_string(),
|
||||||
|
token.to_string(),
|
||||||
|
app.clone(),
|
||||||
|
) {
|
||||||
|
log_warn!("Falha ao iniciar chat polling: {e}");
|
||||||
|
} else {
|
||||||
|
log_info!("Chat polling iniciado com sucesso");
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
363
apps/desktop/src/chat/ChatWidget.tsx
Normal file
363
apps/desktop/src/chat/ChatWidget.tsx
Normal file
|
|
@ -0,0 +1,363 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
|
import { listen } from "@tauri-apps/api/event"
|
||||||
|
import { Store } from "@tauri-apps/plugin-store"
|
||||||
|
import { Send, X, Minus, Loader2, Headphones } from "lucide-react"
|
||||||
|
import type { ChatMessage, ChatMessagesResponse, SendMessageResponse } from "./types"
|
||||||
|
|
||||||
|
interface ChatWidgetProps {
|
||||||
|
ticketId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatWidget({ ticketId }: ChatWidgetProps) {
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
|
const [inputValue, setInputValue] = useState("")
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [isSending, setIsSending] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [ticketInfo, setTicketInfo] = useState<{ ref: number; subject: string; agentName: string } | null>(null)
|
||||||
|
const [hasSession, setHasSession] = useState(false)
|
||||||
|
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
const lastFetchRef = useRef<number>(0)
|
||||||
|
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
// Scroll para o final quando novas mensagens chegam
|
||||||
|
const scrollToBottom = useCallback(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom()
|
||||||
|
}, [messages, scrollToBottom])
|
||||||
|
|
||||||
|
// Carregar configuracao do store
|
||||||
|
const loadConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const store = await Store.load("machine-agent.json")
|
||||||
|
const token = await store.get<string>("token")
|
||||||
|
const config = await store.get<{ apiBaseUrl: string }>("config")
|
||||||
|
|
||||||
|
if (!token || !config?.apiBaseUrl) {
|
||||||
|
setError("Maquina nao registrada")
|
||||||
|
setIsLoading(false)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token, baseUrl: config.apiBaseUrl }
|
||||||
|
} catch (err) {
|
||||||
|
setError("Erro ao carregar configuracao")
|
||||||
|
setIsLoading(false)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Buscar mensagens
|
||||||
|
const fetchMessages = useCallback(async (baseUrl: string, token: string, since?: number) => {
|
||||||
|
try {
|
||||||
|
const response = await invoke<ChatMessagesResponse>("fetch_chat_messages", {
|
||||||
|
baseUrl,
|
||||||
|
token,
|
||||||
|
ticketId,
|
||||||
|
since: since ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
|
setHasSession(response.hasSession)
|
||||||
|
|
||||||
|
if (response.messages.length > 0) {
|
||||||
|
if (since) {
|
||||||
|
// Adicionar apenas novas mensagens
|
||||||
|
setMessages(prev => {
|
||||||
|
const existingIds = new Set(prev.map(m => m.id))
|
||||||
|
const newMsgs = response.messages.filter(m => !existingIds.has(m.id))
|
||||||
|
return [...prev, ...newMsgs]
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Primeira carga
|
||||||
|
setMessages(response.messages)
|
||||||
|
}
|
||||||
|
lastFetchRef.current = Math.max(...response.messages.map(m => m.createdAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Erro ao buscar mensagens:", err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}, [ticketId])
|
||||||
|
|
||||||
|
// Buscar info da sessao
|
||||||
|
const fetchSessionInfo = useCallback(async (baseUrl: string, token: string) => {
|
||||||
|
try {
|
||||||
|
const sessions = await invoke<Array<{
|
||||||
|
ticketId: string
|
||||||
|
ticketRef: number
|
||||||
|
ticketSubject: string
|
||||||
|
agentName: string
|
||||||
|
}>>("fetch_chat_sessions", { baseUrl, token })
|
||||||
|
|
||||||
|
const session = sessions.find(s => s.ticketId === ticketId)
|
||||||
|
if (session) {
|
||||||
|
setTicketInfo({
|
||||||
|
ref: session.ticketRef,
|
||||||
|
subject: session.ticketSubject,
|
||||||
|
agentName: session.agentName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Erro ao buscar sessao:", err)
|
||||||
|
}
|
||||||
|
}, [ticketId])
|
||||||
|
|
||||||
|
// Inicializacao
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
const config = await loadConfig()
|
||||||
|
if (!config || !mounted) return
|
||||||
|
|
||||||
|
const { baseUrl, token } = config
|
||||||
|
|
||||||
|
// Buscar sessao e mensagens iniciais
|
||||||
|
await Promise.all([
|
||||||
|
fetchSessionInfo(baseUrl, token),
|
||||||
|
fetchMessages(baseUrl, token),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!mounted) return
|
||||||
|
setIsLoading(false)
|
||||||
|
|
||||||
|
// Iniciar polling (2 segundos para maior responsividade)
|
||||||
|
pollIntervalRef.current = setInterval(async () => {
|
||||||
|
await fetchMessages(baseUrl, token, lastFetchRef.current)
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
init()
|
||||||
|
|
||||||
|
// Listener para eventos de nova mensagem do Tauri
|
||||||
|
const unlistenPromise = listen<{ ticketId: string; message: ChatMessage }>(
|
||||||
|
"raven://chat/new-message",
|
||||||
|
(event) => {
|
||||||
|
if (event.payload.ticketId === ticketId) {
|
||||||
|
setMessages(prev => {
|
||||||
|
if (prev.some(m => m.id === event.payload.message.id)) {
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
return [...prev, event.payload.message]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false
|
||||||
|
if (pollIntervalRef.current) {
|
||||||
|
clearInterval(pollIntervalRef.current)
|
||||||
|
}
|
||||||
|
unlistenPromise.then(unlisten => unlisten())
|
||||||
|
}
|
||||||
|
}, [ticketId, loadConfig, fetchMessages, fetchSessionInfo])
|
||||||
|
|
||||||
|
// Enviar mensagem
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!inputValue.trim() || isSending) return
|
||||||
|
|
||||||
|
const messageText = inputValue.trim()
|
||||||
|
setInputValue("")
|
||||||
|
setIsSending(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await loadConfig()
|
||||||
|
if (!config) {
|
||||||
|
setIsSending(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await invoke<SendMessageResponse>("send_chat_message", {
|
||||||
|
baseUrl: config.baseUrl,
|
||||||
|
token: config.token,
|
||||||
|
ticketId,
|
||||||
|
body: messageText,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Adicionar mensagem localmente
|
||||||
|
setMessages(prev => [...prev, {
|
||||||
|
id: response.messageId,
|
||||||
|
body: messageText,
|
||||||
|
authorName: "Voce",
|
||||||
|
isFromMachine: true,
|
||||||
|
createdAt: response.createdAt,
|
||||||
|
attachments: [],
|
||||||
|
}])
|
||||||
|
|
||||||
|
lastFetchRef.current = response.createdAt
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Erro ao enviar mensagem:", err)
|
||||||
|
// Restaurar input em caso de erro
|
||||||
|
setInputValue(messageText)
|
||||||
|
} finally {
|
||||||
|
setIsSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMinimize = () => {
|
||||||
|
invoke("minimize_chat_window", { ticketId })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
invoke("close_chat_window", { ticketId })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col items-center justify-center bg-white">
|
||||||
|
<Loader2 className="size-8 animate-spin text-slate-400" />
|
||||||
|
<p className="mt-2 text-sm text-slate-500">Carregando chat...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col items-center justify-center bg-white p-4">
|
||||||
|
<p className="text-sm text-red-600">{error}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasSession) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col items-center justify-center bg-white p-4">
|
||||||
|
<p className="text-sm text-slate-500">Nenhuma sessao de chat ativa</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col bg-white">
|
||||||
|
{/* Header - arrastavel */}
|
||||||
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-full bg-black text-white">
|
||||||
|
<Headphones className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900">
|
||||||
|
{ticketInfo?.agentName ?? "Suporte"}
|
||||||
|
</p>
|
||||||
|
{ticketInfo && (
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Chamado #{ticketInfo.ref}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={handleMinimize}
|
||||||
|
className="rounded p-1.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
|
||||||
|
>
|
||||||
|
<Minus className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="rounded p-1.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mensagens */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Nenhuma mensagem ainda
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-400">
|
||||||
|
O agente iniciara a conversa em breve
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={`flex ${msg.isFromMachine ? "justify-end" : "justify-start"}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`max-w-[80%] rounded-2xl px-4 py-2 ${
|
||||||
|
msg.isFromMachine
|
||||||
|
? "bg-black text-white"
|
||||||
|
: "bg-slate-100 text-slate-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{!msg.isFromMachine && (
|
||||||
|
<p className="mb-1 text-xs font-medium text-slate-500">
|
||||||
|
{msg.authorName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="whitespace-pre-wrap text-sm">{msg.body}</p>
|
||||||
|
<p
|
||||||
|
className={`mt-1 text-right text-xs ${
|
||||||
|
msg.isFromMachine ? "text-white/60" : "text-slate-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatTime(msg.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="border-t border-slate-200 p-3">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<textarea
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Digite sua mensagem..."
|
||||||
|
className="max-h-24 min-h-[40px] flex-1 resize-none rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-1 focus:ring-black"
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!inputValue.trim() || isSending}
|
||||||
|
className="flex size-10 items-center justify-center rounded-lg bg-black text-white transition hover:bg-black/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="size-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(timestamp: number): string {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
return date.toLocaleTimeString("pt-BR", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
}
|
||||||
20
apps/desktop/src/chat/index.tsx
Normal file
20
apps/desktop/src/chat/index.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { ChatWidget } from "./ChatWidget"
|
||||||
|
|
||||||
|
export function ChatApp() {
|
||||||
|
// Obter ticketId da URL
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const ticketId = params.get("ticketId")
|
||||||
|
|
||||||
|
if (!ticketId) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col items-center justify-center bg-white p-4">
|
||||||
|
<p className="text-sm text-red-600">Erro: ticketId nao fornecido</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ChatWidget ticketId={ticketId} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ChatWidget }
|
||||||
|
export * from "./types"
|
||||||
45
apps/desktop/src/chat/types.ts
Normal file
45
apps/desktop/src/chat/types.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
// Tipos para o sistema de chat
|
||||||
|
|
||||||
|
export interface ChatSession {
|
||||||
|
sessionId: string
|
||||||
|
ticketId: string
|
||||||
|
ticketRef: number
|
||||||
|
ticketSubject: string
|
||||||
|
agentName: string
|
||||||
|
agentEmail?: string
|
||||||
|
agentAvatarUrl?: string
|
||||||
|
unreadCount: number
|
||||||
|
lastActivityAt: number
|
||||||
|
startedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string
|
||||||
|
body: string
|
||||||
|
authorName: string
|
||||||
|
authorAvatarUrl?: string
|
||||||
|
isFromMachine: boolean
|
||||||
|
createdAt: number
|
||||||
|
attachments: ChatAttachment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatAttachment {
|
||||||
|
storageId: string
|
||||||
|
name: string
|
||||||
|
size?: number
|
||||||
|
type?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessagesResponse {
|
||||||
|
messages: ChatMessage[]
|
||||||
|
hasSession: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendMessageResponse {
|
||||||
|
messageId: string
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionStartedEvent {
|
||||||
|
session: ChatSession
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import { appLocalDataDir, join } from "@tauri-apps/api/path"
|
||||||
import { ExternalLink, Eye, EyeOff, Loader2, RefreshCw } from "lucide-react"
|
import { ExternalLink, Eye, EyeOff, Loader2, RefreshCw } from "lucide-react"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs"
|
||||||
import { cn } from "./lib/utils"
|
import { cn } from "./lib/utils"
|
||||||
|
import { ChatApp } from "./chat"
|
||||||
import { DeactivationScreen } from "./components/DeactivationScreen"
|
import { DeactivationScreen } from "./components/DeactivationScreen"
|
||||||
|
|
||||||
type MachineOs = {
|
type MachineOs = {
|
||||||
|
|
@ -1642,5 +1643,18 @@ function StatusBadge({ status, className }: { status: string | null; className?:
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Roteamento simples baseado no path
|
||||||
|
function RootApp() {
|
||||||
|
const path = window.location.pathname
|
||||||
|
|
||||||
|
// Rota /chat - janela de chat flutuante
|
||||||
|
if (path === "/chat" || path.startsWith("/chat?")) {
|
||||||
|
return <ChatApp />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rota padrao - aplicacao principal
|
||||||
|
return <App />
|
||||||
|
}
|
||||||
|
|
||||||
const root = document.getElementById("root") || (() => { const el = document.createElement("div"); el.id = "root"; document.body.appendChild(el); return el })()
|
const root = document.getElementById("root") || (() => { const el = document.createElement("div"); el.id = "root"; document.body.appendChild(el); return el })()
|
||||||
createRoot(root).render(<App />)
|
createRoot(root).render(<RootApp />)
|
||||||
|
|
|
||||||
512
convex/liveChat.ts
Normal file
512
convex/liveChat.ts
Normal file
|
|
@ -0,0 +1,512 @@
|
||||||
|
import { v } from "convex/values"
|
||||||
|
import { mutation, query, type MutationCtx, type QueryCtx } from "./_generated/server"
|
||||||
|
import { ConvexError } from "convex/values"
|
||||||
|
import type { Doc, Id } from "./_generated/dataModel"
|
||||||
|
import { sha256 } from "@noble/hashes/sha256"
|
||||||
|
import { bytesToHex as toHex } from "@noble/hashes/utils"
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// HELPERS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
function hashToken(token: string) {
|
||||||
|
return toHex(sha256(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateMachineToken(
|
||||||
|
ctx: MutationCtx | QueryCtx,
|
||||||
|
machineToken: string
|
||||||
|
): Promise<{ machine: Doc<"machines">; tenantId: string }> {
|
||||||
|
const tokenHash = hashToken(machineToken)
|
||||||
|
|
||||||
|
const tokenRecord = await ctx.db
|
||||||
|
.query("machineTokens")
|
||||||
|
.withIndex("by_token_hash", (q) => q.eq("tokenHash", tokenHash))
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (!tokenRecord) {
|
||||||
|
throw new ConvexError("Token de maquina invalido")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenRecord.revoked) {
|
||||||
|
throw new ConvexError("Token de maquina revogado")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenRecord.expiresAt < Date.now()) {
|
||||||
|
throw new ConvexError("Token de maquina expirado")
|
||||||
|
}
|
||||||
|
|
||||||
|
const machine = await ctx.db.get(tokenRecord.machineId)
|
||||||
|
if (!machine) {
|
||||||
|
throw new ConvexError("Maquina nao encontrada")
|
||||||
|
}
|
||||||
|
|
||||||
|
return { machine, tenantId: tokenRecord.tenantId }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MUTATIONS (Agente)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Agente inicia sessao de chat com cliente
|
||||||
|
export const startSession = mutation({
|
||||||
|
args: {
|
||||||
|
ticketId: v.id("tickets"),
|
||||||
|
actorId: v.id("users"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { ticketId, actorId }) => {
|
||||||
|
const ticket = await ctx.db.get(ticketId)
|
||||||
|
if (!ticket) {
|
||||||
|
throw new ConvexError("Ticket nao encontrado")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ticket.machineId) {
|
||||||
|
throw new ConvexError("Este ticket nao esta vinculado a uma maquina")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se agente tem permissao
|
||||||
|
const agent = await ctx.db.get(actorId)
|
||||||
|
if (!agent || agent.tenantId !== ticket.tenantId) {
|
||||||
|
throw new ConvexError("Acesso negado")
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = agent.role?.toUpperCase() ?? ""
|
||||||
|
if (!["ADMIN", "MANAGER", "AGENT"].includes(role)) {
|
||||||
|
throw new ConvexError("Apenas agentes podem iniciar chat ao vivo")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se maquina esta online (heartbeat nos ultimos 5 minutos)
|
||||||
|
const machine = await ctx.db.get(ticket.machineId)
|
||||||
|
if (!machine) {
|
||||||
|
throw new ConvexError("Maquina nao encontrada")
|
||||||
|
}
|
||||||
|
|
||||||
|
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000
|
||||||
|
if (!machine.lastHeartbeatAt || machine.lastHeartbeatAt < fiveMinutesAgo) {
|
||||||
|
throw new ConvexError("Maquina offline. A maquina precisa estar online para iniciar o chat.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se ja existe sessao ativa para este ticket
|
||||||
|
const existingSession = await ctx.db
|
||||||
|
.query("liveChatSessions")
|
||||||
|
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
|
||||||
|
.filter((q) => q.eq(q.field("status"), "ACTIVE"))
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (existingSession) {
|
||||||
|
// Retornar sessao existente
|
||||||
|
return { sessionId: existingSession._id, isNew: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
// Criar nova sessao
|
||||||
|
const sessionId = await ctx.db.insert("liveChatSessions", {
|
||||||
|
tenantId: ticket.tenantId,
|
||||||
|
ticketId,
|
||||||
|
machineId: ticket.machineId,
|
||||||
|
agentId: actorId,
|
||||||
|
agentSnapshot: {
|
||||||
|
name: agent.name,
|
||||||
|
email: agent.email,
|
||||||
|
avatarUrl: agent.avatarUrl,
|
||||||
|
},
|
||||||
|
status: "ACTIVE",
|
||||||
|
startedAt: now,
|
||||||
|
lastActivityAt: now,
|
||||||
|
unreadByMachine: 0,
|
||||||
|
unreadByAgent: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Habilitar chat no ticket se nao estiver
|
||||||
|
if (!ticket.chatEnabled) {
|
||||||
|
await ctx.db.patch(ticketId, { chatEnabled: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sessionId, isNew: true }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Agente encerra sessao de chat
|
||||||
|
export const endSession = mutation({
|
||||||
|
args: {
|
||||||
|
sessionId: v.id("liveChatSessions"),
|
||||||
|
actorId: v.id("users"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { sessionId, actorId }) => {
|
||||||
|
const session = await ctx.db.get(sessionId)
|
||||||
|
if (!session) {
|
||||||
|
throw new ConvexError("Sessao nao encontrada")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar permissao
|
||||||
|
const agent = await ctx.db.get(actorId)
|
||||||
|
if (!agent || agent.tenantId !== session.tenantId) {
|
||||||
|
throw new ConvexError("Acesso negado")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.status !== "ACTIVE") {
|
||||||
|
throw new ConvexError("Sessao ja encerrada")
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(sessionId, {
|
||||||
|
status: "ENDED",
|
||||||
|
endedAt: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MUTATIONS (Maquina/Cliente)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Cliente envia mensagem via machineToken
|
||||||
|
export const postMachineMessage = mutation({
|
||||||
|
args: {
|
||||||
|
machineToken: v.string(),
|
||||||
|
ticketId: v.id("tickets"),
|
||||||
|
body: v.string(),
|
||||||
|
attachments: v.optional(
|
||||||
|
v.array(
|
||||||
|
v.object({
|
||||||
|
storageId: v.id("_storage"),
|
||||||
|
name: v.string(),
|
||||||
|
size: v.optional(v.number()),
|
||||||
|
type: v.optional(v.string()),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const { machine, tenantId } = await validateMachineToken(ctx, args.machineToken)
|
||||||
|
|
||||||
|
const ticket = await ctx.db.get(args.ticketId)
|
||||||
|
if (!ticket || ticket.tenantId !== tenantId) {
|
||||||
|
throw new ConvexError("Ticket nao encontrado")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.machineId?.toString() !== machine._id.toString()) {
|
||||||
|
throw new ConvexError("Esta maquina nao esta vinculada a este ticket")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se existe sessao ativa
|
||||||
|
const session = await ctx.db
|
||||||
|
.query("liveChatSessions")
|
||||||
|
.withIndex("by_ticket", (q) => q.eq("ticketId", args.ticketId))
|
||||||
|
.filter((q) => q.eq(q.field("status"), "ACTIVE"))
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new ConvexError("Nenhuma sessao de chat ativa para este ticket")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obter usuario vinculado a maquina (ou usar nome do hostname)
|
||||||
|
let authorId: Id<"users"> | null = machine.assignedUserId ?? null
|
||||||
|
let authorSnapshot = {
|
||||||
|
name: machine.assignedUserName ?? machine.hostname,
|
||||||
|
email: machine.assignedUserEmail ?? undefined,
|
||||||
|
avatarUrl: undefined as string | undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se nao tem usuario vinculado, buscar por linkedUserIds
|
||||||
|
if (!authorId && machine.linkedUserIds?.length) {
|
||||||
|
authorId = machine.linkedUserIds[0]
|
||||||
|
const linkedUser = await ctx.db.get(authorId)
|
||||||
|
if (linkedUser) {
|
||||||
|
authorSnapshot = {
|
||||||
|
name: linkedUser.name,
|
||||||
|
email: linkedUser.email,
|
||||||
|
avatarUrl: linkedUser.avatarUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se ainda nao tem authorId, criar mensagem sem autor (usando ID do agente temporariamente)
|
||||||
|
if (!authorId) {
|
||||||
|
// Usar o primeiro user do tenant como fallback (requester do ticket)
|
||||||
|
authorId = ticket.requesterId
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
// Limitar tamanho do body
|
||||||
|
if (args.body.length > 4000) {
|
||||||
|
throw new ConvexError("Mensagem muito longa (maximo 4000 caracteres)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inserir mensagem
|
||||||
|
const messageId = await ctx.db.insert("ticketChatMessages", {
|
||||||
|
ticketId: args.ticketId,
|
||||||
|
authorId,
|
||||||
|
authorSnapshot,
|
||||||
|
body: args.body.trim(),
|
||||||
|
attachments: args.attachments,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
tenantId,
|
||||||
|
companyId: ticket.companyId,
|
||||||
|
readBy: [{ userId: authorId, readAt: now }], // Autor ja leu
|
||||||
|
})
|
||||||
|
|
||||||
|
// Atualizar sessao
|
||||||
|
await ctx.db.patch(session._id, {
|
||||||
|
lastActivityAt: now,
|
||||||
|
unreadByAgent: (session.unreadByAgent ?? 0) + 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { messageId, createdAt: now }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cliente marca mensagens como lidas
|
||||||
|
export const markMachineMessagesRead = mutation({
|
||||||
|
args: {
|
||||||
|
machineToken: v.string(),
|
||||||
|
ticketId: v.id("tickets"),
|
||||||
|
messageIds: v.array(v.id("ticketChatMessages")),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const { machine, tenantId } = await validateMachineToken(ctx, args.machineToken)
|
||||||
|
|
||||||
|
const ticket = await ctx.db.get(args.ticketId)
|
||||||
|
if (!ticket || ticket.tenantId !== tenantId) {
|
||||||
|
throw new ConvexError("Ticket nao encontrado")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.machineId?.toString() !== machine._id.toString()) {
|
||||||
|
throw new ConvexError("Esta maquina nao esta vinculada a este ticket")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obter userId para marcar leitura
|
||||||
|
const userId = machine.assignedUserId ?? machine.linkedUserIds?.[0] ?? ticket.requesterId
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
for (const messageId of args.messageIds) {
|
||||||
|
const message = await ctx.db.get(messageId)
|
||||||
|
if (!message || message.ticketId.toString() !== args.ticketId.toString()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const readBy = message.readBy ?? []
|
||||||
|
const alreadyRead = readBy.some((r) => r.userId.toString() === userId.toString())
|
||||||
|
if (!alreadyRead) {
|
||||||
|
await ctx.db.patch(messageId, {
|
||||||
|
readBy: [...readBy, { userId, readAt: now }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zerar contador de nao lidas pela maquina
|
||||||
|
const session = await ctx.db
|
||||||
|
.query("liveChatSessions")
|
||||||
|
.withIndex("by_ticket", (q) => q.eq("ticketId", args.ticketId))
|
||||||
|
.filter((q) => q.eq(q.field("status"), "ACTIVE"))
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
await ctx.db.patch(session._id, { unreadByMachine: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// QUERIES (Maquina/Cliente)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Listar sessoes ativas para uma maquina
|
||||||
|
export const listMachineSessions = query({
|
||||||
|
args: {
|
||||||
|
machineToken: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { machineToken }) => {
|
||||||
|
const { machine, tenantId } = await validateMachineToken(ctx, machineToken)
|
||||||
|
|
||||||
|
const sessions = await ctx.db
|
||||||
|
.query("liveChatSessions")
|
||||||
|
.withIndex("by_machine_status", (q) =>
|
||||||
|
q.eq("machineId", machine._id).eq("status", "ACTIVE")
|
||||||
|
)
|
||||||
|
.collect()
|
||||||
|
|
||||||
|
const result = await Promise.all(
|
||||||
|
sessions.map(async (session) => {
|
||||||
|
const ticket = await ctx.db.get(session.ticketId)
|
||||||
|
return {
|
||||||
|
sessionId: session._id,
|
||||||
|
ticketId: session.ticketId,
|
||||||
|
ticketRef: ticket?.reference ?? 0,
|
||||||
|
ticketSubject: ticket?.subject ?? "",
|
||||||
|
agentName: session.agentSnapshot?.name ?? "Agente",
|
||||||
|
agentEmail: session.agentSnapshot?.email,
|
||||||
|
agentAvatarUrl: session.agentSnapshot?.avatarUrl,
|
||||||
|
unreadCount: session.unreadByMachine ?? 0,
|
||||||
|
lastActivityAt: session.lastActivityAt,
|
||||||
|
startedAt: session.startedAt,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Listar mensagens de um chat para maquina
|
||||||
|
export const listMachineMessages = query({
|
||||||
|
args: {
|
||||||
|
machineToken: v.string(),
|
||||||
|
ticketId: v.id("tickets"),
|
||||||
|
since: v.optional(v.number()),
|
||||||
|
limit: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const { machine, tenantId } = await validateMachineToken(ctx, args.machineToken)
|
||||||
|
|
||||||
|
const ticket = await ctx.db.get(args.ticketId)
|
||||||
|
if (!ticket || ticket.tenantId !== tenantId) {
|
||||||
|
throw new ConvexError("Ticket nao encontrado")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.machineId?.toString() !== machine._id.toString()) {
|
||||||
|
throw new ConvexError("Esta maquina nao esta vinculada a este ticket")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar sessao ativa
|
||||||
|
const session = await ctx.db
|
||||||
|
.query("liveChatSessions")
|
||||||
|
.withIndex("by_ticket", (q) => q.eq("ticketId", args.ticketId))
|
||||||
|
.filter((q) => q.eq(q.field("status"), "ACTIVE"))
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return { messages: [], hasSession: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = ctx.db
|
||||||
|
.query("ticketChatMessages")
|
||||||
|
.withIndex("by_ticket_created", (q) => q.eq("ticketId", args.ticketId))
|
||||||
|
|
||||||
|
const allMessages = await query.collect()
|
||||||
|
|
||||||
|
// Filtrar por since se fornecido
|
||||||
|
let messages = args.since
|
||||||
|
? allMessages.filter((m) => m.createdAt > args.since!)
|
||||||
|
: allMessages
|
||||||
|
|
||||||
|
// Aplicar limite
|
||||||
|
const limit = args.limit ?? 50
|
||||||
|
messages = messages.slice(-limit)
|
||||||
|
|
||||||
|
// Obter userId da maquina para verificar se eh autor
|
||||||
|
const machineUserId = machine.assignedUserId ?? machine.linkedUserIds?.[0]
|
||||||
|
|
||||||
|
const result = messages.map((msg) => {
|
||||||
|
const isFromMachine = machineUserId
|
||||||
|
? msg.authorId.toString() === machineUserId.toString()
|
||||||
|
: false
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: msg._id,
|
||||||
|
body: msg.body,
|
||||||
|
authorName: msg.authorSnapshot?.name ?? "Usuario",
|
||||||
|
authorAvatarUrl: msg.authorSnapshot?.avatarUrl,
|
||||||
|
isFromMachine,
|
||||||
|
createdAt: msg.createdAt,
|
||||||
|
attachments: msg.attachments ?? [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { messages: result, hasSession: true }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Polling leve para verificar updates
|
||||||
|
export const checkMachineUpdates = query({
|
||||||
|
args: {
|
||||||
|
machineToken: v.string(),
|
||||||
|
lastCheckedAt: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const { machine } = await validateMachineToken(ctx, args.machineToken)
|
||||||
|
|
||||||
|
const sessions = await ctx.db
|
||||||
|
.query("liveChatSessions")
|
||||||
|
.withIndex("by_machine_status", (q) =>
|
||||||
|
q.eq("machineId", machine._id).eq("status", "ACTIVE")
|
||||||
|
)
|
||||||
|
.collect()
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return {
|
||||||
|
hasActiveSessions: false,
|
||||||
|
sessions: [],
|
||||||
|
totalUnread: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionSummaries = sessions.map((s) => ({
|
||||||
|
ticketId: s.ticketId,
|
||||||
|
unreadCount: s.unreadByMachine ?? 0,
|
||||||
|
lastActivityAt: s.lastActivityAt,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const totalUnread = sessionSummaries.reduce((sum, s) => sum + s.unreadCount, 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasActiveSessions: true,
|
||||||
|
sessions: sessionSummaries,
|
||||||
|
totalUnread,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// QUERIES (Agente)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Listar sessao ativa de um ticket
|
||||||
|
export const getTicketSession = query({
|
||||||
|
args: {
|
||||||
|
ticketId: v.id("tickets"),
|
||||||
|
viewerId: v.id("users"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { ticketId, viewerId }) => {
|
||||||
|
const ticket = await ctx.db.get(ticketId)
|
||||||
|
if (!ticket) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewer = await ctx.db.get(viewerId)
|
||||||
|
if (!viewer || viewer.tenantId !== ticket.tenantId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await ctx.db
|
||||||
|
.query("liveChatSessions")
|
||||||
|
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
|
||||||
|
.filter((q) => q.eq(q.field("status"), "ACTIVE"))
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se maquina esta online
|
||||||
|
const machine = ticket.machineId ? await ctx.db.get(ticket.machineId) : null
|
||||||
|
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000
|
||||||
|
const machineOnline = machine?.lastHeartbeatAt && machine.lastHeartbeatAt > fiveMinutesAgo
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: session._id,
|
||||||
|
agentId: session.agentId,
|
||||||
|
agentName: session.agentSnapshot?.name,
|
||||||
|
startedAt: session.startedAt,
|
||||||
|
lastActivityAt: session.lastActivityAt,
|
||||||
|
unreadByAgent: session.unreadByAgent ?? 0,
|
||||||
|
machineOnline: Boolean(machineOnline),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -1002,6 +1002,13 @@ export const listByTenant = query({
|
||||||
})
|
})
|
||||||
).then((arr) => arr.filter(Boolean) as Array<{ id: string; email: string; name: string }>)
|
).then((arr) => arr.filter(Boolean) as Array<{ id: string; email: string; name: string }>)
|
||||||
|
|
||||||
|
// ticket count
|
||||||
|
const ticketCount = await ctx.db
|
||||||
|
.query("tickets")
|
||||||
|
.withIndex("by_tenant_machine", (q) => q.eq("tenantId", tenantId).eq("machineId", machine._id))
|
||||||
|
.collect()
|
||||||
|
.then((tickets) => tickets.length)
|
||||||
|
|
||||||
const companyFromId = machine.companyId ? companyById.get(machine.companyId) ?? null : null
|
const companyFromId = machine.companyId ? companyById.get(machine.companyId) ?? null : null
|
||||||
const companyFromSlug = machine.companySlug ? companyBySlug.get(machine.companySlug) ?? null : null
|
const companyFromSlug = machine.companySlug ? companyBySlug.get(machine.companySlug) ?? null : null
|
||||||
const resolvedCompany = companyFromId ?? companyFromSlug
|
const resolvedCompany = companyFromId ?? companyFromSlug
|
||||||
|
|
@ -1054,6 +1061,7 @@ export const listByTenant = query({
|
||||||
usbPolicy: machine.usbPolicy ?? null,
|
usbPolicy: machine.usbPolicy ?? null,
|
||||||
usbPolicyStatus: machine.usbPolicyStatus ?? null,
|
usbPolicyStatus: machine.usbPolicyStatus ?? null,
|
||||||
usbPolicyError: machine.usbPolicyError ?? null,
|
usbPolicyError: machine.usbPolicyError ?? null,
|
||||||
|
ticketCount,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -410,6 +410,31 @@ export default defineSchema({
|
||||||
.index("by_ticket_created", ["ticketId", "createdAt"])
|
.index("by_ticket_created", ["ticketId", "createdAt"])
|
||||||
.index("by_tenant_created", ["tenantId", "createdAt"]),
|
.index("by_tenant_created", ["tenantId", "createdAt"]),
|
||||||
|
|
||||||
|
// Sessoes de chat ao vivo entre agente (dashboard) e cliente (Raven desktop)
|
||||||
|
liveChatSessions: defineTable({
|
||||||
|
tenantId: v.string(),
|
||||||
|
ticketId: v.id("tickets"),
|
||||||
|
machineId: v.id("machines"),
|
||||||
|
agentId: v.id("users"),
|
||||||
|
agentSnapshot: v.optional(
|
||||||
|
v.object({
|
||||||
|
name: v.string(),
|
||||||
|
email: v.optional(v.string()),
|
||||||
|
avatarUrl: v.optional(v.string()),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
status: v.string(), // ACTIVE | ENDED
|
||||||
|
startedAt: v.number(),
|
||||||
|
endedAt: v.optional(v.number()),
|
||||||
|
lastActivityAt: v.number(),
|
||||||
|
unreadByMachine: v.optional(v.number()),
|
||||||
|
unreadByAgent: v.optional(v.number()),
|
||||||
|
})
|
||||||
|
.index("by_ticket", ["ticketId"])
|
||||||
|
.index("by_machine_status", ["machineId", "status"])
|
||||||
|
.index("by_tenant_machine", ["tenantId", "machineId"])
|
||||||
|
.index("by_tenant_status", ["tenantId", "status"]),
|
||||||
|
|
||||||
commentTemplates: defineTable({
|
commentTemplates: defineTable({
|
||||||
tenantId: v.string(),
|
tenantId: v.string(),
|
||||||
kind: v.optional(v.string()),
|
kind: v.optional(v.string()),
|
||||||
|
|
|
||||||
|
|
@ -2956,12 +2956,59 @@ export const listChatMessages = query({
|
||||||
.withIndex("by_ticket_created", (q) => q.eq("ticketId", ticketId))
|
.withIndex("by_ticket_created", (q) => q.eq("ticketId", ticketId))
|
||||||
.collect()
|
.collect()
|
||||||
|
|
||||||
|
// Verificar maquina e sessao de chat ao vivo
|
||||||
|
let liveChat: {
|
||||||
|
hasMachine: boolean
|
||||||
|
machineOnline: boolean
|
||||||
|
machineHostname: string | null
|
||||||
|
activeSession: {
|
||||||
|
sessionId: Id<"liveChatSessions">
|
||||||
|
agentId: Id<"users">
|
||||||
|
agentName: string | null
|
||||||
|
startedAt: number
|
||||||
|
unreadByAgent: number
|
||||||
|
} | null
|
||||||
|
} = {
|
||||||
|
hasMachine: false,
|
||||||
|
machineOnline: false,
|
||||||
|
machineHostname: null,
|
||||||
|
activeSession: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticketDoc.machineId) {
|
||||||
|
const machine = await ctx.db.get(ticketDoc.machineId)
|
||||||
|
if (machine) {
|
||||||
|
const fiveMinutesAgo = now - 5 * 60 * 1000
|
||||||
|
liveChat.hasMachine = true
|
||||||
|
liveChat.machineOnline = Boolean(machine.lastHeartbeatAt && machine.lastHeartbeatAt > fiveMinutesAgo)
|
||||||
|
liveChat.machineHostname = machine.hostname
|
||||||
|
|
||||||
|
// Verificar sessao ativa
|
||||||
|
const activeSession = await ctx.db
|
||||||
|
.query("liveChatSessions")
|
||||||
|
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
|
||||||
|
.filter((q) => q.eq(q.field("status"), "ACTIVE"))
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (activeSession) {
|
||||||
|
liveChat.activeSession = {
|
||||||
|
sessionId: activeSession._id,
|
||||||
|
agentId: activeSession.agentId,
|
||||||
|
agentName: activeSession.agentSnapshot?.name ?? null,
|
||||||
|
startedAt: activeSession.startedAt,
|
||||||
|
unreadByAgent: activeSession.unreadByAgent ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ticketId: String(ticketId),
|
ticketId: String(ticketId),
|
||||||
chatEnabled,
|
chatEnabled,
|
||||||
status,
|
status,
|
||||||
canPost,
|
canPost,
|
||||||
reopenDeadline: ticketDoc.reopenDeadline ?? null,
|
reopenDeadline: ticketDoc.reopenDeadline ?? null,
|
||||||
|
liveChat,
|
||||||
messages: messages
|
messages: messages
|
||||||
.sort((a, b) => a.createdAt - b.createdAt)
|
.sort((a, b) => a.createdAt - b.createdAt)
|
||||||
.map((message) => ({
|
.map((message) => ({
|
||||||
|
|
|
||||||
126
src/app/api/machines/chat/messages/route.ts
Normal file
126
src/app/api/machines/chat/messages/route.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
|
||||||
|
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
|
||||||
|
|
||||||
|
const getMessagesSchema = z.object({
|
||||||
|
machineToken: z.string().min(1),
|
||||||
|
ticketId: z.string().min(1),
|
||||||
|
since: z.number().optional(),
|
||||||
|
limit: z.number().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const postMessageSchema = z.object({
|
||||||
|
machineToken: z.string().min(1),
|
||||||
|
ticketId: z.string().min(1),
|
||||||
|
body: z.string().min(1).max(4000),
|
||||||
|
attachments: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
storageId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
size: z.number().optional(),
|
||||||
|
type: z.string().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const CORS_METHODS = "POST, OPTIONS"
|
||||||
|
|
||||||
|
export async function OPTIONS(request: Request) {
|
||||||
|
return createCorsPreflight(request.headers.get("origin"), CORS_METHODS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/machines/chat/messages
|
||||||
|
// action=list: Lista mensagens de um chat
|
||||||
|
// action=send: Envia nova mensagem
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const origin = request.headers.get("origin")
|
||||||
|
|
||||||
|
let client
|
||||||
|
try {
|
||||||
|
client = createConvexClient()
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ConvexConfigurationError) {
|
||||||
|
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw
|
||||||
|
try {
|
||||||
|
raw = await request.json()
|
||||||
|
} catch {
|
||||||
|
return jsonWithCors({ error: "JSON invalido" }, 400, origin, CORS_METHODS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = raw.action ?? "list"
|
||||||
|
|
||||||
|
if (action === "list") {
|
||||||
|
let payload
|
||||||
|
try {
|
||||||
|
payload = getMessagesSchema.parse(raw)
|
||||||
|
} catch (error) {
|
||||||
|
return jsonWithCors(
|
||||||
|
{ error: "Payload invalido", details: error instanceof Error ? error.message : String(error) },
|
||||||
|
400,
|
||||||
|
origin,
|
||||||
|
CORS_METHODS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.query(api.liveChat.listMachineMessages, {
|
||||||
|
machineToken: payload.machineToken,
|
||||||
|
ticketId: payload.ticketId as Id<"tickets">,
|
||||||
|
since: payload.since,
|
||||||
|
limit: payload.limit,
|
||||||
|
})
|
||||||
|
return jsonWithCors(result, 200, origin, CORS_METHODS)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[machines.chat.messages] Falha ao listar mensagens", error)
|
||||||
|
const details = error instanceof Error ? error.message : String(error)
|
||||||
|
return jsonWithCors({ error: "Falha ao listar mensagens", details }, 500, origin, CORS_METHODS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "send") {
|
||||||
|
let payload
|
||||||
|
try {
|
||||||
|
payload = postMessageSchema.parse(raw)
|
||||||
|
} catch (error) {
|
||||||
|
return jsonWithCors(
|
||||||
|
{ error: "Payload invalido", details: error instanceof Error ? error.message : String(error) },
|
||||||
|
400,
|
||||||
|
origin,
|
||||||
|
CORS_METHODS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.mutation(api.liveChat.postMachineMessage, {
|
||||||
|
machineToken: payload.machineToken,
|
||||||
|
ticketId: payload.ticketId as Id<"tickets">,
|
||||||
|
body: payload.body,
|
||||||
|
attachments: payload.attachments as
|
||||||
|
| Array<{
|
||||||
|
storageId: Id<"_storage">
|
||||||
|
name: string
|
||||||
|
size?: number
|
||||||
|
type?: string
|
||||||
|
}>
|
||||||
|
| undefined,
|
||||||
|
})
|
||||||
|
return jsonWithCors(result, 200, origin, CORS_METHODS)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[machines.chat.messages] Falha ao enviar mensagem", error)
|
||||||
|
const details = error instanceof Error ? error.message : String(error)
|
||||||
|
return jsonWithCors({ error: "Falha ao enviar mensagem", details }, 500, origin, CORS_METHODS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonWithCors({ error: "Acao invalida" }, 400, origin, CORS_METHODS)
|
||||||
|
}
|
||||||
57
src/app/api/machines/chat/poll/route.ts
Normal file
57
src/app/api/machines/chat/poll/route.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
|
||||||
|
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
|
||||||
|
|
||||||
|
const pollSchema = z.object({
|
||||||
|
machineToken: z.string().min(1),
|
||||||
|
lastCheckedAt: z.number().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const CORS_METHODS = "POST, OPTIONS"
|
||||||
|
|
||||||
|
export async function OPTIONS(request: Request) {
|
||||||
|
return createCorsPreflight(request.headers.get("origin"), CORS_METHODS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/machines/chat/poll
|
||||||
|
// Endpoint leve para polling de atualizacoes de chat
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const origin = request.headers.get("origin")
|
||||||
|
|
||||||
|
let client
|
||||||
|
try {
|
||||||
|
client = createConvexClient()
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ConvexConfigurationError) {
|
||||||
|
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload
|
||||||
|
try {
|
||||||
|
const raw = await request.json()
|
||||||
|
payload = pollSchema.parse(raw)
|
||||||
|
} catch (error) {
|
||||||
|
return jsonWithCors(
|
||||||
|
{ error: "Payload invalido", details: error instanceof Error ? error.message : String(error) },
|
||||||
|
400,
|
||||||
|
origin,
|
||||||
|
CORS_METHODS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.query(api.liveChat.checkMachineUpdates, {
|
||||||
|
machineToken: payload.machineToken,
|
||||||
|
lastCheckedAt: payload.lastCheckedAt,
|
||||||
|
})
|
||||||
|
return jsonWithCors(result, 200, origin, CORS_METHODS)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[machines.chat.poll] Falha ao verificar atualizacoes", error)
|
||||||
|
const details = error instanceof Error ? error.message : String(error)
|
||||||
|
return jsonWithCors({ error: "Falha ao verificar atualizacoes", details }, 500, origin, CORS_METHODS)
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/app/api/machines/chat/sessions/route.ts
Normal file
55
src/app/api/machines/chat/sessions/route.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
|
||||||
|
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
|
||||||
|
|
||||||
|
const sessionsSchema = z.object({
|
||||||
|
machineToken: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
const CORS_METHODS = "POST, OPTIONS"
|
||||||
|
|
||||||
|
export async function OPTIONS(request: Request) {
|
||||||
|
return createCorsPreflight(request.headers.get("origin"), CORS_METHODS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/machines/chat/sessions
|
||||||
|
// Lista sessoes de chat ativas para a maquina
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const origin = request.headers.get("origin")
|
||||||
|
|
||||||
|
let client
|
||||||
|
try {
|
||||||
|
client = createConvexClient()
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ConvexConfigurationError) {
|
||||||
|
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload
|
||||||
|
try {
|
||||||
|
const raw = await request.json()
|
||||||
|
payload = sessionsSchema.parse(raw)
|
||||||
|
} catch (error) {
|
||||||
|
return jsonWithCors(
|
||||||
|
{ error: "Payload invalido", details: error instanceof Error ? error.message : String(error) },
|
||||||
|
400,
|
||||||
|
origin,
|
||||||
|
CORS_METHODS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessions = await client.query(api.liveChat.listMachineSessions, {
|
||||||
|
machineToken: payload.machineToken,
|
||||||
|
})
|
||||||
|
return jsonWithCors({ sessions }, 200, origin, CORS_METHODS)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[machines.chat.sessions] Falha ao listar sessoes", error)
|
||||||
|
const details = error instanceof Error ? error.message : String(error)
|
||||||
|
return jsonWithCors({ error: "Falha ao listar sessoes", details }, 500, origin, CORS_METHODS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ import { cn } from "@/lib/utils"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { formatDistanceToNowStrict } from "date-fns"
|
import { formatDistanceToNowStrict } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
|
import { MessageCircle, MonitorSmartphone, WifiOff, X } from "lucide-react"
|
||||||
|
|
||||||
const MAX_MESSAGE_LENGTH = 4000
|
const MAX_MESSAGE_LENGTH = 4000
|
||||||
|
|
||||||
|
|
@ -41,6 +42,18 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
|
||||||
status: string
|
status: string
|
||||||
canPost: boolean
|
canPost: boolean
|
||||||
reopenDeadline: number | null
|
reopenDeadline: number | null
|
||||||
|
liveChat: {
|
||||||
|
hasMachine: boolean
|
||||||
|
machineOnline: boolean
|
||||||
|
machineHostname: string | null
|
||||||
|
activeSession: {
|
||||||
|
sessionId: Id<"liveChatSessions">
|
||||||
|
agentId: Id<"users">
|
||||||
|
agentName: string | null
|
||||||
|
startedAt: number
|
||||||
|
unreadByAgent: number
|
||||||
|
} | null
|
||||||
|
}
|
||||||
messages: Array<{
|
messages: Array<{
|
||||||
id: Id<"ticketChatMessages">
|
id: Id<"ticketChatMessages">
|
||||||
body: string
|
body: string
|
||||||
|
|
@ -58,9 +71,13 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
|
||||||
|
|
||||||
const markChatRead = useMutation(api.tickets.markChatRead)
|
const markChatRead = useMutation(api.tickets.markChatRead)
|
||||||
const postChatMessage = useMutation(api.tickets.postChatMessage)
|
const postChatMessage = useMutation(api.tickets.postChatMessage)
|
||||||
|
const startLiveChat = useMutation(api.liveChat.startSession)
|
||||||
|
const endLiveChat = useMutation(api.liveChat.endSession)
|
||||||
const messagesEndRef = useRef<HTMLDivElement | null>(null)
|
const messagesEndRef = useRef<HTMLDivElement | null>(null)
|
||||||
const [draft, setDraft] = useState("")
|
const [draft, setDraft] = useState("")
|
||||||
const [isSending, setIsSending] = useState(false)
|
const [isSending, setIsSending] = useState(false)
|
||||||
|
const [isStartingChat, setIsStartingChat] = useState(false)
|
||||||
|
const [isEndingChat, setIsEndingChat] = useState(false)
|
||||||
|
|
||||||
const messages = chat?.messages ?? []
|
const messages = chat?.messages ?? []
|
||||||
const canPost = Boolean(chat?.canPost && viewerId)
|
const canPost = Boolean(chat?.canPost && viewerId)
|
||||||
|
|
@ -96,10 +113,50 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
|
||||||
return null
|
return null
|
||||||
}, [canPost, chatEnabled])
|
}, [canPost, chatEnabled])
|
||||||
|
|
||||||
|
const handleStartLiveChat = async () => {
|
||||||
|
if (!viewerId) return
|
||||||
|
setIsStartingChat(true)
|
||||||
|
toast.dismiss("live-chat")
|
||||||
|
try {
|
||||||
|
const result = await startLiveChat({
|
||||||
|
ticketId: ticketId as Id<"tickets">,
|
||||||
|
actorId: viewerId as Id<"users">,
|
||||||
|
})
|
||||||
|
if (result.isNew) {
|
||||||
|
toast.success("Chat ao vivo iniciado! O cliente sera notificado.", { id: "live-chat" })
|
||||||
|
} else {
|
||||||
|
toast.info("Ja existe uma sessao de chat ativa.", { id: "live-chat" })
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Nao foi possivel iniciar o chat"
|
||||||
|
toast.error(message, { id: "live-chat" })
|
||||||
|
} finally {
|
||||||
|
setIsStartingChat(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEndLiveChat = async () => {
|
||||||
|
if (!viewerId || !chat?.liveChat?.activeSession) return
|
||||||
|
setIsEndingChat(true)
|
||||||
|
toast.dismiss("live-chat")
|
||||||
|
try {
|
||||||
|
await endLiveChat({
|
||||||
|
sessionId: chat.liveChat.activeSession.sessionId,
|
||||||
|
actorId: viewerId as Id<"users">,
|
||||||
|
})
|
||||||
|
toast.success("Chat ao vivo encerrado.", { id: "live-chat" })
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Nao foi possivel encerrar o chat"
|
||||||
|
toast.error(message, { id: "live-chat" })
|
||||||
|
} finally {
|
||||||
|
setIsEndingChat(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (!viewerId || !canPost || draft.trim().length === 0) return
|
if (!viewerId || !canPost || draft.trim().length === 0) return
|
||||||
if (draft.length > MAX_MESSAGE_LENGTH) {
|
if (draft.length > MAX_MESSAGE_LENGTH) {
|
||||||
toast.error(`Mensagem muito longa (máx. ${MAX_MESSAGE_LENGTH} caracteres).`)
|
toast.error(`Mensagem muito longa (max. ${MAX_MESSAGE_LENGTH} caracteres).`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setIsSending(true)
|
setIsSending(true)
|
||||||
|
|
@ -115,7 +172,7 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
|
||||||
toast.success("Mensagem enviada!", { id: "ticket-chat" })
|
toast.success("Mensagem enviada!", { id: "ticket-chat" })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
toast.error("Não foi possível enviar a mensagem.", { id: "ticket-chat" })
|
toast.error("Nao foi possivel enviar a mensagem.", { id: "ticket-chat" })
|
||||||
} finally {
|
} finally {
|
||||||
setIsSending(false)
|
setIsSending(false)
|
||||||
}
|
}
|
||||||
|
|
@ -125,15 +182,85 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const liveChat = chat?.liveChat
|
||||||
|
const hasActiveSession = Boolean(liveChat?.activeSession)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-slate-200">
|
<Card className="border-slate-200">
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<CardTitle className="text-base font-semibold text-neutral-800">Chat do atendimento</CardTitle>
|
<CardTitle className="text-base font-semibold text-neutral-800">Chat do atendimento</CardTitle>
|
||||||
|
{liveChat?.hasMachine && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{liveChat.machineOnline ? (
|
||||||
|
<span className="flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
|
||||||
|
<span className="size-1.5 rounded-full bg-green-500" />
|
||||||
|
Online
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-500">
|
||||||
|
<WifiOff className="size-3" />
|
||||||
|
Offline
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
{!chatEnabled ? (
|
{!chatEnabled ? (
|
||||||
<span className="text-xs font-medium text-neutral-500">Chat desativado</span>
|
<span className="text-xs font-medium text-neutral-500">Chat desativado</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{liveChat?.hasMachine && (
|
||||||
|
<>
|
||||||
|
{hasActiveSession ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleEndLiveChat}
|
||||||
|
disabled={isEndingChat}
|
||||||
|
className="gap-1.5 border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||||
|
>
|
||||||
|
{isEndingChat ? <Spinner className="size-3" /> : <X className="size-3" />}
|
||||||
|
Encerrar Chat
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleStartLiveChat}
|
||||||
|
disabled={isStartingChat || !liveChat.machineOnline}
|
||||||
|
className="gap-1.5"
|
||||||
|
title={!liveChat.machineOnline ? "A maquina precisa estar online para iniciar o chat" : undefined}
|
||||||
|
>
|
||||||
|
{isStartingChat ? <Spinner className="size-3" /> : <MessageCircle className="size-3" />}
|
||||||
|
Iniciar Chat
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
{hasActiveSession && liveChat?.activeSession && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-3 py-2">
|
||||||
|
<div className="flex size-6 items-center justify-center rounded-full bg-green-100">
|
||||||
|
<MessageCircle className="size-3.5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-green-800">Chat ao vivo ativo</p>
|
||||||
|
<p className="text-xs text-green-600">
|
||||||
|
Iniciado por {liveChat.activeSession.agentName ?? "agente"} {formatRelative(liveChat.activeSession.startedAt)}
|
||||||
|
{liveChat.activeSession.unreadByAgent > 0 && (
|
||||||
|
<span className="ml-2 rounded-full bg-green-600 px-1.5 py-0.5 text-white">
|
||||||
|
{liveChat.activeSession.unreadByAgent} nao lidas
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{chat === undefined ? (
|
{chat === undefined ? (
|
||||||
<div className="flex items-center justify-center gap-2 rounded-lg border border-dashed border-slate-200 py-6 text-sm text-neutral-500">
|
<div className="flex items-center justify-center gap-2 rounded-lg border border-dashed border-slate-200 py-6 text-sm text-neutral-500">
|
||||||
<Spinner className="size-4" /> Carregando mensagens...
|
<Spinner className="size-4" /> Carregando mensagens...
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue