infraqueue-twilio 0.1.1

Twilio client for INFRAQUEUE
Documentation
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
}