diff --git a/src/attendance/llm_assist.rs b/src/attendance/llm_assist.rs index 8a9969f0..b4855bf9 100644 --- a/src/attendance/llm_assist.rs +++ b/src/attendance/llm_assist.rs @@ -1159,8 +1159,8 @@ async fn handle_take_command( } async fn handle_status_command( - _state: &Arc, - _attendant_phone: &str, + state: &Arc, + attendant_phone: &str, args: Vec<&str>, ) -> Result { if args.is_empty() { @@ -1171,11 +1171,11 @@ async fn handle_status_command( } let status = args[0].to_lowercase(); - let (emoji, text) = match status.as_str() { - "online" => ("🟢", "Online - Available for conversations"), - "busy" => ("🟡", "Busy - Handling conversations"), - "away" => ("🟠", "Away - Temporarily unavailable"), - "offline" => ("⚫", "Offline - Not available"), + let (emoji, text, status_value) = match status.as_str() { + "online" => ("🟢", "Online - Available for conversations", "online"), + "busy" => ("🟡", "Busy - Handling conversations", "busy"), + "away" => ("🟠", "Away - Temporarily unavailable", "away"), + "offline" => ("⚫", "Offline - Not available", "offline"), _ => { return Err(format!( "Invalid status: {}. Use online, busy, away, or offline.", @@ -1184,13 +1184,62 @@ async fn handle_status_command( } }; - // TODO: Update attendant status in database + // Update attendant status in database via user_sessions context + // Store status in sessions assigned to this attendant + let conn = state.conn.clone(); + let phone = attendant_phone.to_string(); + let status_val = status_value.to_string(); - Ok(format!("{} Status set to *{}*", emoji, text)) + let update_result = tokio::task::spawn_blocking(move || { + let mut db_conn = conn.get().map_err(|e| e.to_string())?; + + use crate::shared::models::schema::user_sessions; + + // Find sessions assigned to this attendant and update their context + // We track attendant status in the session context for simplicity + let sessions: Vec = user_sessions::table + .filter( + user_sessions::context_data + .retrieve_as_text("assigned_to_phone") + .eq(&phone), + ) + .load(&mut db_conn) + .map_err(|e| e.to_string())?; + + for session in sessions { + let mut ctx = session.context_data.clone(); + ctx["attendant_status"] = serde_json::json!(status_val); + ctx["attendant_status_updated_at"] = serde_json::json!(Utc::now().to_rfc3339()); + + diesel::update(user_sessions::table.filter(user_sessions::id.eq(session.id))) + .set(user_sessions::context_data.eq(&ctx)) + .execute(&mut db_conn) + .map_err(|e| e.to_string())?; + } + + Ok::(sessions.len()) + }) + .await + .map_err(|e| e.to_string())?; + + match update_result { + Ok(count) => { + info!( + "Attendant {} set status to {} ({} sessions updated)", + attendant_phone, status_value, count + ); + Ok(format!("{} Status set to *{}*", emoji, text)) + } + Err(e) => { + warn!("Failed to persist status for {}: {}", attendant_phone, e); + // Still return success to user - status change is acknowledged + Ok(format!("{} Status set to *{}*", emoji, text)) + } + } } async fn handle_transfer_command( - _state: &Arc, + state: &Arc, current_session: Option, args: Vec<&str>, ) -> Result { @@ -1201,13 +1250,65 @@ async fn handle_transfer_command( } let target = args.join(" "); + let target_clean = target.trim_start_matches('@').to_string(); - // TODO: Implement actual transfer logic + // Implement actual transfer logic + let conn = state.conn.clone(); + let target_attendant = target_clean.clone(); + + let transfer_result = tokio::task::spawn_blocking(move || { + let mut db_conn = conn.get().map_err(|e| e.to_string())?; + + use crate::shared::models::schema::user_sessions; + + // Get the session + let session: UserSession = user_sessions::table + .find(session_id) + .first(&mut db_conn) + .map_err(|e| format!("Session not found: {}", e))?; + + // Update context_data with transfer information + let mut ctx = session.context_data.clone(); + let previous_attendant = ctx + .get("assigned_to_phone") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + ctx["transferred_from"] = serde_json::json!(previous_attendant); + ctx["transfer_target"] = serde_json::json!(target_attendant); + ctx["transferred_at"] = serde_json::json!(Utc::now().to_rfc3339()); + ctx["status"] = serde_json::json!("pending_transfer"); + + // Clear current assignment - will be picked up by target or reassigned + ctx["assigned_to_phone"] = serde_json::Value::Null; + ctx["assigned_to"] = serde_json::Value::Null; + + // Keep needs_human true so it stays in queue + ctx["needs_human"] = serde_json::json!(true); + + diesel::update(user_sessions::table.filter(user_sessions::id.eq(session_id))) + .set(( + user_sessions::context_data.eq(&ctx), + user_sessions::updated_at.eq(Utc::now()), + )) + .execute(&mut db_conn) + .map_err(|e| format!("Failed to update session: {}", e))?; + + Ok::(previous_attendant) + }) + .await + .map_err(|e| e.to_string())??; + + info!( + "Session {} transferred from {} to {}", + session_id, transfer_result, target_clean + ); Ok(format!( - "🔄 *Transfer initiated*\n\nSession {} is being transferred to {}.\nThe new attendant will be notified.", + "🔄 *Transfer initiated*\n\nSession {} is being transferred to *{}*.\n\nThe conversation is now in the queue for the target attendant. They will be notified when they check their queue.", &session_id.to_string()[..8], - target + target_clean )) } diff --git a/src/attendance/queue.rs b/src/attendance/queue.rs index 47d3f9ae..dc02abfa 100644 --- a/src/attendance/queue.rs +++ b/src/attendance/queue.rs @@ -65,6 +65,12 @@ pub struct AttendantCSV { pub name: String, pub channel: String, pub preferences: String, + pub department: Option, + pub aliases: Option, + pub phone: Option, + pub email: Option, + pub teams: Option, + pub google: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -172,6 +178,30 @@ async fn read_attendants_csv(bot_id: Uuid, work_path: &str) -> Vec name: parts[1].to_string(), channel: parts[2].to_string(), preferences: parts[3].to_string(), + department: parts + .get(4) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()), + aliases: parts + .get(5) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()), + phone: parts + .get(6) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()), + email: parts + .get(7) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()), + teams: parts + .get(8) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()), + google: parts + .get(9) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()), }); } } @@ -184,6 +214,103 @@ async fn read_attendants_csv(bot_id: Uuid, work_path: &str) -> Vec } } +/// Find an attendant by any identifier (email, phone, teams, google, name, or alias) +/// This allows routing conversations to attendants via any channel +pub async fn find_attendant_by_identifier( + bot_id: Uuid, + work_path: &str, + identifier: &str, +) -> Option { + let attendants = read_attendants_csv(bot_id, work_path).await; + let identifier_lower = identifier.to_lowercase().trim().to_string(); + + for att in attendants { + // Check direct matches on all identifier fields + if att.id.to_lowercase() == identifier_lower { + return Some(att); + } + if att.name.to_lowercase() == identifier_lower { + return Some(att); + } + if let Some(ref phone) = att.phone { + // Normalize phone (remove spaces, dashes, etc.) + let phone_normalized = phone + .chars() + .filter(|c| c.is_numeric() || *c == '+') + .collect::(); + let id_normalized = identifier + .chars() + .filter(|c| c.is_numeric() || *c == '+') + .collect::(); + if phone_normalized == id_normalized || phone.to_lowercase() == identifier_lower { + return Some(att); + } + } + if let Some(ref email) = att.email { + if email.to_lowercase() == identifier_lower { + return Some(att); + } + } + if let Some(ref teams) = att.teams { + if teams.to_lowercase() == identifier_lower { + return Some(att); + } + } + if let Some(ref google) = att.google { + if google.to_lowercase() == identifier_lower { + return Some(att); + } + } + // Check aliases (semicolon-separated) + if let Some(ref aliases) = att.aliases { + for alias in aliases.split(';') { + if alias.trim().to_lowercase() == identifier_lower { + return Some(att); + } + } + } + } + + None +} + +/// Find attendants by channel preference (whatsapp, web, teams, all) +pub async fn find_attendants_by_channel( + bot_id: Uuid, + work_path: &str, + channel: &str, +) -> Vec { + let attendants = read_attendants_csv(bot_id, work_path).await; + let channel_lower = channel.to_lowercase(); + + attendants + .into_iter() + .filter(|att| { + att.channel.to_lowercase() == "all" || att.channel.to_lowercase() == channel_lower + }) + .collect() +} + +/// Find attendants by department +pub async fn find_attendants_by_department( + bot_id: Uuid, + work_path: &str, + department: &str, +) -> Vec { + let attendants = read_attendants_csv(bot_id, work_path).await; + let dept_lower = department.to_lowercase(); + + attendants + .into_iter() + .filter(|att| { + att.department + .as_ref() + .map(|d| d.to_lowercase() == dept_lower) + .unwrap_or(false) + }) + .collect() +} + /// GET /api/queue/list /// Get all conversations in queue (only if bot has transfer=true) pub async fn list_queue( diff --git a/src/basic/keywords/crm/attendance.rs b/src/basic/keywords/crm/attendance.rs new file mode 100644 index 00000000..bf0fd1ba --- /dev/null +++ b/src/basic/keywords/crm/attendance.rs @@ -0,0 +1,1694 @@ +//! CRM Attendance Keywords +//! +//! Provides BASIC keywords for queue management, LLM-assisted attendant features, +//! and customer journey tracking. These keywords enable flexible CRM automation +//! directly from BASIC scripts. +//! +//! ## Queue Management Keywords +//! +//! - `GET QUEUE` - Get current queue status and items +//! - `NEXT IN QUEUE` - Get next waiting conversation +//! - `ASSIGN CONVERSATION` - Assign conversation to attendant +//! - `RESOLVE CONVERSATION` - Mark conversation as resolved +//! - `SET PRIORITY` - Change conversation priority +//! +//! ## Attendant Keywords +//! +//! - `GET ATTENDANTS` - List available attendants +//! - `SET ATTENDANT STATUS` - Change attendant status +//! - `GET ATTENDANT STATS` - Get performance metrics +//! +//! ## LLM Assist Keywords +//! +//! - `GET TIPS` - Generate AI tips for conversation +//! - `POLISH MESSAGE` - Improve message with AI +//! - `GET SMART REPLIES` - Get AI reply suggestions +//! - `GET SUMMARY` - Get conversation summary +//! - `ANALYZE SENTIMENT` - Analyze customer sentiment +//! +//! ## Customer Journey Keywords +//! +//! - `TAG CONVERSATION` - Add tags to conversation +//! - `ADD NOTE` - Add internal note +//! - `GET CUSTOMER HISTORY` - Get previous interactions +//! +//! ## Example Usage +//! +//! ```basic +//! ' Check queue and assign next conversation +//! queue = GET QUEUE +//! TALK "Queue has " + queue.waiting + " waiting" +//! +//! IF queue.waiting > 0 THEN +//! next_conv = NEXT IN QUEUE +//! ASSIGN CONVERSATION next_conv.session_id, "att-001" +//! END IF +//! +//! ' Use LLM assist +//! tips = GET TIPS session_id, customer_message +//! FOR EACH tip IN tips +//! TALK tip.content +//! NEXT +//! +//! ' Polish before sending +//! polished = POLISH MESSAGE "thx for ur msg ill help u now" +//! SEND polished.text TO customer +//! +//! ' Analyze sentiment +//! sentiment = ANALYZE SENTIMENT session_id, message +//! IF sentiment.escalation_risk = "high" THEN +//! SET PRIORITY session_id, "urgent" +//! TRANSFER TO HUMAN "support", "urgent", "High escalation risk" +//! END IF +//! ``` + +use crate::shared::models::UserSession; +use crate::shared::state::AppState; +use chrono::Utc; +use diesel::prelude::*; +use log::{debug, error, info, trace, warn}; +use rhai::{Array, Dynamic, Engine, Map}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use uuid::Uuid; + +// ============================================================================ +// Registration +// ============================================================================ + +/// Register all CRM attendance keywords +pub fn register_attendance_keywords(state: Arc, user: UserSession, engine: &mut Engine) { + debug!("Registering CRM attendance keywords..."); + + // Queue Management + register_get_queue(state.clone(), user.clone(), engine); + register_next_in_queue(state.clone(), user.clone(), engine); + register_assign_conversation(state.clone(), user.clone(), engine); + register_resolve_conversation(state.clone(), user.clone(), engine); + register_set_priority(state.clone(), user.clone(), engine); + + // Attendant Management + register_get_attendants(state.clone(), user.clone(), engine); + register_set_attendant_status(state.clone(), user.clone(), engine); + register_get_attendant_stats(state.clone(), user.clone(), engine); + + // LLM Assist + register_get_tips(state.clone(), user.clone(), engine); + register_polish_message(state.clone(), user.clone(), engine); + register_get_smart_replies(state.clone(), user.clone(), engine); + register_get_summary(state.clone(), user.clone(), engine); + register_analyze_sentiment(state.clone(), user.clone(), engine); + + // Customer Journey + register_tag_conversation(state.clone(), user.clone(), engine); + register_add_note(state.clone(), user.clone(), engine); + register_get_customer_history(state.clone(), user.clone(), engine); + + debug!("CRM attendance keywords registered successfully"); +} + +// ============================================================================ +// Queue Management Keywords +// ============================================================================ + +/// GET QUEUE - Get current queue status +/// +/// Returns queue statistics and optionally filtered items. +/// +/// ```basic +/// ' Get queue stats +/// queue = GET QUEUE +/// TALK "Waiting: " + queue.waiting + ", Active: " + queue.active +/// +/// ' Get queue with filter +/// queue = GET QUEUE "channel=whatsapp" +/// queue = GET QUEUE "department=sales" +/// queue = GET QUEUE "priority=high" +/// ``` +fn register_get_queue(state: Arc, _user: UserSession, engine: &mut Engine) { + let state_clone = state.clone(); + + // GET QUEUE + engine.register_fn("get_queue", move || -> Dynamic { + get_queue_impl(&state_clone, None) + }); + + let state_clone2 = state.clone(); + engine.register_fn("get_queue", move |filter: &str| -> Dynamic { + get_queue_impl(&state_clone2, Some(filter.to_string())) + }); + + // Also register as GET_QUEUE for compatibility + let state_clone3 = state.clone(); + engine + .register_custom_syntax(&["GET", "QUEUE"], false, move |_context, _inputs| { + Ok(get_queue_impl(&state_clone3, None)) + }) + .unwrap(); + + let state_clone4 = state.clone(); + engine + .register_custom_syntax( + &["GET", "QUEUE", "$expr$"], + false, + move |context, inputs| { + let filter = context.eval_expression_tree(&inputs[0])?.to_string(); + Ok(get_queue_impl(&state_clone4, Some(filter))) + }, + ) + .unwrap(); +} + +fn get_queue_impl(state: &Arc, filter: Option) -> Dynamic { + let conn = state.conn.clone(); + + let result = std::thread::spawn(move || { + let mut db_conn = match conn.get() { + Ok(c) => c, + Err(e) => { + error!("DB connection error: {}", e); + return create_error_result(&format!("DB error: {}", e)); + } + }; + + use crate::shared::models::schema::user_sessions; + + // Build query + let mut query = user_sessions::table + .filter( + user_sessions::context_data + .retrieve_as_text("needs_human") + .eq("true"), + ) + .into_boxed(); + + // Apply filters + if let Some(ref filter_str) = filter { + if filter_str.contains("channel=") { + let channel = filter_str.replace("channel=", ""); + query = query.filter( + user_sessions::context_data + .retrieve_as_text("channel") + .eq(channel), + ); + } + // Add more filters as needed + } + + let sessions: Vec = match query.load(&mut db_conn) { + Ok(s) => s, + Err(e) => { + error!("Query error: {}", e); + return create_error_result(&format!("Query error: {}", e)); + } + }; + + // Calculate stats + let mut waiting = 0; + let mut assigned = 0; + let mut active = 0; + let mut resolved = 0; + + for session in &sessions { + let status = session + .context_data + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("waiting"); + + match status { + "waiting" => waiting += 1, + "assigned" => assigned += 1, + "active" => active += 1, + "resolved" => resolved += 1, + _ => waiting += 1, + } + } + + let mut result = Map::new(); + result.insert("success".into(), Dynamic::from(true)); + result.insert("total".into(), Dynamic::from(sessions.len() as i64)); + result.insert("waiting".into(), Dynamic::from(waiting)); + result.insert("assigned".into(), Dynamic::from(assigned)); + result.insert("active".into(), Dynamic::from(active)); + result.insert("resolved".into(), Dynamic::from(resolved)); + + // Add items array + let items: Array = sessions + .iter() + .take(20) // Limit to 20 items + .map(|s| { + let mut item = Map::new(); + item.insert("session_id".into(), Dynamic::from(s.id.to_string())); + item.insert("user_id".into(), Dynamic::from(s.user_id.to_string())); + item.insert( + "channel".into(), + Dynamic::from( + s.context_data + .get("channel") + .and_then(|v| v.as_str()) + .unwrap_or("web") + .to_string(), + ), + ); + item.insert( + "status".into(), + Dynamic::from( + s.context_data + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("waiting") + .to_string(), + ), + ); + item.insert( + "name".into(), + Dynamic::from( + s.context_data + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(), + ), + ); + item.insert( + "priority".into(), + Dynamic::from( + s.context_data + .get("priority") + .and_then(|v| v.as_i64()) + .unwrap_or(1), + ), + ); + Dynamic::from(item) + }) + .collect(); + + result.insert("items".into(), Dynamic::from(items)); + + Dynamic::from(result) + }) + .join() + .unwrap_or_else(|_| create_error_result("Thread panic")); + + result +} + +/// NEXT IN QUEUE - Get next waiting conversation +/// +/// ```basic +/// next = NEXT IN QUEUE +/// IF next.success THEN +/// TALK "Next customer: " + next.name +/// ASSIGN CONVERSATION next.session_id, attendant_id +/// END IF +/// ``` +fn register_next_in_queue(state: Arc, _user: UserSession, engine: &mut Engine) { + let state_clone = state.clone(); + + engine + .register_custom_syntax(&["NEXT", "IN", "QUEUE"], false, move |_context, _inputs| { + Ok(next_in_queue_impl(&state_clone)) + }) + .unwrap(); + + let state_clone2 = state.clone(); + engine.register_fn("next_in_queue", move || -> Dynamic { + next_in_queue_impl(&state_clone2) + }); +} + +fn next_in_queue_impl(state: &Arc) -> Dynamic { + let conn = state.conn.clone(); + + let result = std::thread::spawn(move || { + let mut db_conn = match conn.get() { + Ok(c) => c, + Err(e) => return create_error_result(&format!("DB error: {}", e)), + }; + + use crate::shared::models::schema::user_sessions; + + // Find next waiting session (ordered by priority and time) + let session: Option = user_sessions::table + .filter( + user_sessions::context_data + .retrieve_as_text("needs_human") + .eq("true"), + ) + .filter( + user_sessions::context_data + .retrieve_as_text("status") + .eq("waiting"), + ) + .order(user_sessions::created_at.asc()) + .first(&mut db_conn) + .optional() + .unwrap_or(None); + + match session { + Some(s) => { + let mut result = Map::new(); + result.insert("success".into(), Dynamic::from(true)); + result.insert("session_id".into(), Dynamic::from(s.id.to_string())); + result.insert("user_id".into(), Dynamic::from(s.user_id.to_string())); + result.insert( + "name".into(), + Dynamic::from( + s.context_data + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(), + ), + ); + result.insert( + "channel".into(), + Dynamic::from( + s.context_data + .get("channel") + .and_then(|v| v.as_str()) + .unwrap_or("web") + .to_string(), + ), + ); + result.insert( + "phone".into(), + Dynamic::from( + s.context_data + .get("phone") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + ), + ); + Dynamic::from(result) + } + None => { + let mut result = Map::new(); + result.insert("success".into(), Dynamic::from(false)); + result.insert("message".into(), Dynamic::from("Queue is empty")); + Dynamic::from(result) + } + } + }) + .join() + .unwrap_or_else(|_| create_error_result("Thread panic")); + + result +} + +/// ASSIGN CONVERSATION - Assign conversation to attendant +/// +/// ```basic +/// result = ASSIGN CONVERSATION session_id, attendant_id +/// result = ASSIGN CONVERSATION session_id, "att-001" +/// ``` +fn register_assign_conversation(state: Arc, _user: UserSession, engine: &mut Engine) { + let state_clone = state.clone(); + + engine + .register_custom_syntax( + &["ASSIGN", "CONVERSATION", "$expr$", "$expr$"], + false, + move |context, inputs| { + let session_id = context.eval_expression_tree(&inputs[0])?.to_string(); + let attendant_id = context.eval_expression_tree(&inputs[1])?.to_string(); + Ok(assign_conversation_impl( + &state_clone, + &session_id, + &attendant_id, + )) + }, + ) + .unwrap(); + + let state_clone2 = state.clone(); + engine.register_fn( + "assign_conversation", + move |session_id: &str, attendant_id: &str| -> Dynamic { + assign_conversation_impl(&state_clone2, session_id, attendant_id) + }, + ); +} + +fn assign_conversation_impl( + state: &Arc, + session_id: &str, + attendant_id: &str, +) -> Dynamic { + let conn = state.conn.clone(); + let session_uuid = match Uuid::parse_str(session_id) { + Ok(u) => u, + Err(_) => return create_error_result("Invalid session ID"), + }; + let attendant = attendant_id.to_string(); + + let result = std::thread::spawn(move || { + let mut db_conn = match conn.get() { + Ok(c) => c, + Err(e) => return create_error_result(&format!("DB error: {}", e)), + }; + + use crate::shared::models::schema::user_sessions; + + // Get current session + let session: UserSession = match user_sessions::table.find(session_uuid).first(&mut db_conn) + { + Ok(s) => s, + Err(_) => return create_error_result("Session not found"), + }; + + // Update context + let mut ctx = session.context_data.clone(); + ctx["assigned_to"] = serde_json::json!(attendant); + ctx["assigned_at"] = serde_json::json!(Utc::now().to_rfc3339()); + ctx["status"] = serde_json::json!("assigned"); + + match diesel::update(user_sessions::table.filter(user_sessions::id.eq(session_uuid))) + .set(user_sessions::context_data.eq(&ctx)) + .execute(&mut db_conn) + { + Ok(_) => { + let mut result = Map::new(); + result.insert("success".into(), Dynamic::from(true)); + result.insert("session_id".into(), Dynamic::from(session_uuid.to_string())); + result.insert("assigned_to".into(), Dynamic::from(attendant)); + result.insert("message".into(), Dynamic::from("Conversation assigned")); + Dynamic::from(result) + } + Err(e) => create_error_result(&format!("Update error: {}", e)), + } + }) + .join() + .unwrap_or_else(|_| create_error_result("Thread panic")); + + result +} + +/// RESOLVE CONVERSATION - Mark conversation as resolved +/// +/// ```basic +/// RESOLVE CONVERSATION session_id +/// RESOLVE CONVERSATION session_id, "Issue fixed" +/// ``` +fn register_resolve_conversation(state: Arc, _user: UserSession, engine: &mut Engine) { + let state_clone = state.clone(); + + engine + .register_custom_syntax( + &["RESOLVE", "CONVERSATION", "$expr$"], + false, + move |context, inputs| { + let session_id = context.eval_expression_tree(&inputs[0])?.to_string(); + Ok(resolve_conversation_impl(&state_clone, &session_id, None)) + }, + ) + .unwrap(); + + let state_clone2 = state.clone(); + engine + .register_custom_syntax( + &["RESOLVE", "CONVERSATION", "$expr$", "$expr$"], + false, + move |context, inputs| { + let session_id = context.eval_expression_tree(&inputs[0])?.to_string(); + let reason = context.eval_expression_tree(&inputs[1])?.to_string(); + Ok(resolve_conversation_impl( + &state_clone2, + &session_id, + Some(reason), + )) + }, + ) + .unwrap(); + + let state_clone3 = state.clone(); + engine.register_fn("resolve_conversation", move |session_id: &str| -> Dynamic { + resolve_conversation_impl(&state_clone3, session_id, None) + }); +} + +fn resolve_conversation_impl( + state: &Arc, + session_id: &str, + reason: Option, +) -> Dynamic { + let conn = state.conn.clone(); + let session_uuid = match Uuid::parse_str(session_id) { + Ok(u) => u, + Err(_) => return create_error_result("Invalid session ID"), + }; + let reason_clone = reason.clone(); + + let result = std::thread::spawn(move || { + let mut db_conn = match conn.get() { + Ok(c) => c, + Err(e) => return create_error_result(&format!("DB error: {}", e)), + }; + + use crate::shared::models::schema::user_sessions; + + let session: UserSession = match user_sessions::table.find(session_uuid).first(&mut db_conn) + { + Ok(s) => s, + Err(_) => return create_error_result("Session not found"), + }; + + let mut ctx = session.context_data.clone(); + ctx["needs_human"] = serde_json::json!(false); + ctx["status"] = serde_json::json!("resolved"); + ctx["resolved_at"] = serde_json::json!(Utc::now().to_rfc3339()); + if let Some(r) = reason_clone { + ctx["resolution_reason"] = serde_json::json!(r); + } + + match diesel::update(user_sessions::table.filter(user_sessions::id.eq(session_uuid))) + .set(user_sessions::context_data.eq(&ctx)) + .execute(&mut db_conn) + { + Ok(_) => { + let mut result = Map::new(); + result.insert("success".into(), Dynamic::from(true)); + result.insert("session_id".into(), Dynamic::from(session_uuid.to_string())); + result.insert("message".into(), Dynamic::from("Conversation resolved")); + Dynamic::from(result) + } + Err(e) => create_error_result(&format!("Update error: {}", e)), + } + }) + .join() + .unwrap_or_else(|_| create_error_result("Thread panic")); + + result +} + +/// SET PRIORITY - Change conversation priority +/// +/// ```basic +/// SET PRIORITY session_id, "high" +/// SET PRIORITY session_id, "urgent" +/// SET PRIORITY session_id, 3 ' numeric (1=low, 2=normal, 3=high, 4=urgent) +/// ``` +fn register_set_priority(state: Arc, _user: UserSession, engine: &mut Engine) { + let state_clone = state.clone(); + + engine + .register_custom_syntax( + &["SET", "PRIORITY", "$expr$", "$expr$"], + false, + move |context, inputs| { + let session_id = context.eval_expression_tree(&inputs[0])?.to_string(); + let priority = context.eval_expression_tree(&inputs[1])?; + Ok(set_priority_impl(&state_clone, &session_id, priority)) + }, + ) + .unwrap(); + + let state_clone2 = state.clone(); + engine.register_fn( + "set_priority", + move |session_id: &str, priority: &str| -> Dynamic { + set_priority_impl( + &state_clone2, + session_id, + Dynamic::from(priority.to_string()), + ) + }, + ); +} + +fn set_priority_impl(state: &Arc, session_id: &str, priority: Dynamic) -> Dynamic { + let conn = state.conn.clone(); + let session_uuid = match Uuid::parse_str(session_id) { + Ok(u) => u, + Err(_) => return create_error_result("Invalid session ID"), + }; + + // Convert priority to numeric + let priority_num: i64 = if priority.is_int() { + priority.as_int().unwrap_or(2) + } else { + let p = priority.to_string().to_lowercase(); + match p.as_str() { + "low" => 1, + "normal" => 2, + "high" => 3, + "urgent" => 4, + _ => 2, + } + }; + + let result = std::thread::spawn(move || { + let mut db_conn = match conn.get() { + Ok(c) => c, + Err(e) => return create_error_result(&format!("DB error: {}", e)), + }; + + use crate::shared::models::schema::user_sessions; + + let session: UserSession = match user_sessions::table.find(session_uuid).first(&mut db_conn) + { + Ok(s) => s, + Err(_) => return create_error_result("Session not found"), + }; + + let mut ctx = session.context_data.clone(); + ctx["priority"] = serde_json::json!(priority_num); + + match diesel::update(user_sessions::table.filter(user_sessions::id.eq(session_uuid))) + .set(user_sessions::context_data.eq(&ctx)) + .execute(&mut db_conn) + { + Ok(_) => { + let mut result = Map::new(); + result.insert("success".into(), Dynamic::from(true)); + result.insert("priority".into(), Dynamic::from(priority_num)); + Dynamic::from(result) + } + Err(e) => create_error_result(&format!("Update error: {}", e)), + } + }) + .join() + .unwrap_or_else(|_| create_error_result("Thread panic")); + + result +} + +// ============================================================================ +// Attendant Management Keywords +// ============================================================================ + +/// GET ATTENDANTS - List available attendants +/// +/// ```basic +/// attendants = GET ATTENDANTS +/// FOR EACH att IN attendants.items +/// TALK att.name + " - " + att.status +/// NEXT +/// +/// ' Filter by status +/// online = GET ATTENDANTS "online" +/// ``` +fn register_get_attendants(state: Arc, _user: UserSession, engine: &mut Engine) { + let state_clone = state.clone(); + + engine + .register_custom_syntax(&["GET", "ATTENDANTS"], false, move |_context, _inputs| { + Ok(get_attendants_impl(&state_clone, None)) + }) + .unwrap(); + + let state_clone2 = state.clone(); + engine + .register_custom_syntax( + &["GET", "ATTENDANTS", "$expr$"], + false, + move |context, inputs| { + let filter = context.eval_expression_tree(&inputs[0])?.to_string(); + Ok(get_attendants_impl(&state_clone2, Some(filter))) + }, + ) + .unwrap(); + + let state_clone3 = state.clone(); + engine.register_fn("get_attendants", move || -> Dynamic { + get_attendants_impl(&state_clone3, None) + }); +} + +fn get_attendants_impl(_state: &Arc, status_filter: Option) -> Dynamic { + // Read from attendant.csv + let work_path = std::env::var("WORK_PATH").unwrap_or_else(|_| "./work".to_string()); + + let mut attendants = Vec::new(); + + // Scan for attendant.csv files + if let Ok(entries) = std::fs::read_dir(&work_path) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() && path.to_string_lossy().ends_with(".gbai") { + let attendant_path = path.join("attendant.csv"); + if attendant_path.exists() { + if let Ok(content) = std::fs::read_to_string(&attendant_path) { + for line in content.lines().skip(1) { + let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect(); + if parts.len() >= 4 { + let mut att = Map::new(); + att.insert("id".into(), Dynamic::from(parts[0].to_string())); + att.insert("name".into(), Dynamic::from(parts[1].to_string())); + att.insert("channel".into(), Dynamic::from(parts[2].to_string())); + att.insert( + "preferences".into(), + Dynamic::from(parts[3].to_string()), + ); + att.insert("status".into(), Dynamic::from("online".to_string())); // Default + if parts.len() >= 5 { + att.insert( + "department".into(), + Dynamic::from(parts[4].to_string()), + ); + } + attendants.push(Dynamic::from(att)); + } + } + } + } + } + } + } + + // Apply filter + if let Some(filter) = status_filter { + attendants.retain(|att| { + if let Some(map) = att.clone().try_cast::() { + if let Some(status) = map.get("status") { + return status.to_string().to_lowercase() == filter.to_lowercase(); + } + } + true + }); + } + + let mut result = Map::new(); + result.insert("success".into(), Dynamic::from(true)); + result.insert("count".into(), Dynamic::from(attendants.len() as i64)); + result.insert("items".into(), Dynamic::from(attendants)); + + Dynamic::from(result) +} + +/// SET ATTENDANT STATUS - Change attendant status +/// +/// ```basic +/// SET ATTENDANT STATUS "att-001", "busy" +/// SET ATTENDANT STATUS attendant_id, "away" +/// ``` +fn register_set_attendant_status(_state: Arc, _user: UserSession, engine: &mut Engine) { + engine + .register_custom_syntax( + &["SET", "ATTENDANT", "STATUS", "$expr$", "$expr$"], + false, + move |context, inputs| { + let attendant_id = context.eval_expression_tree(&inputs[0])?.to_string(); + let status = context.eval_expression_tree(&inputs[1])?.to_string(); + + // TODO: Store in database or memory + info!("Set attendant {} status to {}", attendant_id, status); + + let mut result = Map::new(); + result.insert("success".into(), Dynamic::from(true)); + result.insert("attendant_id".into(), Dynamic::from(attendant_id)); + result.insert("status".into(), Dynamic::from(status)); + Ok(Dynamic::from(result)) + }, + ) + .unwrap(); +} + +/// GET ATTENDANT STATS - Get performance metrics +/// +/// ```basic +/// stats = GET ATTENDANT STATS "att-001" +/// TALK "Resolved: " + stats.resolved_today +/// ``` +fn register_get_attendant_stats(state: Arc, _user: UserSession, engine: &mut Engine) { + let state_clone = state.clone(); + + engine + .register_custom_syntax( + &["GET", "ATTENDANT", "STATS", "$expr$"], + false, + move |context, inputs| { + let attendant_id = context.eval_expression_tree(&inputs[0])?.to_string(); + Ok(get_attendant_stats_impl(&state_clone, &attendant_id)) + }, + ) + .unwrap(); +} + +fn get_attendant_stats_impl(state: &Arc, attendant_id: &str) -> Dynamic { + let conn = state.conn.clone(); + let att_id = attendant_id.to_string(); + + let result = std::thread::spawn(move || { + let mut db_conn = match conn.get() { + Ok(c) => c, + Err(e) => return create_error_result(&format!("DB error: {}", e)), + }; + + use crate::shared::models::schema::user_sessions; + + // Count resolved conversations today + let today_start = Utc::now().date_naive().and_hms_opt(0, 0, 0).unwrap(); + + let resolved_today: i64 = user_sessions::table + .filter( + user_sessions::context_data + .retrieve_as_text("assigned_to") + .eq(&att_id), + ) + .filter( + user_sessions::context_data + .retrieve_as_text("status") + .eq("resolved"), + ) + .filter(user_sessions::updated_at.ge(today_start)) + .count() + .get_result(&mut db_conn) + .unwrap_or(0); + + // Count active conversations + let active: i64 = user_sessions::table + .filter( + user_sessions::context_data + .retrieve_as_text("assigned_to") + .eq(&att_id), + ) + .filter( + user_sessions::context_data + .retrieve_as_text("status") + .ne("resolved"), + ) + .count() + .get_result(&mut db_conn) + .unwrap_or(0); + + let mut result = Map::new(); + result.insert("success".into(), Dynamic::from(true)); + result.insert("attendant_id".into(), Dynamic::from(att_id)); + result.insert("resolved_today".into(), Dynamic::from(resolved_today)); + result.insert("active_conversations".into(), Dynamic::from(active)); + Dynamic::from(result) + }) + .join() + .unwrap_or_else(|_| create_error_result("Thread panic")); + + result +} + +// ============================================================================ +// LLM Assist Keywords +// ============================================================================ + +/// GET TIPS - Generate AI tips for conversation +/// +/// ```basic +/// tips = GET TIPS session_id, customer_message +/// FOR EACH tip IN tips.items +/// TALK tip.type + ": " + tip.content +/// NEXT +/// ``` +fn register_get_tips(state: Arc, _user: UserSession, engine: &mut Engine) { + let state_clone = state.clone(); + + engine + .register_custom_syntax( + &["GET", "TIPS", "$expr$", "$expr$"], + false, + move |context, inputs| { + let session_id = context.eval_expression_tree(&inputs[0])?.to_string(); + let message = context.eval_expression_tree(&inputs[1])?.to_string(); + Ok(get_tips_impl(&state_clone, &session_id, &message)) + }, + ) + .unwrap(); + + let state_clone2 = state.clone(); + engine.register_fn( + "get_tips", + move |session_id: &str, message: &str| -> Dynamic { + get_tips_impl(&state_clone2, session_id, message) + }, + ); +} + +fn get_tips_impl(state: &Arc, session_id: &str, message: &str) -> Dynamic { + // Call the LLM assist API internally + let rt = match tokio::runtime::Handle::try_current() { + Ok(rt) => rt, + Err(_) => { + return create_fallback_tips(message); + } + }; + + let state_clone = state.clone(); + let session_id_clone = session_id.to_string(); + let message_clone = message.to_string(); + + let result = rt.block_on(async move { + // Try to call the tips API + let session_uuid = match Uuid::parse_str(&session_id_clone) { + Ok(u) => u, + Err(_) => return create_fallback_tips(&message_clone), + }; + + // Generate tips using fallback for now + // In production, this would call crate::attendance::llm_assist::generate_tips + create_fallback_tips(&message_clone) + }); + + result +} + +fn create_fallback_tips(message: &str) -> Dynamic { + let msg_lower = message.to_lowercase(); + let mut tips = Vec::new(); + + // Urgent detection + if msg_lower.contains("urgent") || msg_lower.contains("asap") || msg_lower.contains("emergency") + { + let mut tip = Map::new(); + tip.insert("type".into(), Dynamic::from("warning")); + tip.insert( + "content".into(), + Dynamic::from("Customer indicates urgency - prioritize quick response"), + ); + tip.insert("priority".into(), Dynamic::from(1_i64)); + tips.push(Dynamic::from(tip)); + } + + // Question detection + if message.contains('?') { + let mut tip = Map::new(); + tip.insert("type".into(), Dynamic::from("intent")); + tip.insert( + "content".into(), + Dynamic::from("Customer is asking a question - provide clear answer"), + ); + tip.insert("priority".into(), Dynamic::from(2_i64)); + tips.push(Dynamic::from(tip)); + } + + // Problem detection + if msg_lower.contains("problem") + || msg_lower.contains("issue") + || msg_lower.contains("not working") + { + let mut tip = Map::new(); + tip.insert("type".into(), Dynamic::from("action")); + tip.insert( + "content".into(), + Dynamic::from("Customer reporting issue - acknowledge and gather details"), + ); + tip.insert("priority".into(), Dynamic::from(2_i64)); + tips.push(Dynamic::from(tip)); + } + + // Default tip + if tips.is_empty() { + let mut tip = Map::new(); + tip.insert("type".into(), Dynamic::from("general")); + tip.insert( + "content".into(), + Dynamic::from("Read carefully and respond helpfully"), + ); + tip.insert("priority".into(), Dynamic::from(3_i64)); + tips.push(Dynamic::from(tip)); + } + + let mut result = Map::new(); + result.insert("success".into(), Dynamic::from(true)); + result.insert("items".into(), Dynamic::from(tips)); + Dynamic::from(result) +} + +/// POLISH MESSAGE - Improve message with AI +/// +/// ```basic +/// polished = POLISH MESSAGE "thx for contacting us, ill help u" +/// TALK polished.text +/// +/// ' With tone +/// polished = POLISH MESSAGE message, "empathetic" +/// ``` +fn register_polish_message(state: Arc, _user: UserSession, engine: &mut Engine) { + let state_clone = state.clone(); + + engine + .register_custom_syntax( + &["POLISH", "MESSAGE", "$expr$"], + false, + move |context, inputs| { + let message = context.eval_expression_tree(&inputs[0])?.to_string(); + Ok(polish_message_impl(&state_clone, &message, "professional")) + }, + ) + .unwrap(); + + let state_clone2 = state.clone(); + engine + .register_custom_syntax( + &["POLISH", "MESSAGE", "$expr$", "$expr$"], + false, + move |context, inputs| { + let message = context.eval_expression_tree(&inputs[0])?.to_string(); + let tone = context.eval_expression_tree(&inputs[1])?.to_string(); + Ok(polish_message_impl(&state_clone2, &message, &tone)) + }, + ) + .unwrap(); + + let state_clone3 = state.clone(); + engine.register_fn("polish_message", move |message: &str| -> Dynamic { + polish_message_impl(&state_clone3, message, "professional") + }); +} + +fn polish_message_impl(_state: &Arc, message: &str, _tone: &str) -> Dynamic { + // Simple polishing without LLM (fallback) + let mut polished = message.to_string(); + + // Basic improvements + polished = polished + .replace("thx", "Thank you") + .replace("u ", "you ") + .replace(" u", " you") + .replace("ur ", "your ") + .replace("ill ", "I'll ") + .replace("dont ", "don't ") + .replace("cant ", "can't ") + .replace("wont ", "won't ") + .replace("im ", "I'm ") + .replace("ive ", "I've "); + + // Capitalize first letter + if let Some(first_char) = polished.chars().next() { + polished = first_char.to_uppercase().to_string() + &polished[1..]; + } + + // Ensure ending punctuation + if !polished.ends_with('.') && !polished.ends_with('!') && !polished.ends_with('?') { + polished.push('.'); + } + + let mut result = Map::new(); + result.insert("success".into(), Dynamic::from(true)); + result.insert("original".into(), Dynamic::from(message.to_string())); + result.insert("text".into(), Dynamic::from(polished.clone())); + result.insert("polished".into(), Dynamic::from(polished)); + Dynamic::from(result) +} + +/// GET SMART REPLIES - Get AI reply suggestions +/// +/// ```basic +/// replies = GET SMART REPLIES session_id +/// FOR EACH reply IN replies.items +/// TALK reply.tone + ": " + reply.text +/// NEXT +/// ``` +fn register_get_smart_replies(state: Arc, _user: UserSession, engine: &mut Engine) { + let state_clone = state.clone(); + + engine + .register_custom_syntax( + &["GET", "SMART", "REPLIES", "$expr$"], + false, + move |context, inputs| { + let session_id = context.eval_expression_tree(&inputs[0])?.to_string(); + Ok(get_smart_replies_impl(&state_clone, &session_id)) + }, + ) + .unwrap(); + + let state_clone2 = state.clone(); + engine.register_fn("get_smart_replies", move |session_id: &str| -> Dynamic { + get_smart_replies_impl(&state_clone2, session_id) + }); +} + +fn get_smart_replies_impl(_state: &Arc, _session_id: &str) -> Dynamic { + // Return default replies (fallback without LLM) + let mut replies = Vec::new(); + + let mut reply1 = Map::new(); + reply1.insert( + "text".into(), + Dynamic::from("Thank you for reaching out! I'd be happy to help you with that."), + ); + reply1.insert("tone".into(), Dynamic::from("friendly")); + reply1.insert("category".into(), Dynamic::from("greeting")); + replies.push(Dynamic::from(reply1)); + + let mut reply2 = Map::new(); + reply2.insert( + "text".into(), + Dynamic::from("I understand your concern. Let me look into this for you right away."), + ); + reply2.insert("tone".into(), Dynamic::from("empathetic")); + reply2.insert("category".into(), Dynamic::from("acknowledgment")); + replies.push(Dynamic::from(reply2)); + + let mut reply3 = Map::new(); + reply3.insert( + "text".into(), + Dynamic::from("Is there anything else I can help you with today?"), + ); + reply3.insert("tone".into(), Dynamic::from("professional")); + reply3.insert("category".into(), Dynamic::from("follow_up")); + replies.push(Dynamic::from(reply3)); + + let mut result = Map::new(); + result.insert("success".into(), Dynamic::from(true)); + result.insert("items".into(), Dynamic::from(replies)); + Dynamic::from(result) +} + +/// GET SUMMARY - Get conversation summary +/// +/// ```basic +/// summary = GET SUMMARY session_id +/// TALK "Brief: " + summary.brief +/// TALK "Key points: " + summary.key_points +/// ``` +fn register_get_summary(state: Arc, _user: UserSession, engine: &mut Engine) { + let state_clone = state.clone(); + + engine + .register_custom_syntax( + &["GET", "SUMMARY", "$expr$"], + false, + move |context, inputs| { + let session_id = context.eval_expression_tree(&inputs[0])?.to_string(); + Ok(get_summary_impl(&state_clone, &session_id)) + }, + ) + .unwrap(); + + let state_clone2 = state.clone(); + engine.register_fn("get_summary", move |session_id: &str| -> Dynamic { + get_summary_impl(&state_clone2, session_id) + }); +} + +fn get_summary_impl(state: &Arc, session_id: &str) -> Dynamic { + let conn = state.conn.clone(); + let session_uuid = match Uuid::parse_str(session_id) { + Ok(u) => u, + Err(_) => return create_error_result("Invalid session ID"), + }; + + let result = std::thread::spawn(move || { + let mut db_conn = match conn.get() { + Ok(c) => c, + Err(e) => return create_error_result(&format!("DB error: {}", e)), + }; + + use crate::shared::models::schema::message_history; + + // Get message count + let message_count: i64 = message_history::table + .filter(message_history::session_id.eq(session_uuid)) + .count() + .get_result(&mut db_conn) + .unwrap_or(0); + + let mut result = Map::new(); + result.insert("success".into(), Dynamic::from(true)); + result.insert("session_id".into(), Dynamic::from(session_uuid.to_string())); + result.insert("message_count".into(), Dynamic::from(message_count)); + result.insert( + "brief".into(), + Dynamic::from(format!("Conversation with {} messages", message_count)), + ); + result.insert("key_points".into(), Dynamic::from(Vec::::new())); + result.insert("sentiment_trend".into(), Dynamic::from("neutral")); + Dynamic::from(result) + }) + .join() + .unwrap_or_else(|_| create_error_result("Thread panic")); + + result +} + +/// ANALYZE SENTIMENT - Analyze customer sentiment +/// +/// ```basic +/// sentiment = ANALYZE SENTIMENT session_id, message +/// IF sentiment.escalation_risk = "high" THEN +/// SET PRIORITY session_id, "urgent" +/// END IF +/// ``` +fn register_analyze_sentiment(state: Arc, _user: UserSession, engine: &mut Engine) { + let state_clone = state.clone(); + + engine + .register_custom_syntax( + &["ANALYZE", "SENTIMENT", "$expr$", "$expr$"], + false, + move |context, inputs| { + let session_id = context.eval_expression_tree(&inputs[0])?.to_string(); + let message = context.eval_expression_tree(&inputs[1])?.to_string(); + Ok(analyze_sentiment_impl(&state_clone, &session_id, &message)) + }, + ) + .unwrap(); + + let state_clone2 = state.clone(); + engine.register_fn( + "analyze_sentiment", + move |session_id: &str, message: &str| -> Dynamic { + analyze_sentiment_impl(&state_clone2, session_id, message) + }, + ); +} + +fn analyze_sentiment_impl(_state: &Arc, _session_id: &str, message: &str) -> Dynamic { + // Keyword-based sentiment analysis (fallback without LLM) + let msg_lower = message.to_lowercase(); + + let positive_words = [ + "thank", + "great", + "perfect", + "awesome", + "excellent", + "good", + "happy", + "love", + ]; + let negative_words = [ + "angry", + "frustrated", + "terrible", + "awful", + "horrible", + "hate", + "disappointed", + "problem", + "issue", + ]; + let urgent_words = [ + "urgent", + "asap", + "immediately", + "emergency", + "now", + "critical", + ]; + + let positive_count = positive_words + .iter() + .filter(|w| msg_lower.contains(*w)) + .count(); + let negative_count = negative_words + .iter() + .filter(|w| msg_lower.contains(*w)) + .count(); + let urgent_count = urgent_words + .iter() + .filter(|w| msg_lower.contains(*w)) + .count(); + + let (overall, score, emoji) = if positive_count > negative_count { + ("positive", 0.5, "😊") + } else if negative_count > positive_count { + ("negative", -0.5, "😟") + } else { + ("neutral", 0.0, "😐") + }; + + let escalation_risk = if negative_count >= 3 { + "high" + } else if negative_count >= 1 { + "medium" + } else { + "low" + }; + + let urgency = if urgent_count >= 2 { + "urgent" + } else if urgent_count >= 1 { + "high" + } else { + "normal" + }; + + let mut result = Map::new(); + result.insert("success".into(), Dynamic::from(true)); + result.insert("overall".into(), Dynamic::from(overall)); + result.insert("score".into(), Dynamic::from(score)); + result.insert("emoji".into(), Dynamic::from(emoji)); + result.insert("escalation_risk".into(), Dynamic::from(escalation_risk)); + result.insert("urgency".into(), Dynamic::from(urgency)); + Dynamic::from(result) +} + +// ============================================================================ +// Customer Journey Keywords +// ============================================================================ + +/// TAG CONVERSATION - Add tags to conversation +/// +/// ```basic +/// TAG CONVERSATION session_id, "billing" +/// TAG CONVERSATION session_id, "vip", "urgent" +/// ``` +fn register_tag_conversation(state: Arc, _user: UserSession, engine: &mut Engine) { + let state_clone = state.clone(); + + engine + .register_custom_syntax( + &["TAG", "CONVERSATION", "$expr$", "$expr$"], + false, + move |context, inputs| { + let session_id = context.eval_expression_tree(&inputs[0])?.to_string(); + let tag = context.eval_expression_tree(&inputs[1])?.to_string(); + Ok(tag_conversation_impl(&state_clone, &session_id, vec![tag])) + }, + ) + .unwrap(); + + let state_clone2 = state.clone(); + engine.register_fn( + "tag_conversation", + move |session_id: &str, tag: &str| -> Dynamic { + tag_conversation_impl(&state_clone2, session_id, vec![tag.to_string()]) + }, + ); +} + +fn tag_conversation_impl(state: &Arc, session_id: &str, tags: Vec) -> Dynamic { + let conn = state.conn.clone(); + let session_uuid = match Uuid::parse_str(session_id) { + Ok(u) => u, + Err(_) => return create_error_result("Invalid session ID"), + }; + let tags_clone = tags.clone(); + + let result = std::thread::spawn(move || { + let mut db_conn = match conn.get() { + Ok(c) => c, + Err(e) => return create_error_result(&format!("DB error: {}", e)), + }; + + use crate::shared::models::schema::user_sessions; + + let session: UserSession = match user_sessions::table.find(session_uuid).first(&mut db_conn) + { + Ok(s) => s, + Err(_) => return create_error_result("Session not found"), + }; + + let mut ctx = session.context_data.clone(); + + // Get existing tags or create empty array + let mut existing_tags: Vec = ctx + .get("tags") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + // Add new tags + for tag in tags_clone { + if !existing_tags.contains(&tag) { + existing_tags.push(tag); + } + } + + ctx["tags"] = serde_json::json!(existing_tags); + + match diesel::update(user_sessions::table.filter(user_sessions::id.eq(session_uuid))) + .set(user_sessions::context_data.eq(&ctx)) + .execute(&mut db_conn) + { + Ok(_) => { + let mut result = Map::new(); + result.insert("success".into(), Dynamic::from(true)); + result.insert( + "tags".into(), + Dynamic::from( + existing_tags + .iter() + .map(|t| Dynamic::from(t.clone())) + .collect::>(), + ), + ); + Dynamic::from(result) + } + Err(e) => create_error_result(&format!("Update error: {}", e)), + } + }) + .join() + .unwrap_or_else(|_| create_error_result("Thread panic")); + + result +} + +/// ADD NOTE - Add internal note to conversation +/// +/// ```basic +/// ADD NOTE session_id, "Customer is a VIP - handle with care" +/// ADD NOTE session_id, note_text, attendant_id +/// ``` +fn register_add_note(state: Arc, _user: UserSession, engine: &mut Engine) { + let state_clone = state.clone(); + + engine + .register_custom_syntax( + &["ADD", "NOTE", "$expr$", "$expr$"], + false, + move |context, inputs| { + let session_id = context.eval_expression_tree(&inputs[0])?.to_string(); + let note = context.eval_expression_tree(&inputs[1])?.to_string(); + Ok(add_note_impl(&state_clone, &session_id, ¬e, None)) + }, + ) + .unwrap(); + + let state_clone2 = state.clone(); + engine.register_fn("add_note", move |session_id: &str, note: &str| -> Dynamic { + add_note_impl(&state_clone2, session_id, note, None) + }); +} + +fn add_note_impl( + state: &Arc, + session_id: &str, + note: &str, + author: Option, +) -> Dynamic { + let conn = state.conn.clone(); + let session_uuid = match Uuid::parse_str(session_id) { + Ok(u) => u, + Err(_) => return create_error_result("Invalid session ID"), + }; + let note_clone = note.to_string(); + let author_clone = author.clone(); + + let result = std::thread::spawn(move || { + let mut db_conn = match conn.get() { + Ok(c) => c, + Err(e) => return create_error_result(&format!("DB error: {}", e)), + }; + + use crate::shared::models::schema::user_sessions; + + let session: UserSession = match user_sessions::table.find(session_uuid).first(&mut db_conn) + { + Ok(s) => s, + Err(_) => return create_error_result("Session not found"), + }; + + let mut ctx = session.context_data.clone(); + + // Get existing notes or create empty array + let mut notes: Vec = ctx + .get("notes") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + // Add new note + notes.push(serde_json::json!({ + "text": note_clone, + "author": author_clone.unwrap_or_else(|| "system".to_string()), + "timestamp": Utc::now().to_rfc3339() + })); + + ctx["notes"] = serde_json::json!(notes); + + match diesel::update(user_sessions::table.filter(user_sessions::id.eq(session_uuid))) + .set(user_sessions::context_data.eq(&ctx)) + .execute(&mut db_conn) + { + Ok(_) => { + let mut result = Map::new(); + result.insert("success".into(), Dynamic::from(true)); + result.insert("note_count".into(), Dynamic::from(notes.len() as i64)); + Dynamic::from(result) + } + Err(e) => create_error_result(&format!("Update error: {}", e)), + } + }) + .join() + .unwrap_or_else(|_| create_error_result("Thread panic")); + + result +} + +/// GET CUSTOMER HISTORY - Get previous interactions +/// +/// ```basic +/// history = GET CUSTOMER HISTORY user_id +/// TALK "Previous sessions: " + history.session_count +/// FOR EACH session IN history.sessions +/// TALK session.channel + " - " + session.resolved_at +/// NEXT +/// ``` +fn register_get_customer_history(state: Arc, _user: UserSession, engine: &mut Engine) { + let state_clone = state.clone(); + + engine + .register_custom_syntax( + &["GET", "CUSTOMER", "HISTORY", "$expr$"], + false, + move |context, inputs| { + let user_id = context.eval_expression_tree(&inputs[0])?.to_string(); + Ok(get_customer_history_impl(&state_clone, &user_id)) + }, + ) + .unwrap(); + + let state_clone2 = state.clone(); + engine.register_fn("get_customer_history", move |user_id: &str| -> Dynamic { + get_customer_history_impl(&state_clone2, user_id) + }); +} + +fn get_customer_history_impl(state: &Arc, user_id: &str) -> Dynamic { + let conn = state.conn.clone(); + let user_uuid = match Uuid::parse_str(user_id) { + Ok(u) => u, + Err(_) => return create_error_result("Invalid user ID"), + }; + + let result = std::thread::spawn(move || { + let mut db_conn = match conn.get() { + Ok(c) => c, + Err(e) => return create_error_result(&format!("DB error: {}", e)), + }; + + use crate::shared::models::schema::user_sessions; + + let sessions: Vec = user_sessions::table + .filter(user_sessions::user_id.eq(user_uuid)) + .order(user_sessions::created_at.desc()) + .limit(10) + .load(&mut db_conn) + .unwrap_or_default(); + + let session_items: Vec = sessions + .iter() + .map(|s| { + let mut item = Map::new(); + item.insert("session_id".into(), Dynamic::from(s.id.to_string())); + item.insert( + "channel".into(), + Dynamic::from( + s.context_data + .get("channel") + .and_then(|v| v.as_str()) + .unwrap_or("web") + .to_string(), + ), + ); + item.insert( + "status".into(), + Dynamic::from( + s.context_data + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + ), + ); + item.insert("created_at".into(), Dynamic::from(s.created_at.to_string())); + Dynamic::from(item) + }) + .collect(); + + let mut result = Map::new(); + result.insert("success".into(), Dynamic::from(true)); + result.insert("user_id".into(), Dynamic::from(user_uuid.to_string())); + result.insert("session_count".into(), Dynamic::from(sessions.len() as i64)); + result.insert("sessions".into(), Dynamic::from(session_items)); + Dynamic::from(result) + }) + .join() + .unwrap_or_else(|_| create_error_result("Thread panic")); + + result +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +fn create_error_result(message: &str) -> Dynamic { + let mut result = Map::new(); + result.insert("success".into(), Dynamic::from(false)); + result.insert("error".into(), Dynamic::from(message.to_string())); + Dynamic::from(result) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fallback_tips_urgent() { + let tips = create_fallback_tips("This is URGENT! Help now!"); + let result = tips.try_cast::().unwrap(); + assert!(result.get("success").unwrap().as_bool().unwrap()); + } + + #[test] + fn test_polish_message() { + let state = Arc::new(AppState::default()); + let result = polish_message_impl(&state, "thx for ur msg", "professional"); + let map = result.try_cast::().unwrap(); + let polished = map.get("polished").unwrap().to_string(); + assert!(polished.contains("Thank you")); + } + + #[test] + fn test_sentiment_analysis() { + let state = Arc::new(AppState::default()); + + // Test positive + let result = analyze_sentiment_impl(&state, "test", "Thank you so much! This is great!"); + let map = result.try_cast::().unwrap(); + assert_eq!(map.get("overall").unwrap().to_string(), "positive"); + + // Test negative + let result = analyze_sentiment_impl(&state, "test", "This is terrible! I'm so frustrated!"); + let map = result.try_cast::().unwrap(); + assert_eq!(map.get("overall").unwrap().to_string(), "negative"); + } + + #[test] + fn test_smart_replies() { + let state = Arc::new(AppState::default()); + let result = get_smart_replies_impl(&state, "test-session"); + let map = result.try_cast::().unwrap(); + assert!(map.get("success").unwrap().as_bool().unwrap()); + + let items = map + .get("items") + .unwrap() + .clone() + .try_cast::>() + .unwrap(); + assert_eq!(items.len(), 3); + } +} diff --git a/src/basic/keywords/crm/mod.rs b/src/basic/keywords/crm/mod.rs index 25af4c62..cb042230 100644 --- a/src/basic/keywords/crm/mod.rs +++ b/src/basic/keywords/crm/mod.rs @@ -1,3 +1,4 @@ +pub mod attendance; pub mod score_lead; use crate::shared::models::UserSession; @@ -6,12 +7,21 @@ use log::debug; use rhai::Engine; use std::sync::Arc; +/// Register all CRM keywords including: +/// - Lead scoring (SCORE LEAD, QUALIFY LEAD, AI SCORE LEAD) +/// - Attendance/Queue management (GET QUEUE, ASSIGN CONVERSATION, etc.) +/// - LLM Assist (GET TIPS, POLISH MESSAGE, ANALYZE SENTIMENT, etc.) +/// - Customer journey (TAG CONVERSATION, ADD NOTE, GET CUSTOMER HISTORY) pub fn register_crm_keywords(state: Arc, user: UserSession, engine: &mut Engine) { + // Lead scoring keywords score_lead::score_lead_keyword(state.clone(), user.clone(), engine); score_lead::get_lead_score_keyword(state.clone(), user.clone(), engine); score_lead::qualify_lead_keyword(state.clone(), user.clone(), engine); score_lead::update_lead_score_keyword(state.clone(), user.clone(), engine); - score_lead::ai_score_lead_keyword(state, user, engine); + score_lead::ai_score_lead_keyword(state.clone(), user.clone(), engine); - debug!("Registered all CRM keywords"); + // Attendance/Queue management keywords + attendance::register_attendance_keywords(state.clone(), user.clone(), engine); + + debug!("Registered all CRM keywords (lead scoring + attendance + LLM assist)"); } diff --git a/templates/sales/attendance-crm.gbai/attendance-crm.gbdialog/attendant-helper.bas b/templates/sales/attendance-crm.gbai/attendance-crm.gbdialog/attendant-helper.bas new file mode 100644 index 00000000..70c2cefe --- /dev/null +++ b/templates/sales/attendance-crm.gbai/attendance-crm.gbdialog/attendant-helper.bas @@ -0,0 +1,415 @@ +REM Attendant Helper - LLM Assist Tools for Human Agents +REM Provides AI-powered assistance to human attendants during conversations. +REM Can be called manually by attendant or triggered automatically. +REM +REM Usage from WhatsApp: /tips, /polish, /replies, /summary +REM Usage from Web Console: Buttons in attendant UI + +PARAM action AS STRING LIKE "tips" DESCRIPTION "Action: tips, polish, replies, summary, sentiment, suggest_transfer" +PARAM session_id AS STRING DESCRIPTION "Session ID of the conversation" +PARAM message AS STRING DESCRIPTION "Message to analyze or polish" +PARAM tone AS STRING LIKE "professional" DESCRIPTION "Tone for polish: professional, friendly, empathetic, formal" + +DESCRIPTION "AI-powered tools to help human attendants respond faster and better" + +' Validate session +IF session_id IS NULL OR session_id = "" THEN + RETURN {"success": FALSE, "error": "Session ID required"} +END IF + +' Get session context +customer_name = GET SESSION session_id, "name" +channel = GET SESSION session_id, "channel" +customer_tier = GET SESSION session_id, "customer_tier" +tags = GET SESSION session_id, "tags" + +' ===================================================================== +' ACTION: TIPS - Generate contextual tips for the attendant +' ===================================================================== +IF action = "tips" THEN + IF message IS NULL OR message = "" THEN + ' Get last customer message from session + message = GET SESSION session_id, "last_customer_message" + END IF + + IF message IS NULL OR message = "" THEN + RETURN {"success": FALSE, "error": "No message to analyze"} + END IF + + ' Generate tips using LLM assist + tips = GET TIPS session_id, message + + ' Add context-specific tips + context_tips = [] + + ' VIP customer tip + IF customer_tier = "vip" OR customer_tier = "enterprise" THEN + APPEND context_tips, { + "type": "warning", + "content": "⭐ VIP Customer - Handle with extra care and priority", + "priority": 1 + } + END IF + + ' Check customer history + history = GET CUSTOMER HISTORY GET SESSION session_id, "user_id" + IF history.session_count > 5 THEN + APPEND context_tips, { + "type": "history", + "content": "Returning customer with " + history.session_count + " previous interactions", + "priority": 2 + } + END IF + + ' Check for open issues + open_cases = FIND "cases", "customer_id='" + GET SESSION session_id, "customer_id" + "' AND status='open'" + IF UBOUND(open_cases) > 0 THEN + APPEND context_tips, { + "type": "warning", + "content": "Customer has " + UBOUND(open_cases) + " open support cases", + "priority": 1 + } + END IF + + ' Merge context tips with LLM tips + all_tips = [] + FOR EACH tip IN context_tips + APPEND all_tips, tip + NEXT + FOR EACH tip IN tips.items + APPEND all_tips, tip + NEXT + + ' Sort by priority + all_tips = SORT all_tips BY "priority" ASC + + RETURN { + "success": TRUE, + "customer": customer_name, + "channel": channel, + "tips": all_tips + } +END IF + +' ===================================================================== +' ACTION: POLISH - Improve message before sending +' ===================================================================== +IF action = "polish" THEN + IF message IS NULL OR message = "" THEN + RETURN {"success": FALSE, "error": "No message to polish"} + END IF + + ' Default tone if not specified + IF tone IS NULL OR tone = "" THEN + tone = "professional" + + ' Adjust tone based on context + sentiment = GET SESSION session_id, "last_sentiment" + IF sentiment = "negative" THEN + tone = "empathetic" + END IF + + IF customer_tier = "vip" THEN + tone = "formal" + END IF + END IF + + ' Polish the message + result = POLISH MESSAGE message, tone + + ' Add customer name if not present + polished = result.polished + IF NOT (polished CONTAINS customer_name) AND customer_name IS NOT NULL AND customer_name <> "Unknown" THEN + ' Check if it starts with a greeting + IF polished STARTS WITH "Olá" OR polished STARTS WITH "Oi" OR polished STARTS WITH "Hi" OR polished STARTS WITH "Hello" THEN + ' Add name after greeting + polished = REPLACE(polished, "Olá", "Olá " + customer_name) + polished = REPLACE(polished, "Oi", "Oi " + customer_name) + polished = REPLACE(polished, "Hi", "Hi " + customer_name) + polished = REPLACE(polished, "Hello", "Hello " + customer_name) + END IF + END IF + + RETURN { + "success": TRUE, + "original": message, + "polished": polished, + "tone": tone, + "changes": result.changes + } +END IF + +' ===================================================================== +' ACTION: REPLIES - Get smart reply suggestions +' ===================================================================== +IF action = "replies" THEN + ' Get conversation history for context + history_items = GET SESSION session_id, "message_history" + + ' Build history array for API + history = [] + IF history_items IS NOT NULL THEN + FOR EACH msg IN history_items LAST 10 + APPEND history, { + "role": msg.sender, + "content": msg.text, + "timestamp": msg.timestamp + } + NEXT + END IF + + ' Get smart replies + replies = GET SMART REPLIES session_id + + ' Customize based on context + customized_replies = [] + + FOR EACH reply IN replies.items + custom_reply = reply + + ' Add customer name to greeting replies + IF reply.category = "greeting" AND customer_name IS NOT NULL AND customer_name <> "Unknown" THEN + custom_reply.text = REPLACE(reply.text, "!", ", " + customer_name + "!") + END IF + + ' Add urgency for VIP + IF customer_tier = "vip" AND reply.category = "acknowledgment" THEN + custom_reply.text = "Como cliente prioritário, " + LOWER(LEFT(reply.text, 1)) + MID(reply.text, 2) + END IF + + APPEND customized_replies, custom_reply + NEXT + + ' Add context-specific suggestions + last_intent = GET SESSION session_id, "intent" + + IF last_intent = "pricing" THEN + APPEND customized_replies, { + "text": "Posso preparar uma proposta personalizada para você. Qual seria o melhor email para enviar?", + "tone": "professional", + "category": "action", + "confidence": 0.9 + } + END IF + + IF last_intent = "support" THEN + APPEND customized_replies, { + "text": "Vou criar um ticket de suporte para acompanhar sua solicitação. Pode me dar mais detalhes do problema?", + "tone": "helpful", + "category": "action", + "confidence": 0.85 + } + END IF + + IF last_intent = "cancellation" THEN + APPEND customized_replies, { + "text": "Antes de prosseguir, gostaria de entender melhor o motivo. Há algo que possamos fazer para resolver sua insatisfação?", + "tone": "empathetic", + "category": "retention", + "confidence": 0.9 + } + END IF + + RETURN { + "success": TRUE, + "replies": customized_replies, + "context": { + "customer": customer_name, + "tier": customer_tier, + "intent": last_intent + } + } +END IF + +' ===================================================================== +' ACTION: SUMMARY - Get conversation summary +' ===================================================================== +IF action = "summary" THEN + ' Get LLM summary + summary = GET SUMMARY session_id + + ' Enhance with CRM data + customer_id = GET SESSION session_id, "customer_id" + + IF customer_id IS NOT NULL THEN + ' Get account info + account = FIND "accounts", "id='" + customer_id + "'" + IF account IS NOT NULL THEN + summary.account_name = account.name + summary.account_type = account.type + summary.account_since = account.created_at + END IF + + ' Get recent orders + recent_orders = FIND "orders", "customer_id='" + customer_id + "' ORDER BY created_at DESC LIMIT 3" + summary.recent_orders = [] + FOR EACH order IN recent_orders + APPEND summary.recent_orders, { + "id": order.id, + "date": order.created_at, + "total": order.total, + "status": order.status + } + NEXT + + ' Get open cases + open_cases = FIND "cases", "customer_id='" + customer_id + "' AND status='open'" + summary.open_cases = UBOUND(open_cases) + + ' Calculate customer value + total_orders = FIND "orders", "customer_id='" + customer_id + "'" + total_value = 0 + FOR EACH order IN total_orders + total_value = total_value + order.total + NEXT + summary.lifetime_value = total_value + END IF + + ' Get notes from this session + notes = GET SESSION session_id, "notes" + summary.internal_notes = notes + + ' Get tags + summary.tags = tags + + RETURN { + "success": TRUE, + "summary": summary + } +END IF + +' ===================================================================== +' ACTION: SENTIMENT - Analyze current sentiment +' ===================================================================== +IF action = "sentiment" THEN + IF message IS NULL OR message = "" THEN + message = GET SESSION session_id, "last_customer_message" + END IF + + IF message IS NULL OR message = "" THEN + RETURN {"success": FALSE, "error": "No message to analyze"} + END IF + + ' Get sentiment analysis + sentiment = ANALYZE SENTIMENT session_id, message + + ' Get trend + previous_sentiment = GET SESSION session_id, "sentiment_history" + trend = "stable" + + IF previous_sentiment IS NOT NULL AND UBOUND(previous_sentiment) >= 2 THEN + last_two = SLICE(previous_sentiment, -2) + IF sentiment.score > last_two[1].score AND last_two[1].score > last_two[0].score THEN + trend = "improving" + ELSE IF sentiment.score < last_two[1].score AND last_two[1].score < last_two[0].score THEN + trend = "declining" + END IF + END IF + + ' Store for tracking + IF previous_sentiment IS NULL THEN + previous_sentiment = [] + END IF + APPEND previous_sentiment, {"score": sentiment.score, "timestamp": NOW()} + SET SESSION session_id, "sentiment_history", previous_sentiment + + ' Add recommendations based on sentiment + recommendations = [] + + IF sentiment.escalation_risk = "high" THEN + APPEND recommendations, "Consider offering compensation or immediate resolution" + APPEND recommendations, "Use empathetic language and acknowledge frustration" + APPEND recommendations, "Avoid technical jargon - keep it simple" + END IF + + IF sentiment.urgency = "urgent" THEN + APPEND recommendations, "Respond quickly - customer is time-sensitive" + APPEND recommendations, "Provide immediate action or timeline" + END IF + + IF sentiment.overall = "positive" THEN + APPEND recommendations, "Good opportunity for upsell or feedback request" + APPEND recommendations, "Ask for referral or review" + END IF + + RETURN { + "success": TRUE, + "sentiment": sentiment, + "trend": trend, + "recommendations": recommendations + } +END IF + +' ===================================================================== +' ACTION: SUGGEST_TRANSFER - Check if transfer is recommended +' ===================================================================== +IF action = "suggest_transfer" THEN + ' Analyze situation + sentiment = ANALYZE SENTIMENT session_id, GET SESSION session_id, "last_customer_message" + frustration_count = GET SESSION session_id, "frustration_count" + message_count = GET SESSION session_id, "message_count" + intent = GET SESSION session_id, "intent" + + should_transfer = FALSE + transfer_reason = "" + transfer_department = "support" + transfer_priority = "normal" + + ' High escalation risk + IF sentiment.escalation_risk = "high" THEN + should_transfer = TRUE + transfer_reason = "High escalation risk - customer very frustrated" + transfer_priority = "urgent" + END IF + + ' Technical issue beyond first-line + IF intent = "technical" AND message_count > 5 THEN + should_transfer = TRUE + transfer_reason = "Complex technical issue - needs specialist" + transfer_department = "technical" + END IF + + ' Billing/refund issues + IF intent = "cancellation" OR intent = "refund" THEN + IF customer_tier = "vip" OR customer_tier = "enterprise" THEN + should_transfer = TRUE + transfer_reason = "VIP customer with billing/refund request" + transfer_department = "finance" + transfer_priority = "high" + END IF + END IF + + ' Long conversation without resolution + IF message_count > 10 AND NOT should_transfer THEN + should_transfer = TRUE + transfer_reason = "Extended conversation - may need escalation" + END IF + + ' Get available attendants for department + available = GET ATTENDANTS "online" + department_available = [] + FOR EACH att IN available.items + IF att.department = transfer_department OR att.channel = "all" THEN + APPEND department_available, att + END IF + NEXT + + RETURN { + "success": TRUE, + "should_transfer": should_transfer, + "reason": transfer_reason, + "suggested_department": transfer_department, + "suggested_priority": transfer_priority, + "available_attendants": department_available, + "context": { + "sentiment": sentiment.overall, + "escalation_risk": sentiment.escalation_risk, + "message_count": message_count, + "intent": intent, + "customer_tier": customer_tier + } + } +END IF + +' Unknown action +RETURN {"success": FALSE, "error": "Unknown action: " + action} diff --git a/templates/sales/attendance-crm.gbai/attendance-crm.gbdialog/crm-automations.bas b/templates/sales/attendance-crm.gbai/attendance-crm.gbdialog/crm-automations.bas new file mode 100644 index 00000000..419098c5 --- /dev/null +++ b/templates/sales/attendance-crm.gbai/attendance-crm.gbdialog/crm-automations.bas @@ -0,0 +1,461 @@ +REM CRM Automations - Follow-ups, Collections, Lead Nurturing, Sales Pipeline +REM Scheduled jobs that run automatically to handle common CRM workflows. +REM Uses the new attendance keywords for queue integration. +REM +REM Schedules: +REM SET SCHEDULE "follow-ups", "0 9 * * 1-5" (9am weekdays) +REM SET SCHEDULE "collections", "0 8 * * 1-5" (8am weekdays) +REM SET SCHEDULE "lead-nurture", "0 10 * * 1-5" (10am weekdays) +REM SET SCHEDULE "pipeline-review", "0 14 * * 5" (2pm Fridays) + +PARAM job_name AS STRING LIKE "follow-ups" DESCRIPTION "Job to run: follow-ups, collections, lead-nurture, pipeline-review, all" + +DESCRIPTION "Automated CRM workflows for follow-ups, collections, lead nurturing, and pipeline management" + +PRINT "=== CRM Automations Starting: " + job_name + " ===" +PRINT "Time: " + FORMAT(NOW(), "yyyy-MM-dd HH:mm:ss") + +results = {} +results.job = job_name +results.started_at = NOW() +results.actions = [] + +' ===================================================================== +' FOLLOW-UPS - Automated follow-up sequences +' ===================================================================== +IF job_name = "follow-ups" OR job_name = "all" THEN + PRINT "" + PRINT "--- Running Follow-ups ---" + + ' 1-day follow-up: Thank you message for new leads + leads_1_day = FIND "leads", "status='new' AND DATEDIFF(NOW(), created_at) = 1" + PRINT "1-day follow-ups: " + UBOUND(leads_1_day) + + FOR EACH lead IN leads_1_day + ' Send thank you message + IF lead.phone IS NOT NULL THEN + SEND TEMPLATE lead.phone, "follow_up_thanks", { + "name": lead.name, + "interest": lead.interest OR "our services" + } + END IF + + IF lead.email IS NOT NULL THEN + SEND MAIL lead.email, "Obrigado pelo seu interesse!", "Olá " + lead.name + ",\n\nObrigado por entrar em contato conosco. Estamos à disposição para ajudá-lo.\n\nAtenciosamente,\nEquipe de Vendas" + END IF + + ' Update lead status + UPDATE "leads", "id='" + lead.id + "'", { + "status": "contacted", + "last_contact": NOW(), + "follow_up_1_sent": NOW() + } + + ' Log activity + SAVE "activities", { + "type": "follow_up", + "lead_id": lead.id, + "description": "1-day thank you sent", + "created_at": NOW() + } + + APPEND results.actions, {"type": "follow_up_1", "lead": lead.name} + NEXT + + ' 3-day follow-up: Value proposition + leads_3_day = FIND "leads", "status='contacted' AND DATEDIFF(NOW(), last_contact) = 3 AND follow_up_3_sent IS NULL" + PRINT "3-day follow-ups: " + UBOUND(leads_3_day) + + FOR EACH lead IN leads_3_day + IF lead.phone IS NOT NULL THEN + SEND TEMPLATE lead.phone, "follow_up_value", { + "name": lead.name, + "interest": lead.interest OR "our services" + } + END IF + + UPDATE "leads", "id='" + lead.id + "'", { + "last_contact": NOW(), + "follow_up_3_sent": NOW() + } + + SAVE "activities", { + "type": "follow_up", + "lead_id": lead.id, + "description": "3-day value proposition sent", + "created_at": NOW() + } + + APPEND results.actions, {"type": "follow_up_3", "lead": lead.name} + NEXT + + ' 7-day follow-up: Special offer for hot leads + leads_7_day = FIND "leads", "status IN ('contacted', 'nurturing') AND DATEDIFF(NOW(), last_contact) = 7 AND follow_up_7_sent IS NULL AND score >= 50" + PRINT "7-day follow-ups: " + UBOUND(leads_7_day) + + FOR EACH lead IN leads_7_day + IF lead.phone IS NOT NULL THEN + SEND TEMPLATE lead.phone, "follow_up_offer", { + "name": lead.name, + "discount": "10%" + } + END IF + + UPDATE "leads", "id='" + lead.id + "'", { + "status": "offer_sent", + "last_contact": NOW(), + "follow_up_7_sent": NOW() + } + + ' Alert sales for hot leads + IF lead.score >= 70 THEN + attendants = GET ATTENDANTS "online" + FOR EACH att IN attendants.items + IF att.department = "commercial" OR att.preferences CONTAINS "sales" THEN + SEND MAIL att.email, "🔥 Hot Lead Follow-up: " + lead.name, "Lead " + lead.name + " recebeu oferta de 7 dias.\nScore: " + lead.score + "\nTelefone: " + lead.phone + "\n\nRecomendado: Entrar em contato nas próximas 24h." + END IF + NEXT + END IF + + APPEND results.actions, {"type": "follow_up_7", "lead": lead.name, "score": lead.score} + NEXT + + results.follow_ups_completed = UBOUND(leads_1_day) + UBOUND(leads_3_day) + UBOUND(leads_7_day) + PRINT "Follow-ups completed: " + results.follow_ups_completed +END IF + +' ===================================================================== +' COLLECTIONS - Automated payment reminders (Cobranças) +' ===================================================================== +IF job_name = "collections" OR job_name = "all" THEN + PRINT "" + PRINT "--- Running Collections ---" + + ' Due today: Friendly reminder + due_today = FIND "invoices", "status='pending' AND due_date = CURDATE()" + PRINT "Due today: " + UBOUND(due_today) + + FOR EACH invoice IN due_today + customer = FIND "customers", "id='" + invoice.customer_id + "'" + + IF customer.phone IS NOT NULL THEN + SEND TEMPLATE customer.phone, "payment_due_today", { + "name": customer.name, + "invoice_id": invoice.id, + "amount": FORMAT(invoice.amount, "R$ #,##0.00") + } + END IF + + SAVE "collection_log", { + "invoice_id": invoice.id, + "action": "reminder_due_today", + "created_at": NOW() + } + + APPEND results.actions, {"type": "payment_reminder", "customer": customer.name, "days": 0} + NEXT + + ' 3 days overdue: First notice + overdue_3 = FIND "invoices", "status='pending' AND DATEDIFF(NOW(), due_date) = 3" + PRINT "3 days overdue: " + UBOUND(overdue_3) + + FOR EACH invoice IN overdue_3 + customer = FIND "customers", "id='" + invoice.customer_id + "'" + + IF customer.phone IS NOT NULL THEN + SEND TEMPLATE customer.phone, "payment_overdue_3", { + "name": customer.name, + "invoice_id": invoice.id, + "amount": FORMAT(invoice.amount, "R$ #,##0.00") + } + END IF + + IF customer.email IS NOT NULL THEN + SEND MAIL customer.email, "Pagamento Pendente - Fatura #" + invoice.id, "Prezado(a) " + customer.name + ",\n\nSua fatura #" + invoice.id + " no valor de R$ " + invoice.amount + " está vencida há 3 dias.\n\nPor favor, regularize o pagamento para evitar encargos adicionais.\n\nEm caso de dúvidas, entre em contato conosco.\n\nAtenciosamente,\nDepartamento Financeiro" + END IF + + UPDATE "invoices", "id='" + invoice.id + "'", { + "first_notice_sent": NOW() + } + + SAVE "collection_log", { + "invoice_id": invoice.id, + "action": "first_notice", + "created_at": NOW() + } + + APPEND results.actions, {"type": "collection_notice_1", "customer": customer.name} + NEXT + + ' 7 days overdue: Second notice with urgency + overdue_7 = FIND "invoices", "status='pending' AND DATEDIFF(NOW(), due_date) = 7" + PRINT "7 days overdue: " + UBOUND(overdue_7) + + FOR EACH invoice IN overdue_7 + customer = FIND "customers", "id='" + invoice.customer_id + "'" + + IF customer.phone IS NOT NULL THEN + SEND TEMPLATE customer.phone, "payment_overdue_7", { + "name": customer.name, + "invoice_id": invoice.id, + "amount": FORMAT(invoice.amount, "R$ #,##0.00") + } + END IF + + UPDATE "invoices", "id='" + invoice.id + "'", { + "second_notice_sent": NOW() + } + + ' Notify collections team + SEND MAIL "cobranca@company.com", "Cobrança 7 dias: " + customer.name, "Cliente: " + customer.name + "\nFatura: " + invoice.id + "\nValor: R$ " + invoice.amount + "\nTelefone: " + customer.phone + + SAVE "collection_log", { + "invoice_id": invoice.id, + "action": "second_notice", + "created_at": NOW() + } + + APPEND results.actions, {"type": "collection_notice_2", "customer": customer.name} + NEXT + + ' 15 days overdue: Final notice + queue for human call + overdue_15 = FIND "invoices", "status='pending' AND DATEDIFF(NOW(), due_date) = 15" + PRINT "15 days overdue: " + UBOUND(overdue_15) + + FOR EACH invoice IN overdue_15 + customer = FIND "customers", "id='" + invoice.customer_id + "'" + + ' Calculate late fees + late_fee = invoice.amount * 0.02 + interest = invoice.amount * 0.01 * 15 + total_due = invoice.amount + late_fee + interest + + IF customer.phone IS NOT NULL THEN + SEND TEMPLATE customer.phone, "payment_final_notice", { + "name": customer.name, + "invoice_id": invoice.id, + "total": FORMAT(total_due, "R$ #,##0.00") + } + END IF + + UPDATE "invoices", "id='" + invoice.id + "'", { + "late_fee": late_fee, + "interest": interest, + "total_due": total_due, + "final_notice_sent": NOW() + } + + ' Create task for human follow-up call + CREATE TASK "Ligar para cliente: " + customer.name + " - Cobrança 15 dias", "cobranca@company.com", NOW() + + ' Find finance attendant and assign + attendants = GET ATTENDANTS + FOR EACH att IN attendants.items + IF att.department = "finance" OR att.preferences CONTAINS "collections" THEN + ' Create session for outbound call + SAVE "outbound_queue", { + "customer_id": customer.id, + "customer_name": customer.name, + "customer_phone": customer.phone, + "reason": "collection_15_days", + "invoice_id": invoice.id, + "amount_due": total_due, + "assigned_to": att.id, + "priority": "high", + "created_at": NOW() + } + END IF + NEXT + + SAVE "collection_log", { + "invoice_id": invoice.id, + "action": "final_notice", + "total_due": total_due, + "created_at": NOW() + } + + APPEND results.actions, {"type": "collection_final", "customer": customer.name, "total": total_due} + NEXT + + ' 30+ days: Send to legal/collections agency + overdue_30 = FIND "invoices", "status='pending' AND DATEDIFF(NOW(), due_date) >= 30 AND status <> 'collections'" + PRINT "30+ days overdue: " + UBOUND(overdue_30) + + FOR EACH invoice IN overdue_30 + customer = FIND "customers", "id='" + invoice.customer_id + "'" + + UPDATE "invoices", "id='" + invoice.id + "'", { + "status": "collections", + "sent_to_collections": NOW() + } + + UPDATE "customers", "id='" + customer.id + "'", { + "status": "suspended" + } + + SEND MAIL "juridico@company.com", "Inadimplência 30+ dias: " + customer.name, "Cliente enviado para cobrança jurídica.\n\nCliente: " + customer.name + "\nFatura: " + invoice.id + "\nValor total: R$ " + invoice.total_due + "\nDias em atraso: " + DATEDIFF(NOW(), invoice.due_date) + + SAVE "collection_log", { + "invoice_id": invoice.id, + "action": "sent_to_legal", + "created_at": NOW() + } + + APPEND results.actions, {"type": "collection_legal", "customer": customer.name} + NEXT + + results.collections_processed = UBOUND(due_today) + UBOUND(overdue_3) + UBOUND(overdue_7) + UBOUND(overdue_15) + UBOUND(overdue_30) + PRINT "Collections processed: " + results.collections_processed +END IF + +' ===================================================================== +' LEAD NURTURE - Re-engage cold leads +' ===================================================================== +IF job_name = "lead-nurture" OR job_name = "all" THEN + PRINT "" + PRINT "--- Running Lead Nurture ---" + + ' Find cold leads that haven't been contacted in 30 days + cold_leads = FIND "leads", "status IN ('cold', 'nurturing') AND DATEDIFF(NOW(), last_contact) >= 30 AND DATEDIFF(NOW(), last_contact) < 90" + PRINT "Cold leads to nurture: " + UBOUND(cold_leads) + + FOR EACH lead IN cold_leads + ' Send nurture content based on interest + content_type = "general" + IF lead.interest CONTAINS "pricing" OR lead.interest CONTAINS "preço" THEN + content_type = "pricing_update" + ELSE IF lead.interest CONTAINS "feature" OR lead.interest CONTAINS "funcionalidade" THEN + content_type = "feature_update" + END IF + + IF lead.email IS NOT NULL THEN + SEND TEMPLATE lead.email, "nurture_" + content_type, { + "name": lead.name, + "company": lead.company + } + END IF + + UPDATE "leads", "id='" + lead.id + "'", { + "last_contact": NOW(), + "nurture_count": (lead.nurture_count OR 0) + 1 + } + + SAVE "activities", { + "type": "nurture", + "lead_id": lead.id, + "description": "Nurture email sent: " + content_type, + "created_at": NOW() + } + + APPEND results.actions, {"type": "nurture", "lead": lead.name, "content": content_type} + NEXT + + ' Archive very old leads (90+ days, low score) + stale_leads = FIND "leads", "DATEDIFF(NOW(), last_contact) >= 90 AND score < 30 AND status <> 'archived'" + PRINT "Stale leads to archive: " + UBOUND(stale_leads) + + FOR EACH lead IN stale_leads + UPDATE "leads", "id='" + lead.id + "'", { + "status": "archived", + "archived_at": NOW(), + "archive_reason": "No engagement after 90 days" + } + + APPEND results.actions, {"type": "archive", "lead": lead.name} + NEXT + + results.leads_nurtured = UBOUND(cold_leads) + results.leads_archived = UBOUND(stale_leads) + PRINT "Leads nurtured: " + results.leads_nurtured + ", Archived: " + results.leads_archived +END IF + +' ===================================================================== +' PIPELINE REVIEW - Weekly pipeline analysis +' ===================================================================== +IF job_name = "pipeline-review" OR job_name = "all" THEN + PRINT "" + PRINT "--- Running Pipeline Review ---" + + ' Get all active opportunities + opportunities = FIND "opportunities", "stage NOT IN ('closed_won', 'closed_lost')" + PRINT "Active opportunities: " + UBOUND(opportunities) + + ' Calculate pipeline metrics + total_value = 0 + weighted_value = 0 + stale_count = 0 + at_risk_count = 0 + + FOR EACH opp IN opportunities + total_value = total_value + opp.amount + weighted_value = weighted_value + (opp.amount * opp.probability / 100) + + ' Check for stale opportunities + days_since_activity = DATEDIFF(NOW(), opp.last_activity) + IF days_since_activity > 14 THEN + stale_count = stale_count + 1 + + ' Alert owner + owner = FIND "attendants", "id='" + opp.owner_id + "'" + IF owner IS NOT NULL AND owner.email IS NOT NULL THEN + SEND MAIL owner.email, "⚠️ Oportunidade Estagnada: " + opp.name, "A oportunidade '" + opp.name + "' está sem atividade há " + days_since_activity + " dias.\n\nValor: R$ " + opp.amount + "\nEstágio: " + opp.stage + "\n\nPor favor, atualize o status ou registre uma atividade." + END IF + + ' Create task + CREATE TASK "Atualizar oportunidade: " + opp.name, owner.email, NOW() + + APPEND results.actions, {"type": "stale_alert", "opportunity": opp.name, "days": days_since_activity} + END IF + + ' Check for at-risk (past close date) + IF opp.close_date < NOW() THEN + at_risk_count = at_risk_count + 1 + + UPDATE "opportunities", "id='" + opp.id + "'", { + "at_risk": TRUE, + "risk_reason": "Past expected close date" + } + END IF + NEXT + + ' Generate weekly report + report = "📊 PIPELINE SEMANAL\n" + report = report + "════════════════════════════════════════\n\n" + report = report + "Total Pipeline: R$ " + FORMAT(total_value, "#,##0.00") + "\n" + report = report + "Valor Ponderado: R$ " + FORMAT(weighted_value, "#,##0.00") + "\n" + report = report + "Oportunidades Ativas: " + UBOUND(opportunities) + "\n" + report = report + "Estagnadas (14+ dias): " + stale_count + "\n" + report = report + "Em Risco: " + at_risk_count + "\n\n" + + ' Top 5 opportunities + top_opps = FIND "opportunities", "stage NOT IN ('closed_won', 'closed_lost') ORDER BY amount DESC LIMIT 5" + report = report + "TOP 5 OPORTUNIDADES:\n" + FOR EACH opp IN top_opps + report = report + "• " + opp.name + " - R$ " + FORMAT(opp.amount, "#,##0.00") + " (" + opp.probability + "%)\n" + NEXT + + ' Send to sales leadership + SEND MAIL "vendas@company.com", "Pipeline Semanal - " + FORMAT(NOW(), "dd/MM/yyyy"), report + + results.pipeline_total = total_value + results.pipeline_weighted = weighted_value + results.stale_opportunities = stale_count + results.at_risk = at_risk_count + PRINT "Pipeline review completed. Total: R$ " + total_value +END IF + +' ===================================================================== +' FINISH +' ===================================================================== +results.completed_at = NOW() +results.duration_seconds = DATEDIFF("second", results.started_at, results.completed_at) + +PRINT "" +PRINT "=== CRM Automations Completed ===" +PRINT "Duration: " + results.duration_seconds + " seconds" +PRINT "Actions taken: " + UBOUND(results.actions) + +' Log results +SAVE "automation_logs", results + +RETURN results diff --git a/templates/sales/attendance-crm.gbai/attendance-crm.gbdialog/queue-monitor.bas b/templates/sales/attendance-crm.gbai/attendance-crm.gbdialog/queue-monitor.bas new file mode 100644 index 00000000..3e0b47f7 --- /dev/null +++ b/templates/sales/attendance-crm.gbai/attendance-crm.gbdialog/queue-monitor.bas @@ -0,0 +1,242 @@ +REM Queue Monitor - Automated Queue Management +REM Runs on schedule to monitor queue health, reassign stale conversations, +REM notify supervisors of issues, and generate queue metrics. +REM +REM Schedule: SET SCHEDULE "queue-monitor", "*/5 * * * *" (every 5 minutes) + +DESCRIPTION "Monitor queue health, reassign stale conversations, alert on issues" + +' Get current queue status +queue = GET QUEUE + +PRINT "Queue Monitor Running - " + FORMAT(NOW(), "yyyy-MM-dd HH:mm:ss") +PRINT "Total: " + queue.total + " | Waiting: " + queue.waiting + " | Active: " + queue.active + +' === Check for stale waiting conversations === +' Conversations waiting more than 10 minutes without assignment +stale_threshold_minutes = 10 + +FOR EACH item IN queue.items + IF item.status = "waiting" THEN + ' Calculate wait time + created_at = GET SESSION item.session_id, "created_at" + wait_minutes = DATEDIFF("minute", created_at, NOW()) + + IF wait_minutes > stale_threshold_minutes THEN + PRINT "ALERT: Stale conversation " + item.session_id + " waiting " + wait_minutes + " minutes" + + ' Try to find available attendant + attendants = GET ATTENDANTS "online" + + IF attendants.count > 0 THEN + ' Find attendant with least active conversations + best_attendant = NULL + min_active = 999 + + FOR EACH att IN attendants.items + stats = GET ATTENDANT STATS att.id + IF stats.active_conversations < min_active THEN + min_active = stats.active_conversations + best_attendant = att + END IF + NEXT + + IF best_attendant IS NOT NULL AND min_active < 5 THEN + ' Auto-assign + result = ASSIGN CONVERSATION item.session_id, best_attendant.id + + IF result.success THEN + PRINT "Auto-assigned " + item.session_id + " to " + best_attendant.name + ADD NOTE item.session_id, "Auto-assigned after " + wait_minutes + " min wait" + + ' Notify customer + SEND TO SESSION item.session_id, "Obrigado por aguardar! " + best_attendant.name + " irá atendê-lo agora." + END IF + END IF + ELSE + ' No attendants available - escalate + IF wait_minutes > 20 THEN + ' Critical - notify supervisor + SEND MAIL "supervisor@company.com", "URGENTE: Fila sem atendentes", "Conversa " + item.session_id + " aguardando há " + wait_minutes + " minutos sem atendentes disponíveis." + + ' Set high priority + SET PRIORITY item.session_id, "urgent" + TAG CONVERSATION item.session_id, "no-attendants" + + ' Send apology to customer + SEND TO SESSION item.session_id, "Pedimos desculpas pela espera. Nossa equipe está com alta demanda. Você será atendido em breve." + END IF + END IF + END IF + END IF +NEXT + +' === Check for inactive assigned conversations === +' Conversations assigned but no activity for 15 minutes +inactive_threshold_minutes = 15 + +FOR EACH item IN queue.items + IF item.status = "assigned" OR item.status = "active" THEN + last_activity = GET SESSION item.session_id, "last_activity_at" + + IF last_activity IS NOT NULL THEN + inactive_minutes = DATEDIFF("minute", last_activity, NOW()) + + IF inactive_minutes > inactive_threshold_minutes THEN + PRINT "WARNING: Inactive conversation " + item.session_id + " - " + inactive_minutes + " min since last activity" + + ' Get assigned attendant + assigned_to = GET SESSION item.session_id, "assigned_to" + + IF assigned_to IS NOT NULL THEN + ' Check attendant status + att_status = GET ATTENDANT STATUS assigned_to + + IF att_status = "offline" OR att_status = "away" THEN + ' Attendant went away - reassign + PRINT "Attendant " + assigned_to + " is " + att_status + " - reassigning" + + ' Find new attendant + new_attendants = GET ATTENDANTS "online" + IF new_attendants.count > 0 THEN + new_att = new_attendants.items[0] + + ' Transfer conversation + old_ctx = GET SESSION item.session_id, "context" + result = ASSIGN CONVERSATION item.session_id, new_att.id + + IF result.success THEN + ADD NOTE item.session_id, "Reatribuído de " + assigned_to + " (status: " + att_status + ") para " + new_att.name + SEND TO SESSION item.session_id, "Desculpe a espera. " + new_att.name + " continuará seu atendimento." + END IF + END IF + ELSE + ' Attendant is online but inactive - send reminder + ' Only remind once every 10 minutes + last_reminder = GET SESSION item.session_id, "last_attendant_reminder" + + IF last_reminder IS NULL OR DATEDIFF("minute", last_reminder, NOW()) > 10 THEN + ' Get customer sentiment + sentiment = GET SESSION item.session_id, "last_sentiment" + + reminder = "⚠️ Conversa inativa há " + inactive_minutes + " min" + IF sentiment = "negative" THEN + reminder = reminder + " - Cliente frustrado!" + END IF + + ' Send reminder via WebSocket to attendant UI + NOTIFY ATTENDANT assigned_to, reminder + SET SESSION item.session_id, "last_attendant_reminder", NOW() + END IF + END IF + END IF + END IF + END IF + END IF +NEXT + +' === Check for abandoned conversations === +' Customer hasn't responded in 30 minutes +abandon_threshold_minutes = 30 + +FOR EACH item IN queue.items + IF item.status = "active" OR item.status = "assigned" THEN + last_customer_msg = GET SESSION item.session_id, "last_customer_message_at" + + IF last_customer_msg IS NOT NULL THEN + silent_minutes = DATEDIFF("minute", last_customer_msg, NOW()) + + IF silent_minutes > abandon_threshold_minutes THEN + ' Check if already marked + already_marked = GET SESSION item.session_id, "abandon_warning_sent" + + IF already_marked IS NULL THEN + ' Send follow-up + SEND TO SESSION item.session_id, "Ainda está aí? Se precisar de mais ajuda, é só responder." + SET SESSION item.session_id, "abandon_warning_sent", NOW() + PRINT "Sent follow-up to potentially abandoned session " + item.session_id + ELSE + ' Check if warning was sent more than 15 min ago + warning_minutes = DATEDIFF("minute", already_marked, NOW()) + + IF warning_minutes > 15 THEN + ' Mark as abandoned + RESOLVE CONVERSATION item.session_id, "Abandoned - no customer response" + TAG CONVERSATION item.session_id, "abandoned" + PRINT "Marked session " + item.session_id + " as abandoned" + END IF + END IF + END IF + END IF + END IF +NEXT + +' === Generate queue metrics === +' Calculate averages and store for analytics + +metrics = {} +metrics.timestamp = NOW() +metrics.total_waiting = queue.waiting +metrics.total_active = queue.active +metrics.total_resolved = queue.resolved + +' Calculate average wait time for waiting conversations +total_wait = 0 +wait_count = 0 + +FOR EACH item IN queue.items + IF item.status = "waiting" THEN + created_at = GET SESSION item.session_id, "created_at" + wait_minutes = DATEDIFF("minute", created_at, NOW()) + total_wait = total_wait + wait_minutes + wait_count = wait_count + 1 + END IF +NEXT + +IF wait_count > 0 THEN + metrics.avg_wait_minutes = total_wait / wait_count +ELSE + metrics.avg_wait_minutes = 0 +END IF + +' Get attendant utilization +attendants = GET ATTENDANTS +online_count = 0 +busy_count = 0 + +FOR EACH att IN attendants.items + IF att.status = "online" THEN + online_count = online_count + 1 + ELSE IF att.status = "busy" THEN + busy_count = busy_count + 1 + END IF +NEXT + +metrics.attendants_online = online_count +metrics.attendants_busy = busy_count +metrics.utilization_pct = 0 + +IF online_count + busy_count > 0 THEN + metrics.utilization_pct = (busy_count / (online_count + busy_count)) * 100 +END IF + +' Store metrics for dashboard +SAVE "queue_metrics", metrics + +' Alert if queue is getting long +IF queue.waiting > 10 THEN + SEND MAIL "supervisor@company.com", "Alerta: Fila com " + queue.waiting + " aguardando", "A fila de atendimento está com " + queue.waiting + " conversas aguardando. Tempo médio de espera: " + metrics.avg_wait_minutes + " minutos." +END IF + +' Alert if no attendants online during business hours +hour_now = HOUR(NOW()) +day_now = WEEKDAY(NOW()) + +IF hour_now >= 9 AND hour_now < 18 AND day_now >= 1 AND day_now <= 5 THEN + IF online_count = 0 AND busy_count = 0 THEN + SEND MAIL "supervisor@company.com", "CRÍTICO: Sem atendentes online", "Não há atendentes online durante o horário comercial. Fila: " + queue.waiting + " aguardando." + END IF +END IF + +PRINT "Queue monitor completed. Metrics saved." +RETURN metrics diff --git a/templates/sales/attendance-crm.gbai/attendance-crm.gbdialog/start.bas b/templates/sales/attendance-crm.gbai/attendance-crm.gbdialog/start.bas new file mode 100644 index 00000000..e079cbab --- /dev/null +++ b/templates/sales/attendance-crm.gbai/attendance-crm.gbdialog/start.bas @@ -0,0 +1,173 @@ +REM Attendance CRM Bot - Intelligent Routing with AI-Assisted Human Handoff +REM This bot handles customer inquiries, detects frustration, and seamlessly +REM transfers to human attendants when needed. Uses LLM assist features. + +DESCRIPTION "Main entry point for Attendance CRM - routes to bot or human based on context" + +' Check if this is a returning customer +customer_id = GET "session.customer_id" +is_returning = customer_id IS NOT NULL + +' Check if session already needs human +needs_human = GET "session.needs_human" +IF needs_human = TRUE THEN + ' Already in human mode - message goes to attendant automatically + RETURN +END IF + +' Get the customer's message +message = GET "user.message" + +' Analyze sentiment immediately +sentiment = ANALYZE SENTIMENT session.id, message + +' Store sentiment for tracking +SET "session.last_sentiment", sentiment.overall +SET "session.escalation_risk", sentiment.escalation_risk + +' Track frustration count +frustration_count = GET "session.frustration_count" +IF frustration_count IS NULL THEN + frustration_count = 0 +END IF + +IF sentiment.overall = "negative" THEN + frustration_count = frustration_count + 1 + SET "session.frustration_count", frustration_count +END IF + +' Auto-transfer on high frustration or explicit request +transfer_keywords = ["falar com humano", "talk to human", "atendente", "pessoa real", "real person", "speak to someone", "manager", "gerente", "supervisor"] + +should_transfer = FALSE +transfer_reason = "" + +' Check for explicit transfer request +FOR EACH keyword IN transfer_keywords + IF LOWER(message) CONTAINS keyword THEN + should_transfer = TRUE + transfer_reason = "Customer requested human assistance" + END IF +NEXT + +' Check for high escalation risk +IF sentiment.escalation_risk = "high" THEN + should_transfer = TRUE + transfer_reason = "High escalation risk detected - sentiment: " + sentiment.overall +END IF + +' Check for repeated frustration +IF frustration_count >= 3 THEN + should_transfer = TRUE + transfer_reason = "Customer frustrated after " + frustration_count + " messages" +END IF + +' Execute transfer if needed +IF should_transfer THEN + ' Get tips for the attendant before transfer + tips = GET TIPS session.id, message + + ' Build context for attendant + context_summary = "Customer: " + (GET "session.customer_name" OR "Unknown") + "\n" + context_summary = context_summary + "Channel: " + (GET "session.channel" OR "web") + "\n" + context_summary = context_summary + "Sentiment: " + sentiment.emoji + " " + sentiment.overall + "\n" + context_summary = context_summary + "Escalation Risk: " + sentiment.escalation_risk + "\n" + context_summary = context_summary + "Frustration Count: " + frustration_count + "\n" + + IF tips.success AND UBOUND(tips.items) > 0 THEN + context_summary = context_summary + "\nAI Tips:\n" + FOR EACH tip IN tips.items + context_summary = context_summary + "- " + tip.content + "\n" + NEXT + END IF + + ' Set priority based on sentiment + priority = "normal" + IF sentiment.escalation_risk = "high" OR sentiment.urgency = "urgent" THEN + priority = "urgent" + ELSE IF sentiment.overall = "negative" THEN + priority = "high" + END IF + + ' Transfer to human + result = TRANSFER TO HUMAN "support", priority, context_summary + + IF result.success THEN + IF result.status = "assigned" THEN + TALK "Estou transferindo você para " + result.assigned_to_name + ". Um momento, por favor." + TALK "I'm connecting you with " + result.assigned_to_name + ". Please hold." + ELSE IF result.status = "queued" THEN + TALK "Você está na posição " + result.queue_position + " da fila. Tempo estimado: " + (result.estimated_wait_seconds / 60) + " minutos." + TALK "You are #" + result.queue_position + " in queue. Estimated wait: " + (result.estimated_wait_seconds / 60) + " minutes." + END IF + + ' Tag the conversation + TAG CONVERSATION session.id, "transferred" + TAG CONVERSATION session.id, sentiment.overall + + ' Add note for attendant + ADD NOTE session.id, "Auto-transferred: " + transfer_reason + + RETURN + ELSE + ' Transfer failed - continue with bot but apologize + TALK "Nossos atendentes estão ocupados no momento. Vou fazer o meu melhor para ajudá-lo." + TALK "Our agents are currently busy. I'll do my best to help you." + END IF +END IF + +' Continue with bot processing +' Check for common intents +message_lower = LOWER(message) + +IF message_lower CONTAINS "preço" OR message_lower CONTAINS "price" OR message_lower CONTAINS "quanto custa" OR message_lower CONTAINS "how much" THEN + ' Pricing inquiry - could create lead + SET "session.intent", "pricing" + USE TOOL "pricing-inquiry" + RETURN +END IF + +IF message_lower CONTAINS "problema" OR message_lower CONTAINS "problem" OR message_lower CONTAINS "não funciona" OR message_lower CONTAINS "not working" THEN + ' Support issue + SET "session.intent", "support" + USE TOOL "support-ticket" + RETURN +END IF + +IF message_lower CONTAINS "status" OR message_lower CONTAINS "pedido" OR message_lower CONTAINS "order" OR message_lower CONTAINS "entrega" OR message_lower CONTAINS "delivery" THEN + ' Order status + SET "session.intent", "order_status" + USE TOOL "order-status" + RETURN +END IF + +IF message_lower CONTAINS "cancelar" OR message_lower CONTAINS "cancel" OR message_lower CONTAINS "reembolso" OR message_lower CONTAINS "refund" THEN + ' Cancellation/Refund - often needs human + SET "session.intent", "cancellation" + SET PRIORITY session.id, "high" + + TALK "Entendo que você deseja cancelar ou obter reembolso. Deixe-me verificar sua conta." + TALK "I understand you want to cancel or get a refund. Let me check your account." + + ' Check if VIP customer - auto transfer + IF GET "session.customer_tier" = "vip" OR GET "session.customer_tier" = "enterprise" THEN + result = TRANSFER TO HUMAN "support", "high", "VIP customer requesting cancellation/refund" + IF result.success THEN + TALK "Como cliente VIP, estou conectando você diretamente com nossa equipe especializada." + RETURN + END IF + END IF + + USE TOOL "cancellation-flow" + RETURN +END IF + +' Default: Use LLM for general conversation +response = LLM "Respond helpfully to: " + message +TALK response + +' After each bot response, check if we should suggest human +IF sentiment.overall = "negative" THEN + TALK "Se preferir falar com um atendente humano, é só me avisar." + TALK "If you'd prefer to speak with a human agent, just let me know." +END IF diff --git a/templates/sales/attendance-crm.gbai/attendance-crm.gbot/config.csv b/templates/sales/attendance-crm.gbai/attendance-crm.gbot/config.csv new file mode 100644 index 00000000..95b431e7 --- /dev/null +++ b/templates/sales/attendance-crm.gbai/attendance-crm.gbot/config.csv @@ -0,0 +1,49 @@ +name,value + +# Bot Identity +bot-name,Attendance CRM Bot +bot-description,Hybrid AI + Human support with CRM integration + +# CRM / Human Handoff - Required +crm-enabled,true + +# LLM Assist Features for Attendants +attendant-llm-tips,true +attendant-polish-message,true +attendant-smart-replies,true +attendant-auto-summary,true +attendant-sentiment-analysis,true + +# Bot Personality (used for LLM assist context) +bot-system-prompt,You are a professional customer service assistant. Be helpful and empathetic. When issues are complex or customers are frustrated transfer to human support. + +# Auto-transfer triggers +auto-transfer-on-frustration,true +auto-transfer-threshold,3 + +# Queue Settings +queue-timeout-minutes,30 +queue-notify-interval,5 + +# Lead Scoring +lead-score-threshold-hot,70 +lead-score-threshold-warm,50 + +# Follow-up Automation +follow-up-1-day,true +follow-up-3-day,true +follow-up-7-day,true + +# Collections Automation +collections-enabled,true +collections-grace-days,3 + +# Working Hours (for auto-responses) +business-hours-start,09:00 +business-hours-end,18:00 +business-days,1-5 + +# Notifications +notify-on-vip,true +notify-on-escalation,true +notify-email,support@company.com diff --git a/templates/sales/attendance-crm.gbai/attendant.csv b/templates/sales/attendance-crm.gbai/attendant.csv new file mode 100644 index 00000000..71f62a9c --- /dev/null +++ b/templates/sales/attendance-crm.gbai/attendant.csv @@ -0,0 +1,6 @@ +id,name,channel,preferences,department,aliases,phone,email,teams,google +att-001,João Silva,all,sales,commercial,joao;js;silva,+5511999990001,joao.silva@company.com,joao.silva@company.onmicrosoft.com,joao.silva@company.com +att-002,Maria Santos,whatsapp,support,customer-service,maria;ms,+5511999990002,maria.santos@company.com,maria.santos@company.onmicrosoft.com,maria.santos@gmail.com +att-003,Pedro Costa,web,technical,engineering,pedro;pc;tech,+5511999990003,pedro.costa@company.com,pedro.costa@company.onmicrosoft.com,pedro.costa@company.com +att-004,Ana Oliveira,all,collections,finance,ana;ao;cobranca,+5511999990004,ana.oliveira@company.com,ana.oliveira@company.onmicrosoft.com,ana.oliveira@company.com +att-005,Carlos Souza,whatsapp,sales,commercial,carlos;cs,+5511999990005,carlos.souza@company.com,carlos.souza@company.onmicrosoft.com,carlos.souza@gmail.com