- Add shell_script_arg() method for bash/sh/cmd -c scripts - Allow > < redirects in shell scripts (blocked in regular args) - Allow && || command chaining in shell scripts - Update safe_sh_command functions to use shell_script_arg - Update run_commands, start, and LLM server commands - Block dangerous patterns: backticks, path traversal - Fix struct field mismatches and type errors
860 lines
27 KiB
Rust
860 lines
27 KiB
Rust
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<String>,
|
|
pub status_callback_url: Option<String>,
|
|
}
|
|
|
|
#[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<DateTime<Utc>>,
|
|
pub date_sent: Option<DateTime<Utc>>,
|
|
pub date_updated: Option<DateTime<Utc>>,
|
|
pub price: Option<String>,
|
|
pub price_unit: Option<String>,
|
|
pub error_code: Option<i32>,
|
|
pub error_message: Option<String>,
|
|
pub num_segments: Option<u32>,
|
|
pub num_media: Option<u32>,
|
|
}
|
|
|
|
#[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<Vec<String>>,
|
|
pub status_callback: Option<String>,
|
|
pub max_price: Option<String>,
|
|
pub validity_period: Option<u32>,
|
|
pub schedule_type: Option<ScheduleType>,
|
|
pub send_at: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
#[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<u32>,
|
|
pub num_segments: Option<u32>,
|
|
pub sms_sid: Option<String>,
|
|
pub sms_status: Option<String>,
|
|
pub api_version: Option<String>,
|
|
pub from_city: Option<String>,
|
|
pub from_state: Option<String>,
|
|
pub from_zip: Option<String>,
|
|
pub from_country: Option<String>,
|
|
pub to_city: Option<String>,
|
|
pub to_state: Option<String>,
|
|
pub to_zip: Option<String>,
|
|
pub to_country: Option<String>,
|
|
pub media_urls: Vec<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
pub error_code: Option<i32>,
|
|
pub error_message: Option<String>,
|
|
}
|
|
|
|
#[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<ConversationMessage>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub last_message_at: DateTime<Utc>,
|
|
pub metadata: HashMap<String, String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ConversationMessage {
|
|
pub sid: String,
|
|
pub direction: MessageDirection,
|
|
pub body: String,
|
|
pub timestamp: DateTime<Utc>,
|
|
pub status: MessageStatus,
|
|
}
|
|
|
|
pub struct TwilioSmsChannel {
|
|
config: TwilioConfig,
|
|
http_client: Client,
|
|
conversations: Arc<RwLock<HashMap<String, ConversationContext>>>,
|
|
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<SmsMessage, TwilioError> {
|
|
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<String>,
|
|
body: &str,
|
|
) -> Vec<Result<SmsMessage, TwilioError>> {
|
|
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<SmsMessage, TwilioError> {
|
|
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<u32>,
|
|
) -> Result<Vec<SmsMessage>, 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<SmsMessage, TwilioError> {
|
|
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<String, String>) -> Result<IncomingWebhook, TwilioError> {
|
|
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<String, String>) -> Result<StatusCallback, TwilioError> {
|
|
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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Response>");
|
|
|
|
if let Some(msg) = message {
|
|
if let Some(media) = media_url {
|
|
twiml.push_str(&format!(
|
|
"\n <Message>\n <Body>{}</Body>\n <Media>{}</Media>\n </Message>",
|
|
escape_xml(msg),
|
|
escape_xml(media)
|
|
));
|
|
} else {
|
|
twiml.push_str(&format!(
|
|
"\n <Message>{}</Message>",
|
|
escape_xml(msg)
|
|
));
|
|
}
|
|
}
|
|
|
|
twiml.push_str("\n</Response>");
|
|
twiml
|
|
}
|
|
|
|
pub async fn get_conversation(&self, phone_number: &str) -> Option<ConversationContext> {
|
|
let conversations = self.conversations.read().await;
|
|
conversations.get(phone_number).cloned()
|
|
}
|
|
|
|
pub async fn get_phone_numbers(&self) -> Result<Vec<TwilioPhoneNumber>, 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<String, String>,
|
|
) -> 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::<Sha1>::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<MessageStatus, TwilioError> {
|
|
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<String>,
|
|
to: String,
|
|
body: Option<String>,
|
|
status: String,
|
|
direction: Option<String>,
|
|
date_created: Option<String>,
|
|
date_sent: Option<String>,
|
|
date_updated: Option<String>,
|
|
price: Option<String>,
|
|
price_unit: Option<String>,
|
|
error_code: Option<i32>,
|
|
error_message: Option<String>,
|
|
num_segments: Option<String>,
|
|
num_media: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct MessageListResponse {
|
|
messages: Vec<TwilioMessageResponse>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct PhoneNumberListResponse {
|
|
incoming_phone_numbers: Vec<PhoneNumberResponse>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct PhoneNumberResponse {
|
|
sid: String,
|
|
phone_number: String,
|
|
friendly_name: String,
|
|
capabilities: CapabilitiesResponse,
|
|
status: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct CapabilitiesResponse {
|
|
sms: Option<bool>,
|
|
mms: Option<bool>,
|
|
voice: Option<bool>,
|
|
fax: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TwilioApiError {
|
|
pub code: i32,
|
|
pub message: String,
|
|
pub more_info: Option<String>,
|
|
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,
|
|
}
|
|
}
|