use chrono::{DateTime, Utc}; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TwilioConfig { pub account_sid: String, pub auth_token: String, pub from_number: String, pub messaging_service_sid: Option, pub status_callback_url: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SmsMessage { pub sid: String, pub account_sid: String, pub from: String, pub to: String, pub body: String, pub status: MessageStatus, pub direction: MessageDirection, pub date_created: Option>, pub date_sent: Option>, pub date_updated: Option>, pub price: Option, pub price_unit: Option, pub error_code: Option, pub error_message: Option, pub num_segments: Option, pub num_media: Option, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum MessageStatus { Queued, Sending, Sent, Delivered, Undelivered, Failed, Receiving, Received, Accepted, Scheduled, Read, PartiallyDelivered, Canceled, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum MessageDirection { Inbound, OutboundApi, OutboundCall, OutboundReply, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SendSmsRequest { pub to: String, pub body: String, pub media_url: Option>, pub status_callback: Option, pub max_price: Option, pub validity_period: Option, pub schedule_type: Option, pub send_at: Option>, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum ScheduleType { Fixed, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IncomingWebhook { pub message_sid: String, pub account_sid: String, pub from: String, pub to: String, pub body: String, pub num_media: Option, pub num_segments: Option, pub sms_sid: Option, pub sms_status: Option, pub api_version: Option, pub from_city: Option, pub from_state: Option, pub from_zip: Option, pub from_country: Option, pub to_city: Option, pub to_state: Option, pub to_zip: Option, pub to_country: Option, pub media_urls: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StatusCallback { pub message_sid: String, pub message_status: MessageStatus, pub account_sid: String, pub from: String, pub to: String, pub api_version: Option, pub error_code: Option, pub error_message: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TwilioPhoneNumber { pub sid: String, pub phone_number: String, pub friendly_name: String, pub capabilities: PhoneCapabilities, pub status: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PhoneCapabilities { pub sms: bool, pub mms: bool, pub voice: bool, pub fax: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConversationContext { pub id: Uuid, pub phone_number: String, pub messages: Vec, pub created_at: DateTime, pub last_message_at: DateTime, pub metadata: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConversationMessage { pub sid: String, pub direction: MessageDirection, pub body: String, pub timestamp: DateTime, pub status: MessageStatus, } pub struct TwilioSmsChannel { config: TwilioConfig, http_client: Client, conversations: Arc>>, base_url: String, } impl TwilioSmsChannel { pub fn new(config: TwilioConfig) -> Self { Self { config, http_client: Client::new(), conversations: Arc::new(RwLock::new(HashMap::new())), base_url: "https://api.twilio.com/2010-04-01".to_string(), } } pub async fn send_sms(&self, request: SendSmsRequest) -> Result { let url = format!( "{}/Accounts/{}/Messages.json", self.base_url, self.config.account_sid ); let mut params: HashMap<&str, String> = HashMap::new(); params.insert("To", request.to.clone()); params.insert("Body", request.body.clone()); if let Some(ref msid) = self.config.messaging_service_sid { params.insert("MessagingServiceSid", msid.clone()); } else { params.insert("From", self.config.from_number.clone()); } if let Some(ref callback) = request.status_callback.or(self.config.status_callback_url.clone()) { params.insert("StatusCallback", callback.clone()); } if let Some(ref media_urls) = request.media_url { for (i, url) in media_urls.iter().enumerate() { params.insert(Box::leak(format!("MediaUrl{}", i).into_boxed_str()), url.clone()); } } if let Some(ref max_price) = request.max_price { params.insert("MaxPrice", max_price.clone()); } if let Some(validity) = request.validity_period { params.insert("ValidityPeriod", validity.to_string()); } if let Some(ref schedule_type) = request.schedule_type { let schedule_str = match schedule_type { ScheduleType::Fixed => "fixed", }; params.insert("ScheduleType", schedule_str.to_string()); if let Some(send_at) = request.send_at { params.insert("SendAt", send_at.to_rfc3339()); } } let response = self .http_client .post(&url) .basic_auth(&self.config.account_sid, Some(&self.config.auth_token)) .form(¶ms) .send() .await .map_err(|e| TwilioError::NetworkError(e.to_string()))?; if !response.status().is_success() { let error: TwilioApiError = response .json() .await .unwrap_or_else(|_| TwilioApiError { code: 0, message: "Unknown error".to_string(), more_info: None, status: 500, }); return Err(TwilioError::ApiError(error)); } let message: TwilioMessageResponse = response .json() .await .map_err(|e| TwilioError::ParseError(e.to_string()))?; let sms_message = self.convert_response_to_message(message); self.update_conversation(&request.to, &sms_message, MessageDirection::OutboundApi) .await; Ok(sms_message) } pub async fn send_bulk_sms( &self, recipients: Vec, body: &str, ) -> Vec> { let mut results = Vec::with_capacity(recipients.len()); for recipient in recipients { let request = SendSmsRequest { to: recipient, body: body.to_string(), media_url: None, status_callback: None, max_price: None, validity_period: None, schedule_type: None, send_at: None, }; let result = self.send_sms(request).await; results.push(result); tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; } results } pub async fn get_message(&self, message_sid: &str) -> Result { let url = format!( "{}/Accounts/{}/Messages/{}.json", self.base_url, self.config.account_sid, message_sid ); let response = self .http_client .get(&url) .basic_auth(&self.config.account_sid, Some(&self.config.auth_token)) .send() .await .map_err(|e| TwilioError::NetworkError(e.to_string()))?; if !response.status().is_success() { let error: TwilioApiError = response .json() .await .unwrap_or_else(|_| TwilioApiError { code: 0, message: "Message not found".to_string(), more_info: None, status: 404, }); return Err(TwilioError::ApiError(error)); } let message: TwilioMessageResponse = response .json() .await .map_err(|e| TwilioError::ParseError(e.to_string()))?; Ok(self.convert_response_to_message(message)) } pub async fn list_messages( &self, to: Option<&str>, from: Option<&str>, date_sent: Option<&str>, page_size: Option, ) -> Result, TwilioError> { let mut url = format!( "{}/Accounts/{}/Messages.json", self.base_url, self.config.account_sid ); let mut query_params = Vec::new(); if let Some(to_number) = to { query_params.push(format!("To={}", urlencoding::encode(to_number))); } if let Some(from_number) = from { query_params.push(format!("From={}", urlencoding::encode(from_number))); } if let Some(date) = date_sent { query_params.push(format!("DateSent={}", urlencoding::encode(date))); } if let Some(size) = page_size { query_params.push(format!("PageSize={}", size)); } if !query_params.is_empty() { url = format!("{}?{}", url, query_params.join("&")); } let response = self .http_client .get(&url) .basic_auth(&self.config.account_sid, Some(&self.config.auth_token)) .send() .await .map_err(|e| TwilioError::NetworkError(e.to_string()))?; if !response.status().is_success() { let error: TwilioApiError = response .json() .await .unwrap_or_else(|_| TwilioApiError { code: 0, message: "Failed to list messages".to_string(), more_info: None, status: 500, }); return Err(TwilioError::ApiError(error)); } let list_response: MessageListResponse = response .json() .await .map_err(|e| TwilioError::ParseError(e.to_string()))?; let messages = list_response .messages .into_iter() .map(|m| self.convert_response_to_message(m)) .collect(); Ok(messages) } pub async fn delete_message(&self, message_sid: &str) -> Result<(), TwilioError> { let url = format!( "{}/Accounts/{}/Messages/{}.json", self.base_url, self.config.account_sid, message_sid ); let response = self .http_client .delete(&url) .basic_auth(&self.config.account_sid, Some(&self.config.auth_token)) .send() .await .map_err(|e| TwilioError::NetworkError(e.to_string()))?; if !response.status().is_success() { let error: TwilioApiError = response .json() .await .unwrap_or_else(|_| TwilioApiError { code: 0, message: "Failed to delete message".to_string(), more_info: None, status: 500, }); return Err(TwilioError::ApiError(error)); } Ok(()) } pub async fn cancel_scheduled_message(&self, message_sid: &str) -> Result { let url = format!( "{}/Accounts/{}/Messages/{}.json", self.base_url, self.config.account_sid, message_sid ); let response = self .http_client .post(&url) .basic_auth(&self.config.account_sid, Some(&self.config.auth_token)) .form(&[("Status", "canceled")]) .send() .await .map_err(|e| TwilioError::NetworkError(e.to_string()))?; if !response.status().is_success() { let error: TwilioApiError = response .json() .await .unwrap_or_else(|_| TwilioApiError { code: 0, message: "Failed to cancel message".to_string(), more_info: None, status: 500, }); return Err(TwilioError::ApiError(error)); } let message: TwilioMessageResponse = response .json() .await .map_err(|e| TwilioError::ParseError(e.to_string()))?; Ok(self.convert_response_to_message(message)) } pub fn parse_incoming_webhook(&self, params: &HashMap) -> Result { let message_sid = params .get("MessageSid") .ok_or_else(|| TwilioError::ParseError("Missing MessageSid".to_string()))? .clone(); let account_sid = params .get("AccountSid") .ok_or_else(|| TwilioError::ParseError("Missing AccountSid".to_string()))? .clone(); let from = params .get("From") .ok_or_else(|| TwilioError::ParseError("Missing From".to_string()))? .clone(); let to = params .get("To") .ok_or_else(|| TwilioError::ParseError("Missing To".to_string()))? .clone(); let body = params.get("Body").cloned().unwrap_or_default(); let num_media = params .get("NumMedia") .and_then(|s| s.parse().ok()); let num_segments = params .get("NumSegments") .and_then(|s| s.parse().ok()); let mut media_urls = Vec::new(); if let Some(count) = num_media { for i in 0..count { if let Some(url) = params.get(&format!("MediaUrl{}", i)) { media_urls.push(url.clone()); } } } Ok(IncomingWebhook { message_sid, account_sid, from, to, body, num_media, num_segments, sms_sid: params.get("SmsSid").cloned(), sms_status: params.get("SmsStatus").cloned(), api_version: params.get("ApiVersion").cloned(), from_city: params.get("FromCity").cloned(), from_state: params.get("FromState").cloned(), from_zip: params.get("FromZip").cloned(), from_country: params.get("FromCountry").cloned(), to_city: params.get("ToCity").cloned(), to_state: params.get("ToState").cloned(), to_zip: params.get("ToZip").cloned(), to_country: params.get("ToCountry").cloned(), media_urls, }) } pub fn parse_status_callback(&self, params: &HashMap) -> Result { let message_sid = params .get("MessageSid") .ok_or_else(|| TwilioError::ParseError("Missing MessageSid".to_string()))? .clone(); let message_status_str = params .get("MessageStatus") .ok_or_else(|| TwilioError::ParseError("Missing MessageStatus".to_string()))?; let message_status = parse_message_status(message_status_str)?; let account_sid = params .get("AccountSid") .ok_or_else(|| TwilioError::ParseError("Missing AccountSid".to_string()))? .clone(); let from = params.get("From").cloned().unwrap_or_default(); let to = params.get("To").cloned().unwrap_or_default(); let error_code = params .get("ErrorCode") .and_then(|s| s.parse().ok()); Ok(StatusCallback { message_sid, message_status, account_sid, from, to, api_version: params.get("ApiVersion").cloned(), error_code, error_message: params.get("ErrorMessage").cloned(), }) } pub async fn handle_incoming_message(&self, webhook: IncomingWebhook) -> ConversationContext { let message = SmsMessage { sid: webhook.message_sid.clone(), account_sid: webhook.account_sid.clone(), from: webhook.from.clone(), to: webhook.to.clone(), body: webhook.body.clone(), status: MessageStatus::Received, direction: MessageDirection::Inbound, date_created: Some(Utc::now()), date_sent: Some(Utc::now()), date_updated: Some(Utc::now()), price: None, price_unit: None, error_code: None, error_message: None, num_segments: webhook.num_segments, num_media: webhook.num_media, }; self.update_conversation(&webhook.from, &message, MessageDirection::Inbound) .await } pub fn generate_twiml_response(&self, message: Option<&str>, media_url: Option<&str>) -> String { let mut twiml = String::from("\n"); if let Some(msg) = message { if let Some(media) = media_url { twiml.push_str(&format!( "\n \n {}\n {}\n ", escape_xml(msg), escape_xml(media) )); } else { twiml.push_str(&format!( "\n {}", escape_xml(msg) )); } } twiml.push_str("\n"); twiml } pub async fn get_conversation(&self, phone_number: &str) -> Option { let conversations = self.conversations.read().await; conversations.get(phone_number).cloned() } pub async fn get_phone_numbers(&self) -> Result, TwilioError> { let url = format!( "{}/Accounts/{}/IncomingPhoneNumbers.json", self.base_url, self.config.account_sid ); let response = self .http_client .get(&url) .basic_auth(&self.config.account_sid, Some(&self.config.auth_token)) .send() .await .map_err(|e| TwilioError::NetworkError(e.to_string()))?; if !response.status().is_success() { let error: TwilioApiError = response .json() .await .unwrap_or_else(|_| TwilioApiError { code: 0, message: "Failed to list phone numbers".to_string(), more_info: None, status: 500, }); return Err(TwilioError::ApiError(error)); } let list_response: PhoneNumberListResponse = response .json() .await .map_err(|e| TwilioError::ParseError(e.to_string()))?; let numbers = list_response .incoming_phone_numbers .into_iter() .map(|p| TwilioPhoneNumber { sid: p.sid, phone_number: p.phone_number, friendly_name: p.friendly_name, capabilities: PhoneCapabilities { sms: p.capabilities.sms.unwrap_or(false), mms: p.capabilities.mms.unwrap_or(false), voice: p.capabilities.voice.unwrap_or(false), fax: p.capabilities.fax.unwrap_or(false), }, status: p.status, }) .collect(); Ok(numbers) } pub fn validate_webhook_signature( &self, signature: &str, url: &str, params: &HashMap, ) -> bool { use hmac::{Hmac, Mac}; use sha1::Sha1; let mut sorted_params: Vec<(&String, &String)> = params.iter().collect(); sorted_params.sort_by(|a, b| a.0.cmp(b.0)); let mut data = url.to_string(); for (key, value) in sorted_params { data.push_str(key); data.push_str(value); } let mut mac = match Hmac::::new_from_slice(self.config.auth_token.as_bytes()) { Ok(m) => m, Err(_) => return false, }; mac.update(data.as_bytes()); let result = mac.finalize(); let computed_signature = base64::Engine::encode( &base64::engine::general_purpose::STANDARD, result.into_bytes(), ); signature == computed_signature } async fn update_conversation( &self, phone_number: &str, message: &SmsMessage, direction: MessageDirection, ) -> ConversationContext { let mut conversations = self.conversations.write().await; let context = conversations .entry(phone_number.to_string()) .or_insert_with(|| ConversationContext { id: Uuid::new_v4(), phone_number: phone_number.to_string(), messages: Vec::new(), created_at: Utc::now(), last_message_at: Utc::now(), metadata: HashMap::new(), }); context.messages.push(ConversationMessage { sid: message.sid.clone(), direction, body: message.body.clone(), timestamp: Utc::now(), status: message.status, }); context.last_message_at = Utc::now(); context.clone() } fn convert_response_to_message(&self, response: TwilioMessageResponse) -> SmsMessage { SmsMessage { sid: response.sid, account_sid: response.account_sid, from: response.from.unwrap_or_default(), to: response.to, body: response.body.unwrap_or_default(), status: parse_message_status(&response.status).unwrap_or(MessageStatus::Queued), direction: parse_direction(&response.direction.unwrap_or_default()), date_created: response.date_created.and_then(|d| DateTime::parse_from_rfc2822(&d).ok().map(|dt| dt.with_timezone(&Utc))), date_sent: response.date_sent.and_then(|d| DateTime::parse_from_rfc2822(&d).ok().map(|dt| dt.with_timezone(&Utc))), date_updated: response.date_updated.and_then(|d| DateTime::parse_from_rfc2822(&d).ok().map(|dt| dt.with_timezone(&Utc))), price: response.price, price_unit: response.price_unit, error_code: response.error_code, error_message: response.error_message, num_segments: response.num_segments.and_then(|s| s.parse().ok()), num_media: response.num_media.and_then(|s| s.parse().ok()), } } } fn parse_message_status(status: &str) -> Result { match status.to_lowercase().as_str() { "queued" => Ok(MessageStatus::Queued), "sending" => Ok(MessageStatus::Sending), "sent" => Ok(MessageStatus::Sent), "delivered" => Ok(MessageStatus::Delivered), "undelivered" => Ok(MessageStatus::Undelivered), "failed" => Ok(MessageStatus::Failed), "receiving" => Ok(MessageStatus::Receiving), "received" => Ok(MessageStatus::Received), "accepted" => Ok(MessageStatus::Accepted), "scheduled" => Ok(MessageStatus::Scheduled), "read" => Ok(MessageStatus::Read), "partially_delivered" => Ok(MessageStatus::PartiallyDelivered), "canceled" => Ok(MessageStatus::Canceled), _ => Err(TwilioError::ParseError(format!("Unknown status: {}", status))), } } fn parse_direction(direction: &str) -> MessageDirection { match direction.to_lowercase().as_str() { "inbound" => MessageDirection::Inbound, "outbound-api" => MessageDirection::OutboundApi, "outbound-call" => MessageDirection::OutboundCall, "outbound-reply" => MessageDirection::OutboundReply, _ => MessageDirection::OutboundApi, } } fn escape_xml(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } #[derive(Debug, Deserialize)] struct TwilioMessageResponse { sid: String, account_sid: String, from: Option, to: String, body: Option, status: String, direction: Option, date_created: Option, date_sent: Option, date_updated: Option, price: Option, price_unit: Option, error_code: Option, error_message: Option, num_segments: Option, num_media: Option, } #[derive(Debug, Deserialize)] struct MessageListResponse { messages: Vec, } #[derive(Debug, Deserialize)] struct PhoneNumberListResponse { incoming_phone_numbers: Vec, } #[derive(Debug, Deserialize)] struct PhoneNumberResponse { sid: String, phone_number: String, friendly_name: String, capabilities: CapabilitiesResponse, status: String, } #[derive(Debug, Deserialize)] struct CapabilitiesResponse { sms: Option, mms: Option, voice: Option, fax: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TwilioApiError { pub code: i32, pub message: String, pub more_info: Option, pub status: i32, } #[derive(Debug, Clone)] pub enum TwilioError { NetworkError(String), ApiError(TwilioApiError), ParseError(String), ConfigError(String), InvalidSignature, } impl std::fmt::Display for TwilioError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::NetworkError(e) => write!(f, "Network error: {}", e), Self::ApiError(e) => write!(f, "Twilio API error {}: {}", e.code, e.message), Self::ParseError(e) => write!(f, "Parse error: {}", e), Self::ConfigError(e) => write!(f, "Configuration error: {}", e), Self::InvalidSignature => write!(f, "Invalid webhook signature"), } } } impl std::error::Error for TwilioError {} pub fn create_twilio_config( account_sid: &str, auth_token: &str, from_number: &str, ) -> TwilioConfig { TwilioConfig { account_sid: account_sid.to_string(), auth_token: auth_token.to_string(), from_number: from_number.to_string(), status_callback_url: None, messaging_service_sid: None, } }