sistema-de-chamados/apps/desktop/src-tauri/src/lib.rs
esdrasrenan 6430d33c7c fix(desktop): adiciona permissao start-dragging e debug logging no Hub
- Adiciona core🪟allow-start-dragging para corrigir erro ACL do drag-region
- Adiciona logging detalhado no ChatHubWidget para debug de clicks
- Adiciona logging no comando open_chat_window para diagnostico
- Ajusta ordem size/position e set_ignore_cursor_events no Hub
- Remove set_hub_minimized apos build para evitar conflitos de timing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 01:17:42 -03:00

826 lines
27 KiB
Rust

mod agent;
mod chat;
#[cfg(target_os = "windows")]
mod rustdesk;
#[cfg(target_os = "windows")]
mod service_client;
mod usb_control;
use agent::{collect_inventory_plain, collect_profile, AgentRuntime, MachineProfile};
use chat::{ChatRuntime, ChatSession, ChatMessagesResponse, SendMessageResponse};
use chrono::Local;
use usb_control::{UsbPolicy, UsbPolicyResult};
use tauri::{Emitter, Listener, Manager, WindowEvent};
use tauri_plugin_store::Builder as StorePluginBuilder;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
#[cfg(target_os = "windows")]
use tauri::menu::{MenuBuilder, MenuItemBuilder};
#[cfg(target_os = "windows")]
use tauri::tray::TrayIconBuilder;
#[cfg(target_os = "windows")]
use winreg::enums::*;
#[cfg(target_os = "windows")]
use winreg::RegKey;
const DEFAULT_CONVEX_URL: &str = "https://convex.esdrasrenan.com.br";
// ============================================================================
// Sistema de Logging para Agente
// ============================================================================
static AGENT_LOG_FILE: OnceLock<std::sync::Mutex<std::fs::File>> = OnceLock::new();
pub fn init_agent_logging() -> Result<(), String> {
let dir = logs_directory()
.ok_or("LOCALAPPDATA indisponivel para logging")?;
std::fs::create_dir_all(&dir)
.map_err(|e| format!("Falha ao criar diretorio de logs: {e}"))?;
let path = dir.join("raven-agent.log");
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| format!("Falha ao abrir raven-agent.log: {e}"))?;
let _ = AGENT_LOG_FILE.set(std::sync::Mutex::new(file));
Ok(())
}
pub fn log_agent(level: &str, message: &str) {
let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S%.3f");
let line = format!("[{timestamp}] [{level}] {message}\n");
// Escreve para stderr (util em dev/debug)
eprint!("{line}");
// Escreve para arquivo
if let Some(mutex) = AGENT_LOG_FILE.get() {
if let Ok(mut file) = mutex.lock() {
let _ = file.write_all(line.as_bytes());
let _ = file.flush();
}
}
}
#[macro_export]
macro_rules! log_info {
($($arg:tt)*) => {
$crate::log_agent("INFO", format!($($arg)*).as_str())
};
}
#[macro_export]
macro_rules! log_error {
($($arg:tt)*) => {
$crate::log_agent("ERROR", format!($($arg)*).as_str())
};
}
#[macro_export]
macro_rules! log_warn {
($($arg:tt)*) => {
$crate::log_agent("WARN", format!($($arg)*).as_str())
};
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RustdeskProvisioningResult {
pub id: String,
pub password: String,
pub installed_version: Option<String>,
pub updated: bool,
pub last_provisioned_at: i64,
}
#[tauri::command]
fn collect_machine_profile() -> Result<MachineProfile, String> {
collect_profile().map_err(|error| error.to_string())
}
#[tauri::command]
fn collect_machine_inventory() -> Result<serde_json::Value, String> {
Ok(collect_inventory_plain())
}
#[tauri::command]
fn start_machine_agent(
state: tauri::State<AgentRuntime>,
base_url: String,
token: String,
status: Option<String>,
interval_seconds: Option<u64>,
) -> Result<(), String> {
state
.start_heartbeat(base_url, token, status, interval_seconds)
.map_err(|error| error.to_string())
}
#[tauri::command]
fn stop_machine_agent(state: tauri::State<AgentRuntime>) -> Result<(), String> {
state.stop();
Ok(())
}
#[tauri::command]
fn open_devtools(window: tauri::WebviewWindow) -> Result<(), String> {
window.open_devtools();
Ok(())
}
#[tauri::command]
fn log_app_event(message: String) -> Result<(), String> {
append_app_log(&message)
}
fn append_app_log(message: &str) -> Result<(), String> {
let Some(dir) = logs_directory() else {
return Err("LOCALAPPDATA indisponivel para gravar logs".to_string());
};
std::fs::create_dir_all(&dir)
.map_err(|error| format!("Falha ao criar pasta de logs: {error}"))?;
let path = dir.join("app.log");
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|error| format!("Falha ao abrir app.log: {error}"))?;
let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S");
writeln!(file, "[{timestamp}] {message}")
.map_err(|error| format!("Falha ao escrever log: {error}"))?;
Ok(())
}
fn logs_directory() -> Option<PathBuf> {
let base = std::env::var("LOCALAPPDATA").ok()?;
Some(Path::new(&base).join("br.com.esdrasrenan.sistemadechamados").join("logs"))
}
#[tauri::command]
async fn ensure_rustdesk_and_emit(
app: tauri::AppHandle,
config_string: Option<String>,
password: Option<String>,
machine_id: Option<String>,
) -> Result<RustdeskProvisioningResult, String> {
let result = tauri::async_runtime::spawn_blocking(move || {
run_rustdesk_ensure(config_string, password, machine_id)
})
.await
.map_err(|error| error.to_string())??;
if let Err(error) = app.emit("raven://remote-access/provisioned", &result) {
eprintln!("[rustdesk] falha ao emitir evento raven://remote-access/provisioned: {error}");
}
Ok(result)
}
#[cfg(target_os = "windows")]
fn run_rustdesk_ensure(
config_string: Option<String>,
password: Option<String>,
machine_id: Option<String>,
) -> Result<RustdeskProvisioningResult, String> {
// Tenta usar o servico primeiro (sem UAC)
if service_client::is_service_available() {
log_info!("Usando Raven Service para provisionar RustDesk");
match service_client::provision_rustdesk(
config_string.as_deref(),
password.as_deref(),
machine_id.as_deref(),
) {
Ok(result) => {
return Ok(RustdeskProvisioningResult {
id: result.id,
password: result.password,
installed_version: result.installed_version,
updated: result.updated,
last_provisioned_at: result.last_provisioned_at,
});
}
Err(e) => {
log_warn!("Falha ao usar servico para RustDesk: {e}");
// Continua para fallback
}
}
}
// Fallback: chamada direta (pode pedir UAC)
log_info!("Usando chamada direta para provisionar RustDesk (pode pedir UAC)");
rustdesk::ensure_rustdesk(
config_string.as_deref(),
password.as_deref(),
machine_id.as_deref(),
)
.map_err(|error| error.to_string())
}
#[cfg(not(target_os = "windows"))]
fn run_rustdesk_ensure(
_config_string: Option<String>,
_password: Option<String>,
_machine_id: Option<String>,
) -> Result<RustdeskProvisioningResult, String> {
Err("Provisionamento automático do RustDesk está disponível apenas no Windows.".to_string())
}
#[tauri::command]
fn apply_usb_policy(policy: String) -> Result<UsbPolicyResult, String> {
// Valida a politica primeiro
let _policy_enum = UsbPolicy::from_str(&policy)
.ok_or_else(|| format!("Politica USB invalida: {}. Use ALLOW, BLOCK_ALL ou READONLY.", policy))?;
// Tenta usar o servico primeiro (sem UAC)
#[cfg(target_os = "windows")]
if service_client::is_service_available() {
log_info!("Usando Raven Service para aplicar politica USB: {}", policy);
match service_client::apply_usb_policy(&policy) {
Ok(result) => {
return Ok(UsbPolicyResult {
success: result.success,
policy: result.policy,
error: result.error,
applied_at: result.applied_at,
});
}
Err(e) => {
log_warn!("Falha ao usar servico para USB policy: {e}");
// Continua para fallback
}
}
}
// Fallback: chamada direta (pode pedir UAC)
log_info!("Usando chamada direta para aplicar politica USB (pode pedir UAC)");
usb_control::apply_usb_policy(_policy_enum).map_err(|e| e.to_string())
}
#[tauri::command]
fn get_usb_policy() -> Result<String, String> {
// Tenta usar o servico primeiro
#[cfg(target_os = "windows")]
if service_client::is_service_available() {
match service_client::get_usb_policy() {
Ok(policy) => return Ok(policy),
Err(e) => {
log_warn!("Falha ao obter USB policy via servico: {e}");
// Continua para fallback
}
}
}
// Fallback: leitura direta (nao precisa elevacao para ler)
usb_control::get_current_policy()
.map(|p| p.as_str().to_string())
.map_err(|e| e.to_string())
}
#[tauri::command]
fn refresh_usb_policy() -> Result<(), 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,
convex_url: Option<String>,
token: String,
) -> Result<(), String> {
let url = convex_url.unwrap_or_else(|| DEFAULT_CONVEX_URL.to_string());
state.start_polling(base_url, url, token, app)
}
#[tauri::command]
fn stop_chat_polling(state: tauri::State<ChatRuntime>) -> Result<(), String> {
state.stop();
Ok(())
}
#[tauri::command]
fn is_chat_using_realtime(state: tauri::State<ChatRuntime>) -> bool {
state.is_using_sse()
}
#[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,
attachments: Option<Vec<chat::AttachmentPayload>>,
) -> Result<SendMessageResponse, String> {
chat::send_message(&base_url, &token, &ticket_id, &body, attachments).await
}
#[tauri::command]
async fn mark_chat_messages_read(
base_url: String,
token: String,
ticket_id: String,
message_ids: Vec<String>,
) -> Result<(), String> {
if message_ids.is_empty() {
return Ok(());
}
chat::mark_messages_read(&base_url, &token, &ticket_id, &message_ids).await
}
#[tauri::command]
async fn upload_chat_file(
base_url: String,
token: String,
file_path: String,
) -> Result<chat::AttachmentPayload, String> {
use std::path::Path;
// Ler o arquivo
let path = Path::new(&file_path);
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.ok_or("Nome de arquivo inválido")?
.to_string();
let file_data = std::fs::read(&file_path)
.map_err(|e| format!("Falha ao ler arquivo: {e}"))?;
let file_size = file_data.len() as u64;
// Validar arquivo
chat::is_allowed_file(&file_name, file_size)?;
// Obter tipo MIME
let mime_type = chat::get_mime_type(&file_name);
// Gerar URL de upload
let upload_url = chat::generate_upload_url(
&base_url,
&token,
&file_name,
&mime_type,
file_size,
)
.await?;
// Fazer upload
let storage_id = chat::upload_file(&upload_url, file_data, &mime_type).await?;
Ok(chat::AttachmentPayload {
storage_id,
name: file_name,
size: Some(file_size),
mime_type: Some(mime_type),
})
}
#[tauri::command]
fn open_chat_window(app: tauri::AppHandle, ticket_id: String, ticket_ref: u64) -> Result<(), String> {
log_info!("[CMD] open_chat_window called: ticket_id={}, ticket_ref={}", ticket_id, ticket_ref);
let result = chat::open_chat_window(&app, &ticket_id, ticket_ref);
log_info!("[CMD] open_chat_window result: {:?}", result);
result
}
#[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)
}
#[tauri::command]
fn set_chat_minimized(app: tauri::AppHandle, ticket_id: String, minimized: bool) -> Result<(), String> {
chat::set_chat_minimized(&app, &ticket_id, minimized)
}
#[tauri::command]
fn open_hub_window(app: tauri::AppHandle) -> Result<(), String> {
chat::open_hub_window(&app)
}
#[tauri::command]
fn close_hub_window(app: tauri::AppHandle) -> Result<(), String> {
chat::close_hub_window(&app)
}
#[tauri::command]
fn set_hub_minimized(app: tauri::AppHandle, minimized: bool) -> Result<(), String> {
chat::set_hub_minimized(&app, minimized)
}
// ============================================================================
// Handler de Deep Link (raven://)
// ============================================================================
/// Processa URLs do protocolo raven://
/// Formatos suportados:
/// - raven://ticket/{token} - Abre visualizacao do chamado
/// - raven://chat/{ticketId}?token={token} - Abre chat do chamado
/// - raven://rate/{token} - Abre avaliacao do chamado
fn handle_deep_link(app: &tauri::AppHandle, url: &str) {
log_info!("Processando deep link: {url}");
// Remove o prefixo raven://
let path = url.trim_start_matches("raven://");
// Parse do path
let parts: Vec<&str> = path.split('/').collect();
if parts.is_empty() {
log_warn!("Deep link invalido: path vazio");
return;
}
match parts[0] {
"ticket" => {
if parts.len() > 1 {
let token = parts[1].split('?').next().unwrap_or(parts[1]);
log_info!("Abrindo ticket com token: {token}");
// Mostra a janela principal
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
// Emite evento para o frontend navegar para o ticket
let _ = app.emit("raven://deep-link/ticket", serde_json::json!({
"token": token
}));
}
}
}
"chat" => {
if parts.len() > 1 {
let ticket_id = parts[1].split('?').next().unwrap_or(parts[1]);
log_info!("Abrindo chat do ticket: {ticket_id}");
// Abre janela de chat (ticket_ref 0 quando vem de deeplink)
if let Err(e) = chat::open_chat_window(app, ticket_id, 0) {
log_error!("Falha ao abrir chat: {e}");
}
}
}
"rate" => {
if parts.len() > 1 {
let token = parts[1].split('?').next().unwrap_or(parts[1]);
log_info!("Abrindo avaliacao com token: {token}");
// Mostra a janela principal
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
// Emite evento para o frontend navegar para avaliacao
let _ = app.emit("raven://deep-link/rate", serde_json::json!({
"token": token
}));
}
}
}
_ => {
log_warn!("Deep link desconhecido: {path}");
}
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(AgentRuntime::new())
.manage(ChatRuntime::new())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(StorePluginBuilder::default().build())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
// Quando uma segunda instância tenta iniciar, foca a janela existente
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}))
.on_window_event(|window, event| {
if let WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
})
.setup(|app| {
// Inicializa sistema de logging primeiro
if let Err(e) = init_agent_logging() {
eprintln!("[raven] Falha ao inicializar logging: {e}");
}
log_info!("Raven iniciando...");
// Configura handler de deep link (raven://)
#[cfg(desktop)]
{
let handle = app.handle().clone();
app.listen("deep-link://new-url", move |event| {
let urls = event.payload();
log_info!("Deep link recebido: {urls}");
handle_deep_link(&handle, urls);
});
}
#[cfg(target_os = "windows")]
{
let start_in_background = std::env::args().any(|arg| arg == "--background");
setup_raven_autostart();
setup_tray(app.handle())?;
if start_in_background {
if let Some(win) = app.get_webview_window("main") {
let _ = win.hide();
}
}
// Tenta iniciar o agente e chat em background se houver credenciais salvas
let app_handle = app.handle().clone();
let agent_runtime = app.state::<AgentRuntime>().inner().clone();
let chat_runtime = app.state::<ChatRuntime>().inner().clone();
tauri::async_runtime::spawn(async move {
// Aguarda um pouco para o app estabilizar
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
if let Err(e) = try_start_background_agent(&app_handle, agent_runtime, chat_runtime).await {
log_warn!("Agente nao iniciado em background: {e}");
}
});
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
collect_machine_profile,
collect_machine_inventory,
start_machine_agent,
stop_machine_agent,
open_devtools,
log_app_event,
ensure_rustdesk_and_emit,
apply_usb_policy,
get_usb_policy,
refresh_usb_policy,
// Chat commands
start_chat_polling,
stop_chat_polling,
is_chat_using_realtime,
get_chat_sessions,
fetch_chat_sessions,
fetch_chat_messages,
send_chat_message,
mark_chat_messages_read,
upload_chat_file,
open_chat_window,
close_chat_window,
minimize_chat_window,
set_chat_minimized,
// Hub commands
open_hub_window,
close_hub_window,
set_hub_minimized
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
#[cfg(target_os = "windows")]
fn setup_raven_autostart() {
let exe_path = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
log_error!("Falha ao obter caminho do executavel: {e}");
return;
}
};
let path_str = exe_path.display().to_string();
// Adiciona flag --background para indicar inicio automatico
let value = format!("\"{}\" --background", path_str);
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let key = match hkcu.create_subkey(r"Software\Microsoft\Windows\CurrentVersion\Run") {
Ok((key, _)) => key,
Err(e) => {
log_error!("Falha ao criar/abrir chave de registro Run: {e}");
return;
}
};
if let Err(e) = key.set_value("Raven", &value) {
log_error!("Falha ao definir valor de auto-start no registro: {e}");
return;
}
log_info!("Auto-start configurado: {value}");
// Valida que foi salvo corretamente
match key.get_value::<String, _>("Raven") {
Ok(saved) => {
if saved == value {
log_info!("Auto-start validado: entrada existe no registro");
} else {
log_warn!("Auto-start: valor difere. Esperado: {value}, Salvo: {saved}");
}
}
Err(e) => {
log_warn!("Auto-start: nao foi possivel validar entrada: {e}");
}
}
}
#[cfg(target_os = "windows")]
fn setup_tray(app: &tauri::AppHandle) -> tauri::Result<()> {
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 menu = MenuBuilder::new(app)
.items(&[&show_item, &chat_item, &quit_item])
.build()?;
let mut builder = TrayIconBuilder::new()
.menu(&menu)
.on_menu_event(|tray, event| {
match event.id().as_ref() {
"show" => {
if let Some(win) = tray.app_handle().get_webview_window("main") {
let _ = win.show();
let _ = win.set_focus();
}
// Reabrir 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() {
let _ = chat::open_chat_window(tray.app_handle(), &session.ticket_id, session.ticket_ref);
}
}
}
"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 sessions.len() > 1 {
// Multiplas sessoes - abrir hub
if let Err(e) = chat::open_hub_window(tray.app_handle()) {
log_error!("Falha ao abrir hub de chat: {e}");
}
} else if let Some(session) = sessions.first() {
// Uma sessao - abrir diretamente
if let Err(e) = chat::open_chat_window(tray.app_handle(), &session.ticket_id, session.ticket_ref) {
log_error!("Falha ao abrir janela de chat: {e}");
}
}
}
}
"quit" => {
tray.app_handle().exit(0);
}
_ => {}
}
})
.on_tray_icon_event(|tray, event| {
if let tauri::tray::TrayIconEvent::DoubleClick { .. } = event {
if let Some(win) = tray.app_handle().get_webview_window("main") {
let _ = win.show();
let _ = win.set_focus();
}
// Reabrir 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() {
let _ = chat::open_chat_window(tray.app_handle(), &session.ticket_id, session.ticket_ref);
}
}
}
});
if let Some(icon) = app.default_window_icon() {
builder = builder.icon(icon.clone());
}
builder = builder.tooltip("Raven");
builder.build(app)?;
Ok(())
}
#[cfg(target_os = "windows")]
async fn try_start_background_agent(
app: &tauri::AppHandle,
agent_runtime: AgentRuntime,
chat_runtime: ChatRuntime,
) -> Result<(), String> {
log_info!("Verificando credenciais salvas para iniciar agente...");
let app_data = app
.path()
.app_local_data_dir()
.map_err(|e| format!("Falha ao obter diretorio de dados: {e}"))?;
let store_path = app_data.join("machine-agent.json");
if !store_path.exists() {
return Err("Nenhuma configuracao encontrada".to_string());
}
// Ler arquivo JSON diretamente
let content = std::fs::read_to_string(&store_path)
.map_err(|e| format!("Falha ao ler machine-agent.json: {e}"))?;
let data: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| format!("Falha ao parsear machine-agent.json: {e}"))?;
let token = data
.get("token")
.and_then(|v| v.as_str())
.filter(|t| !t.is_empty())
.ok_or("Token nao encontrado ou vazio")?;
let config = data.get("config");
let api_base_url = config
.and_then(|c| c.get("apiBaseUrl"))
.and_then(|v| v.as_str())
.unwrap_or("https://tickets.esdrasrenan.com.br");
let convex_url = config
.and_then(|c| c.get("convexUrl"))
.and_then(|v| v.as_str())
.unwrap_or(DEFAULT_CONVEX_URL);
let interval = config
.and_then(|c| c.get("heartbeatIntervalSec"))
.and_then(|v| v.as_u64())
.unwrap_or(300);
log_info!(
"Iniciando agente em background: url={}, interval={}s",
api_base_url,
interval
);
agent_runtime
.start_heartbeat(
api_base_url.to_string(),
token.to_string(),
Some("online".to_string()),
Some(interval),
)
.map_err(|e| format!("Falha ao iniciar heartbeat: {e}"))?;
// Iniciar sistema de chat (WebSocket + fallback HTTP polling)
if let Err(e) =
chat_runtime.start_polling(api_base_url.to_string(), convex_url.to_string(), token.to_string(), app.clone())
{
log_warn!("Falha ao iniciar chat em background: {e}");
} else {
log_info!("Chat iniciado com sucesso (Convex WebSocket)");
}
log_info!("Agente iniciado com sucesso em background");
Ok(())
}