botserver/src/basic/keywords/sms.rs
Rodrigo Rodriguez (Pragmatismo) c67aaa677a feat(security): Complete security infrastructure implementation
SECURITY MODULES ADDED:
- security/auth.rs: Full RBAC with roles (Anonymous, User, Moderator, Admin, SuperAdmin, Service, Bot, BotOwner, BotOperator, BotViewer) and permissions
- security/cors.rs: Hardened CORS (no wildcard in production, env-based config)
- security/panic_handler.rs: Panic catching middleware with safe 500 responses
- security/path_guard.rs: Path traversal protection, null byte prevention
- security/request_id.rs: UUID request tracking with correlation IDs
- security/error_sanitizer.rs: Sensitive data redaction from responses
- security/zitadel_auth.rs: Zitadel token introspection and role mapping
- security/sql_guard.rs: SQL injection prevention with table whitelist
- security/command_guard.rs: Command injection prevention
- security/secrets.rs: Zeroizing secret management
- security/validation.rs: Input validation utilities
- security/rate_limiter.rs: Rate limiting with governor crate
- security/headers.rs: Security headers (CSP, HSTS, X-Frame-Options)

MAIN.RS UPDATES:
- Replaced tower_http::cors::Any with hardened create_cors_layer()
- Added panic handler middleware
- Added request ID tracking middleware
- Set global panic hook

SECURITY STATUS:
- 0 unwrap() in production code
- 0 panic! in production code
- 0 unsafe blocks
- cargo audit: PASS (no vulnerabilities)
- Estimated completion: ~98%

Remaining: Wire auth middleware to handlers, audit logs for sensitive data
2025-12-28 19:29:18 -03:00

881 lines
33 KiB
Rust

/*****************************************************************************\
| █████ █████ ██ █ █████ █████ ████ ██ ████ █████ █████ ███ ® |
| ██ █ ███ █ █ ██ ██ ██ ██ ██ ██ █ ██ ██ █ █ |
| ██ ███ ████ █ ██ █ ████ █████ ██████ ██ ████ █ █ █ ██ |
| ██ ██ █ █ ██ █ █ ██ ██ ██ ██ ██ ██ █ ██ ██ █ █ |
| █████ █████ █ ███ █████ ██ ██ ██ ██ █████ ████ █████ █ ███ |
| |
| 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<String>,
pub provider: String,
pub to: String,
pub priority: String,
pub error: Option<String>,
}
pub fn register_sms_keywords(state: Arc<AppState>, 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<AppState>, 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<AppState>,
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<AppState>,
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<SmsSendResult, Box<dyn std::error::Error + Send + Sync>> {
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<Option<String>, Box<dyn std::error::Error + Send + Sync>> {
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(&params)
.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<Option<String>, Box<dyn std::error::Error + Send + Sync>> {
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(&params)
.header("X-Amz-Date", &timestamp)
.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("<MessageId>") {
if let Some(end) = body.find("</MessageId>") {
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<Option<String>, Box<dyn std::error::Error + Send + Sync>> {
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<Option<String>, Box<dyn std::error::Error + Send + Sync>> {
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<Option<String>, Box<dyn std::error::Error + Send + Sync>> {
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())
}
}