use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use hmac::{Hmac, Mac};
use reqwest::Client;
use sha1::Sha1;
use std::collections::BTreeMap;
use std::time::Duration;
use uuid::Uuid;
use crate::{config::TwilioConfig, errors::TwilioError, models::{MessageCreateRequest, MessageCreateResponse}};
#[derive(Clone)]
pub struct TwilioClient {
base_url: String,
account_sid: String,
auth_token: String,
http: Client,
mock: bool,
}
impl TwilioClient {
pub fn new(cfg: &TwilioConfig) -> Result<Self, TwilioError> {
let http = Client::builder()
.timeout(Duration::from_secs(30))
.user_agent("infraqueue-twilio/0.1")
.build()
.map_err(|e| TwilioError::Internal(format!("failed to build http client: {e}")))?;
Ok(Self {
base_url: cfg.base_url.clone(),
account_sid: cfg.account_sid.clone(),
auth_token: cfg.auth_token.clone(),
http,
mock: cfg.mock,
})
}
pub async fn create_message(&self, req: &MessageCreateRequest) -> Result<MessageCreateResponse, TwilioError> {
if self.mock {
let sid = format!("SM{}", Uuid::new_v4().simple());
return Ok(MessageCreateResponse { sid, to: req.to.clone(), status: "queued".to_string() });
}
let url = format!(
"{}/2010-04-01/Accounts/{}/Messages.json",
self.base_url.trim_end_matches('/'), self.account_sid
);
let mut form = vec![("To", req.to.clone())];
if let Some(msid) = &req.messaging_service_sid {
form.push(("MessagingServiceSid", msid.clone()));
}
if let Some(from) = &req.from {
form.push(("From", from.clone()));
}
form.push(("Body", req.body.clone()));
if let Some(urls) = &req.media_urls {
for u in urls {
form.push(("MediaUrl", u.clone()));
}
}
let resp = self
.http
.post(&url)
.basic_auth(&self.account_sid, Some(&self.auth_token))
.form(&form)
.send()
.await
.map_err(|e| TwilioError::Internal(format!("twilio request failed: {e}")))?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(TwilioError::Status { status, body: text });
}
let sid = extract_sid(&text).unwrap_or_else(|| format!("SM{}", Uuid::new_v4().simple()));
Ok(MessageCreateResponse { sid, to: req.to.clone(), status: "queued".to_string() })
}
pub fn compute_webhook_signature(&self, full_url: &str, params: &BTreeMap<String, String>) -> String {
let mut data = String::from(full_url);
for (k, v) in params.iter() {
data.push_str(k);
data.push_str(v);
}
let mut mac = Hmac::<Sha1>::new_from_slice(self.auth_token.as_bytes()).expect("HMAC key");
mac.update(data.as_bytes());
let result = mac.finalize().into_bytes();
BASE64.encode(result)
}
}
fn extract_sid(body: &str) -> Option<String> {
let key = "\"sid\"";
if let Some(i) = body.find(key) {
let rest = &body[i + key.len()..];
if let Some(j) = rest.find('"') {
let rest = &rest[j + 1..];
if let Some(k) = rest.find('"') {
return Some(rest[..k].to_string());
}
}
}
None
}