/*****************************************************************************\ | █████ █████ ██ █ █████ █████ ████ ██ ████ █████ █████ ███ ® | | ██ █ ███ █ █ ██ ██ ██ ██ ██ ██ █ ██ ██ █ █ | | ██ ███ ████ █ ██ █ ████ █████ ██████ ██ ████ █ █ █ ██ | | ██ ██ █ █ ██ █ █ ██ ██ ██ ██ ██ ██ █ ██ ██ █ █ | | █████ █████ █ ███ █████ ██ ██ ██ ██ █████ ████ █████ █ ███ | | | | General Bots Copyright (c) pragmatismo.com.br. All rights reserved. | | Licensed under the AGPL-3.0. | | | | According to our dual licensing model, this program can be used either | | under the terms of the GNU Affero General Public License, version 3, | | or under a proprietary license. | | | | The texts of the GNU Affero General Public License with an additional | | permission and of our proprietary license can be found at and | | in the LICENSE file you have received along with this program. | | | | This program is distributed in the hope that it will be useful, | | but WITHOUT ANY WARRANTY, without even the implied warranty of | | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | | GNU Affero General Public License for more details. | | | | "General Bots" is a registered trademark of pragmatismo.com.br. | | The licensing of the program under the AGPLv3 does not imply a | | trademark license. Therefore any rights, title and interest in | | our trademarks remain entirely with us. | | | \*****************************************************************************/ use crate::core::config::ConfigManager; use crate::shared::models::UserSession; use crate::shared::state::AppState; use log::{error, info, trace}; use rhai::{Dynamic, Engine}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use uuid::Uuid; #[derive(Debug, Clone, PartialEq, Eq)] pub enum SmsProvider { Twilio, AwsSns, Vonage, MessageBird, Custom(String), } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum SmsPriority { Low, Normal, High, Urgent, } impl Default for SmsPriority { fn default() -> Self { Self::Normal } } impl From<&str> for SmsPriority { fn from(s: &str) -> Self { match s.to_lowercase().as_str() { "low" => Self::Low, "high" => Self::High, "urgent" | "critical" => Self::Urgent, _ => Self::Normal, } } } impl std::fmt::Display for SmsPriority { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Low => write!(f, "low"), Self::Normal => write!(f, "normal"), Self::High => write!(f, "high"), Self::Urgent => write!(f, "urgent"), } } } impl From<&str> for SmsProvider { fn from(s: &str) -> Self { match s.to_lowercase().as_str() { "twilio" => Self::Twilio, "aws" | "aws_sns" | "sns" => Self::AwsSns, "vonage" | "nexmo" => Self::Vonage, "messagebird" => Self::MessageBird, other => Self::Custom(other.to_string()), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SmsSendResult { pub success: bool, pub message_id: Option, pub provider: String, pub to: String, pub priority: String, pub error: Option, } pub fn register_sms_keywords(state: Arc, user: UserSession, engine: &mut Engine) { register_send_sms_keyword(Arc::clone(&state), user.clone(), engine); register_send_sms_with_third_arg_keyword(Arc::clone(&state), user.clone(), engine); register_send_sms_full_keyword(state, user, engine); } pub fn register_send_sms_keyword(state: Arc, user: UserSession, engine: &mut Engine) { let state_clone = Arc::clone(&state); let user_clone = user; engine .register_custom_syntax( ["SEND_SMS", "$expr$", ",", "$expr$"], false, move |context, inputs| { let phone = context.eval_expression_tree(&inputs[0])?.to_string(); let message = context.eval_expression_tree(&inputs[1])?.to_string(); trace!("SEND_SMS: Sending SMS to {}", phone); let state_for_task = Arc::clone(&state_clone); let user_for_task = user_clone.clone(); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let rt = tokio::runtime::Builder::new_multi_thread() .worker_threads(2) .enable_all() .build(); let send_err = if let Ok(rt) = rt { let result = rt.block_on(async move { execute_send_sms( &state_for_task, &user_for_task, &phone, &message, None, None, ) .await }); tx.send(result).err() } else { tx.send(Err("Failed to build tokio runtime".into())).err() }; if send_err.is_some() { error!("Failed to send SEND_SMS result from thread"); } }); match rx.recv_timeout(std::time::Duration::from_secs(30)) { Ok(Ok(result)) => { let mut map = rhai::Map::new(); map.insert("success".into(), Dynamic::from(result.success)); map.insert( "message_id".into(), Dynamic::from(result.message_id.unwrap_or_default()), ); map.insert("provider".into(), Dynamic::from(result.provider)); map.insert("to".into(), Dynamic::from(result.to)); map.insert("priority".into(), Dynamic::from(result.priority)); if let Some(err) = result.error { map.insert("error".into(), Dynamic::from(err)); } Ok(Dynamic::from(map)) } Ok(Err(e)) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime( format!("SEND_SMS failed: {}", e).into(), rhai::Position::NONE, ))), Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { Err(Box::new(rhai::EvalAltResult::ErrorRuntime( "SEND_SMS timed out".into(), rhai::Position::NONE, ))) } Err(e) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime( format!("SEND_SMS thread failed: {}", e).into(), rhai::Position::NONE, ))), } }, ) .expect("valid syntax registration"); } pub fn register_send_sms_with_third_arg_keyword( state: Arc, user: UserSession, engine: &mut Engine, ) { let state_clone = Arc::clone(&state); let user_clone = user; engine .register_custom_syntax( ["SEND_SMS", "$expr$", ",", "$expr$", ",", "$expr$"], false, move |context, inputs| { let phone = context.eval_expression_tree(&inputs[0])?.to_string(); let message = context.eval_expression_tree(&inputs[1])?.to_string(); let third_arg = context.eval_expression_tree(&inputs[2])?.to_string(); let is_priority = matches!( third_arg.to_lowercase().as_str(), "low" | "normal" | "high" | "urgent" | "critical" ); let (provider_override, priority_override) = if is_priority { (None, Some(third_arg.clone())) } else { (Some(third_arg.clone()), None) }; trace!( "SEND_SMS: Sending SMS to {} (third_arg={}, is_priority={})", phone, third_arg, is_priority ); let state_for_task = Arc::clone(&state_clone); let user_for_task = user_clone.clone(); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let rt = tokio::runtime::Builder::new_multi_thread() .worker_threads(2) .enable_all() .build(); let send_err = if let Ok(rt) = rt { let result = rt.block_on(async move { execute_send_sms( &state_for_task, &user_for_task, &phone, &message, provider_override.as_deref(), priority_override.as_deref(), ) .await }); tx.send(result).err() } else { tx.send(Err("Failed to build tokio runtime".into())).err() }; if send_err.is_some() { error!("Failed to send SEND_SMS result from thread"); } }); match rx.recv_timeout(std::time::Duration::from_secs(30)) { Ok(Ok(result)) => { let mut map = rhai::Map::new(); map.insert("success".into(), Dynamic::from(result.success)); map.insert( "message_id".into(), Dynamic::from(result.message_id.unwrap_or_default()), ); map.insert("provider".into(), Dynamic::from(result.provider)); map.insert("to".into(), Dynamic::from(result.to)); map.insert("priority".into(), Dynamic::from(result.priority)); if let Some(err) = result.error { map.insert("error".into(), Dynamic::from(err)); } Ok(Dynamic::from(map)) } Ok(Err(e)) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime( format!("SEND_SMS failed: {}", e).into(), rhai::Position::NONE, ))), Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { Err(Box::new(rhai::EvalAltResult::ErrorRuntime( "SEND_SMS timed out".into(), rhai::Position::NONE, ))) } Err(e) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime( format!("SEND_SMS thread failed: {}", e).into(), rhai::Position::NONE, ))), } }, ) .expect("valid syntax registration"); } pub fn register_send_sms_full_keyword( state: Arc, user: UserSession, engine: &mut Engine, ) { let state_clone = Arc::clone(&state); let user_clone = user.clone(); engine .register_custom_syntax( ["SEND_SMS", "$expr$", ",", "$expr$", ",", "$expr$"], false, move |context, inputs| { let phone = context.eval_expression_tree(&inputs[0])?.to_string(); let message = context.eval_expression_tree(&inputs[1])?.to_string(); let provider = context.eval_expression_tree(&inputs[2])?.to_string(); let priority = context.eval_expression_tree(&inputs[3])?.to_string(); trace!( "SEND_SMS: Sending SMS to {} via {} with priority {}", phone, provider, priority ); let state_for_task = Arc::clone(&state_clone); let user_for_task = user_clone.clone(); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let rt = tokio::runtime::Builder::new_multi_thread() .worker_threads(2) .enable_all() .build(); let send_err = if let Ok(rt) = rt { let result = rt.block_on(async move { execute_send_sms( &state_for_task, &user_for_task, &phone, &message, Some(&provider), Some(&priority), ) .await }); tx.send(result).err() } else { tx.send(Err("Failed to build tokio runtime".into())).err() }; if send_err.is_some() { error!("Failed to send SEND_SMS result from thread"); } }); match rx.recv_timeout(std::time::Duration::from_secs(30)) { Ok(Ok(result)) => { let mut map = rhai::Map::new(); map.insert("success".into(), Dynamic::from(result.success)); map.insert( "message_id".into(), Dynamic::from(result.message_id.unwrap_or_default()), ); map.insert("provider".into(), Dynamic::from(result.provider)); map.insert("to".into(), Dynamic::from(result.to)); map.insert("priority".into(), Dynamic::from(result.priority)); if let Some(err) = result.error { map.insert("error".into(), Dynamic::from(err)); } Ok(Dynamic::from(map)) } Ok(Err(e)) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime( format!("SEND_SMS failed: {}", e).into(), rhai::Position::NONE, ))), Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { Err(Box::new(rhai::EvalAltResult::ErrorRuntime( "SEND_SMS timed out".into(), rhai::Position::NONE, ))) } Err(e) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime( format!("SEND_SMS thread failed: {}", e).into(), rhai::Position::NONE, ))), } }, ) .expect("valid syntax registration"); let state_clone2 = Arc::clone(&state); let user_clone2 = user; engine .register_custom_syntax( [ "SEND_SMS", "$expr$", ",", "$expr$", ",", "$expr$", ",", "$expr$", ], false, move |context, inputs| { let phone = context.eval_expression_tree(&inputs[0])?.to_string(); let message = context.eval_expression_tree(&inputs[1])?.to_string(); let provider = context.eval_expression_tree(&inputs[2])?.to_string(); let priority = context.eval_expression_tree(&inputs[3])?.to_string(); trace!( "SEND_SMS: Sending SMS to {} via {} with priority {}", phone, provider, priority ); let state_for_task = Arc::clone(&state_clone2); let user_for_task = user_clone2.clone(); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let rt = tokio::runtime::Builder::new_multi_thread() .worker_threads(2) .enable_all() .build(); let send_err = if let Ok(rt) = rt { let result = rt.block_on(async move { execute_send_sms( &state_for_task, &user_for_task, &phone, &message, Some(&provider), Some(&priority), ) .await }); tx.send(result).err() } else { tx.send(Err("Failed to build tokio runtime".into())).err() }; if send_err.is_some() { error!("Failed to send SEND_SMS result from thread"); } }); match rx.recv_timeout(std::time::Duration::from_secs(30)) { Ok(Ok(result)) => { let mut map = rhai::Map::new(); map.insert("success".into(), Dynamic::from(result.success)); map.insert( "message_id".into(), Dynamic::from(result.message_id.unwrap_or_default()), ); map.insert("provider".into(), Dynamic::from(result.provider)); map.insert("to".into(), Dynamic::from(result.to)); map.insert("priority".into(), Dynamic::from(result.priority)); if let Some(err) = result.error { map.insert("error".into(), Dynamic::from(err)); } Ok(Dynamic::from(map)) } Ok(Err(e)) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime( format!("SEND_SMS failed: {}", e).into(), rhai::Position::NONE, ))), Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { Err(Box::new(rhai::EvalAltResult::ErrorRuntime( "SEND_SMS timed out".into(), rhai::Position::NONE, ))) } Err(e) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime( format!("SEND_SMS thread failed: {}", e).into(), rhai::Position::NONE, ))), } }, ) .expect("valid syntax registration"); } async fn execute_send_sms( state: &AppState, user: &UserSession, phone: &str, message: &str, provider_override: Option<&str>, priority_override: Option<&str>, ) -> Result> { let config_manager = ConfigManager::new(state.conn.clone()); let bot_id = user.bot_id; let provider_name = match provider_override { Some(p) => p.to_string(), None => config_manager .get_config(&bot_id, "sms-provider", None) .unwrap_or_else(|_| "twilio".to_string()), }; let provider = SmsProvider::from(provider_name.as_str()); let priority = match priority_override { Some(p) => SmsPriority::from(p), None => { let priority_str = config_manager .get_config(&bot_id, "sms-default-priority", None) .unwrap_or_else(|_| "normal".to_string()); SmsPriority::from(priority_str.as_str()) } }; let normalized_phone = normalize_phone_number(phone); if matches!(priority, SmsPriority::High | SmsPriority::Urgent) { info!( "High priority SMS to {}: priority={}", normalized_phone, priority ); } let result = match provider { SmsProvider::Twilio => { send_via_twilio(state, &bot_id, &normalized_phone, message, &priority).await } SmsProvider::AwsSns => { send_via_aws_sns(state, &bot_id, &normalized_phone, message, &priority).await } SmsProvider::Vonage => { send_via_vonage(state, &bot_id, &normalized_phone, message, &priority).await } SmsProvider::MessageBird => { send_via_messagebird(state, &bot_id, &normalized_phone, message, &priority).await } SmsProvider::Custom(name) => { send_via_custom_webhook(state, &bot_id, &name, &normalized_phone, message, &priority) .await } }; match result { Ok(message_id) => { info!( "SMS sent successfully to {} via {} (priority={}): {}", normalized_phone, provider_name, priority, message_id.as_deref().unwrap_or("no-id") ); Ok(SmsSendResult { success: true, message_id, provider: provider_name, to: normalized_phone, priority: priority.to_string(), error: None, }) } Err(e) => { error!("SMS send failed to {}: {}", normalized_phone, e); Ok(SmsSendResult { success: false, message_id: None, provider: provider_name, to: normalized_phone, priority: priority.to_string(), error: Some(e.to_string()), }) } } } fn normalize_phone_number(phone: &str) -> String { let has_plus = phone.starts_with('+'); let digits: String = phone.chars().filter(|c| c.is_ascii_digit()).collect(); if has_plus { format!("+{}", digits) } else if digits.len() == 10 { format!("+1{}", digits) } else { // Both 11-digit starting with '1' and other cases get the same format format!("+{}", digits) } } async fn send_via_twilio( state: &AppState, bot_id: &Uuid, phone: &str, message: &str, priority: &SmsPriority, ) -> Result, Box> { let config_manager = ConfigManager::new(state.conn.clone()); let account_sid = config_manager .get_config(bot_id, "twilio-account-sid", None) .map_err(|_| "Twilio account SID not configured. Set twilio-account-sid in config.")?; let auth_token = config_manager .get_config(bot_id, "twilio-auth-token", None) .map_err(|_| "Twilio auth token not configured. Set twilio-auth-token in config.")?; let from_number = config_manager .get_config(bot_id, "twilio-from-number", None) .map_err(|_| "Twilio from number not configured. Set twilio-from-number in config.")?; let client = reqwest::Client::new(); let url = format!( "https://api.twilio.com/2010-04-01/Accounts/{}/Messages.json", account_sid ); let final_message = match priority { SmsPriority::Urgent => format!("[URGENT] {}", message), SmsPriority::High => format!("[HIGH] {}", message), _ => message.to_string(), }; let params = [ ("To", phone), ("From", from_number.as_str()), ("Body", final_message.as_str()), ]; let response = client .post(&url) .basic_auth(&account_sid, Some(&auth_token)) .form(¶ms) .send() .await?; if response.status().is_success() { let json: serde_json::Value = response.json().await?; let sid = json["sid"].as_str().map(|s| s.to_string()); Ok(sid) } else { let error_text = response.text().await?; Err(format!("Twilio API error: {}", error_text).into()) } } async fn send_via_aws_sns( state: &AppState, bot_id: &Uuid, phone: &str, message: &str, priority: &SmsPriority, ) -> Result, Box> { let config_manager = ConfigManager::new(state.conn.clone()); let access_key = config_manager .get_config(bot_id, "aws-access-key", None) .map_err(|_| "AWS access key not configured. Set aws-access-key in config.")?; let secret_key = config_manager .get_config(bot_id, "aws-secret-key", None) .map_err(|_| "AWS secret key not configured. Set aws-secret-key in config.")?; let region = config_manager .get_config(bot_id, "aws-region", Some("us-east-1")) .unwrap_or_else(|_| "us-east-1".to_string()); let client = reqwest::Client::new(); let url = format!("https://sns.{}.amazonaws.com/", region); let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string(); let sms_type = match priority { SmsPriority::High | SmsPriority::Urgent => "Transactional", _ => "Promotional", }; let params = [ ("Action", "Publish"), ("PhoneNumber", phone), ("Message", message), ("Version", "2010-03-31"), ("MessageAttributes.entry.1.Name", "AWS.SNS.SMS.SMSType"), ("MessageAttributes.entry.1.Value.DataType", "String"), ("MessageAttributes.entry.1.Value.StringValue", sms_type), ]; let response = client .post(&url) .form(¶ms) .header("X-Amz-Date", ×tamp) .basic_auth(&access_key, Some(&secret_key)) .send() .await?; if response.status().is_success() { let body = response.text().await?; if let Some(start) = body.find("") { if let Some(end) = body.find("") { let message_id = &body[start + 11..end]; return Ok(Some(message_id.to_string())); } } Ok(None) } else { let error_text = response.text().await?; Err(format!("AWS SNS API error: {}", error_text).into()) } } async fn send_via_vonage( state: &AppState, bot_id: &Uuid, phone: &str, message: &str, priority: &SmsPriority, ) -> Result, Box> { let config_manager = ConfigManager::new(state.conn.clone()); let api_key = config_manager .get_config(bot_id, "vonage-api-key", None) .map_err(|_| "Vonage API key not configured. Set vonage-api-key in config.")?; let api_secret = config_manager .get_config(bot_id, "vonage-api-secret", None) .map_err(|_| "Vonage API secret not configured. Set vonage-api-secret in config.")?; let from_number = config_manager .get_config(bot_id, "vonage-from-number", None) .map_err(|_| "Vonage from number not configured. Set vonage-from-number in config.")?; let client = reqwest::Client::new(); let message_class = match priority { SmsPriority::Urgent => Some("0"), _ => None, }; let mut body = serde_json::json!({ "api_key": api_key, "api_secret": api_secret, "to": phone, "from": from_number, "text": message }); if let Some(class) = message_class { body["message-class"] = serde_json::Value::String(class.to_string()); } let response = client .post("https://rest.nexmo.com/sms/json") .json(&body) .send() .await?; if !response.status().is_success() { let error_text = response.text().await?; return Err(format!("Vonage API error: {error_text}").into()); } let json: serde_json::Value = response.json().await?; let messages = json["messages"].as_array(); if let Some(msgs) = messages { if let Some(first) = msgs.first() { if first["status"].as_str() == Some("0") { return Ok(first["message-id"].as_str().map(|s| s.to_string())); } let error_text = first["error-text"].as_str().unwrap_or("Unknown error"); return Err(format!("Vonage error: {error_text}").into()); } } Err("Invalid Vonage response".into()) } async fn send_via_messagebird( state: &AppState, bot_id: &Uuid, phone: &str, message: &str, priority: &SmsPriority, ) -> Result, Box> { let config_manager = ConfigManager::new(state.conn.clone()); let api_key = config_manager .get_config(bot_id, "messagebird-api-key", None) .map_err(|_| "MessageBird API key not configured. Set messagebird-api-key in config.")?; let originator = config_manager .get_config(bot_id, "messagebird-originator", None) .map_err(|_| { "MessageBird originator not configured. Set messagebird-originator in config." })?; let client = reqwest::Client::new(); let type_details = match priority { SmsPriority::Urgent => Some(serde_json::json!({"class": 0})), SmsPriority::High => Some(serde_json::json!({"class": 1})), _ => None, }; let mut body = serde_json::json!({ "originator": originator, "recipients": [phone], "body": message }); if let Some(details) = type_details { body["typeDetails"] = details; } let response = client .post("https://rest.messagebird.com/messages") .header("Authorization", format!("AccessKey {}", api_key)) .json(&body) .send() .await?; if response.status().is_success() { let json: serde_json::Value = response.json().await?; Ok(json["id"].as_str().map(|s| s.to_string())) } else { let error_text = response.text().await?; Err(format!("MessageBird API error: {}", error_text).into()) } } async fn send_via_custom_webhook( state: &AppState, bot_id: &Uuid, webhook_name: &str, phone: &str, message: &str, priority: &SmsPriority, ) -> Result, Box> { let config_manager = ConfigManager::new(state.conn.clone()); let webhook_url = config_manager .get_config(bot_id, &format!("{}-webhook-url", webhook_name), None) .map_err(|_| { format!( "Custom SMS webhook URL not configured. Set {}-webhook-url in config.", webhook_name ) })?; let api_key = config_manager .get_config(bot_id, &format!("{}-api-key", webhook_name), None) .ok(); let client = reqwest::Client::new(); let payload = serde_json::json!({ "to": phone, "message": message, "provider": webhook_name, "priority": priority.to_string() }); let mut request = client.post(&webhook_url).json(&payload); if let Some(key) = api_key { request = request.header("Authorization", format!("Bearer {}", key)); } let response = request.send().await?; if response.status().is_success() { let json: serde_json::Value = response .json() .await .unwrap_or_else(|_| serde_json::json!({})); Ok(json["message_id"] .as_str() .or_else(|| json["id"].as_str()) .map(|s| s.to_string())) } else { let error_text = response.text().await?; Err(format!("Custom webhook error: {}", error_text).into()) } }