642 lines
24 KiB
Rust
642 lines
24 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. |
|
|
| |
|
|
\*****************************************************************************/
|
|
|
|
//! SMS keyword for sending text messages
|
|
//!
|
|
//! Provides BASIC keywords:
|
|
//! - SEND_SMS phone, message -> sends SMS to phone number
|
|
//! - SEND_SMS phone, message, provider -> sends SMS using specific provider
|
|
//!
|
|
//! Supported providers:
|
|
//! - twilio (default)
|
|
//! - aws_sns
|
|
//! - nexmo/vonage
|
|
//! - messagebird
|
|
|
|
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;
|
|
|
|
/// SMS Provider types
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum SmsProvider {
|
|
Twilio,
|
|
AwsSns,
|
|
Vonage,
|
|
MessageBird,
|
|
Custom(String),
|
|
}
|
|
|
|
impl From<&str> for SmsProvider {
|
|
fn from(s: &str) -> Self {
|
|
match s.to_lowercase().as_str() {
|
|
"twilio" => SmsProvider::Twilio,
|
|
"aws" | "aws_sns" | "sns" => SmsProvider::AwsSns,
|
|
"vonage" | "nexmo" => SmsProvider::Vonage,
|
|
"messagebird" => SmsProvider::MessageBird,
|
|
other => SmsProvider::Custom(other.to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// SMS send result
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SmsSendResult {
|
|
pub success: bool,
|
|
pub message_id: Option<String>,
|
|
pub provider: String,
|
|
pub to: String,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
/// Register SMS keywords
|
|
pub fn register_sms_keywords(state: Arc<AppState>, user: UserSession, engine: &mut Engine) {
|
|
register_send_sms_keyword(state.clone(), user.clone(), engine);
|
|
register_send_sms_with_provider_keyword(state, user, engine);
|
|
}
|
|
|
|
/// SEND_SMS phone, message
|
|
/// Sends an SMS message using the default configured provider
|
|
pub fn register_send_sms_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$"],
|
|
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,
|
|
)
|
|
.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));
|
|
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,
|
|
))),
|
|
}
|
|
},
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
/// SEND_SMS phone, message, provider
|
|
/// Sends an SMS message using a specific provider
|
|
pub fn register_send_sms_with_provider_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();
|
|
|
|
trace!("SEND_SMS: Sending SMS to {} via {}", phone, provider);
|
|
|
|
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),
|
|
)
|
|
.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));
|
|
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,
|
|
))),
|
|
}
|
|
},
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
async fn execute_send_sms(
|
|
state: &AppState,
|
|
user: &UserSession,
|
|
phone: &str,
|
|
message: &str,
|
|
provider_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;
|
|
|
|
// Get provider from config or use override
|
|
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());
|
|
|
|
// Normalize phone number
|
|
let normalized_phone = normalize_phone_number(phone);
|
|
|
|
// Send via appropriate provider
|
|
let result = match provider {
|
|
SmsProvider::Twilio => send_via_twilio(state, &bot_id, &normalized_phone, message).await,
|
|
SmsProvider::AwsSns => send_via_aws_sns(state, &bot_id, &normalized_phone, message).await,
|
|
SmsProvider::Vonage => send_via_vonage(state, &bot_id, &normalized_phone, message).await,
|
|
SmsProvider::MessageBird => {
|
|
send_via_messagebird(state, &bot_id, &normalized_phone, message).await
|
|
}
|
|
SmsProvider::Custom(name) => {
|
|
send_via_custom_webhook(state, &bot_id, &name, &normalized_phone, message).await
|
|
}
|
|
};
|
|
|
|
match result {
|
|
Ok(message_id) => {
|
|
info!(
|
|
"SMS sent successfully to {} via {}: {}",
|
|
normalized_phone,
|
|
provider_name,
|
|
message_id.as_deref().unwrap_or("no-id")
|
|
);
|
|
Ok(SmsSendResult {
|
|
success: true,
|
|
message_id,
|
|
provider: provider_name,
|
|
to: normalized_phone,
|
|
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,
|
|
error: Some(e.to_string()),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
fn normalize_phone_number(phone: &str) -> String {
|
|
// Remove all non-digit characters except leading +
|
|
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 {
|
|
// Assume US number without country code
|
|
format!("+1{}", digits)
|
|
} else if digits.len() == 11 && digits.starts_with('1') {
|
|
// US number with country code but no +
|
|
format!("+{}", digits)
|
|
} else {
|
|
// Return as-is with + prefix
|
|
format!("+{}", digits)
|
|
}
|
|
}
|
|
|
|
async fn send_via_twilio(
|
|
state: &AppState,
|
|
bot_id: &Uuid,
|
|
phone: &str,
|
|
message: &str,
|
|
) -> 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 params = [("To", phone), ("From", &from_number), ("Body", message)];
|
|
|
|
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,
|
|
) -> 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());
|
|
|
|
// Use HTTP API directly instead of AWS SDK
|
|
let client = reqwest::Client::new();
|
|
let url = format!("https://sns.{}.amazonaws.com/", region);
|
|
|
|
// Create timestamp for AWS Signature
|
|
let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
|
|
let _date = ×tamp[..8];
|
|
|
|
// Build the request parameters
|
|
let params = [
|
|
("Action", "Publish"),
|
|
("PhoneNumber", phone),
|
|
("Message", message),
|
|
("Version", "2010-03-31"),
|
|
];
|
|
|
|
// For simplicity, using query string auth (requires proper AWS SigV4 in production)
|
|
// This is a simplified implementation - in production use aws-sigv4 crate
|
|
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?;
|
|
// Parse MessageId from XML response
|
|
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,
|
|
) -> 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 payload = serde_json::json!({
|
|
"api_key": api_key,
|
|
"api_secret": api_secret,
|
|
"to": phone.trim_start_matches('+'),
|
|
"from": from_number,
|
|
"text": message
|
|
});
|
|
|
|
let response = client
|
|
.post("https://rest.nexmo.com/sms/json")
|
|
.json(&payload)
|
|
.send()
|
|
.await?;
|
|
|
|
if response.status().is_success() {
|
|
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()));
|
|
} else {
|
|
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())
|
|
} else {
|
|
let error_text = response.text().await?;
|
|
Err(format!("Vonage API error: {}", error_text).into())
|
|
}
|
|
}
|
|
|
|
async fn send_via_messagebird(
|
|
state: &AppState,
|
|
bot_id: &Uuid,
|
|
phone: &str,
|
|
message: &str,
|
|
) -> 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 payload = serde_json::json!({
|
|
"originator": originator,
|
|
"recipients": [phone.trim_start_matches('+')],
|
|
"body": message
|
|
});
|
|
|
|
let response = client
|
|
.post("https://rest.messagebird.com/messages")
|
|
.header("Authorization", format!("AccessKey {}", api_key))
|
|
.json(&payload)
|
|
.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,
|
|
provider_name: &str,
|
|
phone: &str,
|
|
message: &str,
|
|
) -> 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", provider_name), None)
|
|
.map_err(|_| {
|
|
format!(
|
|
"Custom SMS webhook URL not configured. Set {}-webhook-url in config.",
|
|
provider_name
|
|
)
|
|
})?;
|
|
|
|
let api_key = config_manager
|
|
.get_config(bot_id, &format!("{}-api-key", provider_name), None)
|
|
.ok();
|
|
|
|
let client = reqwest::Client::new();
|
|
|
|
let payload = serde_json::json!({
|
|
"to": phone,
|
|
"message": message,
|
|
"provider": provider_name
|
|
});
|
|
|
|
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(serde_json::json!({}));
|
|
Ok(json["message_id"]
|
|
.as_str()
|
|
.or(json["id"].as_str())
|
|
.map(|s| s.to_string()))
|
|
} else {
|
|
let error_text = response.text().await?;
|
|
Err(format!("Custom webhook error: {}", error_text).into())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_normalize_phone_us_10_digit() {
|
|
assert_eq!(normalize_phone_number("5551234567"), "+15551234567");
|
|
}
|
|
|
|
#[test]
|
|
fn test_normalize_phone_us_11_digit() {
|
|
assert_eq!(normalize_phone_number("15551234567"), "+15551234567");
|
|
}
|
|
|
|
#[test]
|
|
fn test_normalize_phone_with_plus() {
|
|
assert_eq!(normalize_phone_number("+15551234567"), "+15551234567");
|
|
}
|
|
|
|
#[test]
|
|
fn test_normalize_phone_with_formatting() {
|
|
assert_eq!(normalize_phone_number("+1 (555) 123-4567"), "+15551234567");
|
|
}
|
|
|
|
#[test]
|
|
fn test_normalize_phone_international() {
|
|
assert_eq!(normalize_phone_number("+44 7911 123456"), "+447911123456");
|
|
}
|
|
|
|
#[test]
|
|
fn test_sms_provider_from_str() {
|
|
assert_eq!(SmsProvider::from("twilio"), SmsProvider::Twilio);
|
|
assert_eq!(SmsProvider::from("aws_sns"), SmsProvider::AwsSns);
|
|
assert_eq!(SmsProvider::from("vonage"), SmsProvider::Vonage);
|
|
assert_eq!(SmsProvider::from("nexmo"), SmsProvider::Vonage);
|
|
assert_eq!(SmsProvider::from("messagebird"), SmsProvider::MessageBird);
|
|
assert_eq!(
|
|
SmsProvider::from("custom"),
|
|
SmsProvider::Custom("custom".to_string())
|
|
);
|
|
}
|
|
}
|