use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use sms_core::{InboundMessage, SendRequest, SendResponse, SmsClient, SmsError};
const PROVIDER: &str = "plivo";
#[derive(Clone, Debug)]
pub struct PlivoClient {
pub auth_id: String,
pub auth_token: String,
pub base_url: String,
#[cfg(feature = "reqwest")]
http: reqwest::Client,
}
impl PlivoClient {
pub fn new<S: Into<String>>(auth_id: S, auth_token: S) -> Self {
Self::with_base_url(auth_id, auth_token, "https://api.plivo.com".to_string())
}
pub fn from_env() -> Result<Self, SmsError> {
let auth_id = std::env::var("PLIVO_AUTH_ID")
.map_err(|_| SmsError::Auth("PLIVO_AUTH_ID not set".into()))?;
let auth_token = std::env::var("PLIVO_AUTH_TOKEN")
.map_err(|_| SmsError::Auth("PLIVO_AUTH_TOKEN not set".into()))?;
Ok(Self::new(auth_id, auth_token))
}
pub fn with_base_url<S: Into<String>>(auth_id: S, auth_token: S, base_url: String) -> Self {
Self {
auth_id: auth_id.into(),
auth_token: auth_token.into(),
base_url,
#[cfg(feature = "reqwest")]
http: reqwest::Client::new(),
}
}
}
#[derive(Debug, Serialize)]
struct PlivoSendRequest<'a> {
src: &'a str,
dst: &'a str,
text: &'a str,
}
#[async_trait]
impl SmsClient for PlivoClient {
async fn send(&self, req: SendRequest<'_>) -> Result<SendResponse, SmsError> {
#[cfg(not(feature = "reqwest"))]
{
let _ = req;
return Err(SmsError::Unexpected("reqwest feature disabled".into()));
}
#[cfg(feature = "reqwest")]
{
let url = format!(
"{}/v1/Account/{}/Message/",
self.base_url.trim_end_matches('/'),
self.auth_id
);
let payload = PlivoSendRequest {
src: req.from,
dst: req.to,
text: req.text,
};
let res = self
.http
.post(url)
.basic_auth(&self.auth_id, Some(&self.auth_token))
.json(&payload)
.send()
.await
.map_err(|e| SmsError::Http(e.to_string()))?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
return Err(SmsError::Provider(format!("HTTP {}: {}", status, body)));
}
let raw_text = res
.text()
.await
.map_err(|e| SmsError::Http(e.to_string()))?;
let raw_json: serde_json::Value = serde_json::from_str(&raw_text)
.unwrap_or_else(|_| serde_json::json!({ "raw": raw_text }));
let id = raw_json
.get("message_uuid")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(sms_core::fallback_id);
Ok(SendResponse {
id,
provider: PROVIDER,
raw: raw_json,
})
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PlivoInbound {
#[serde(rename = "From")]
pub from: String,
#[serde(rename = "To")]
pub to: String,
#[serde(rename = "Text")]
pub text: String,
#[serde(rename = "Type")]
pub r#type: Option<String>,
#[serde(rename = "MessageUUID")]
pub message_uuid: Option<String>,
#[serde(rename = "Time")]
pub time: Option<String>,
#[serde(flatten)]
pub extra: serde_json::Value,
}
impl From<PlivoInbound> for InboundMessage {
fn from(p: PlivoInbound) -> Self {
let ts = p.time.as_deref().and_then(|s| {
time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok()
});
let raw = serde_json::to_value(&p).unwrap_or_default();
InboundMessage {
id: p.message_uuid.clone(),
from: p.from,
to: p.to,
text: p.text,
timestamp: ts,
provider: PROVIDER,
raw,
}
}
}
#[cfg(feature = "axum")]
pub mod axum_handlers {
use super::*;
use axum::http::StatusCode;
use axum::{extract::Form, response::IntoResponse};
pub async fn receive_webhook(Form(inbound): Form<PlivoInbound>) -> impl IntoResponse {
let msg: InboundMessage = inbound.into();
(StatusCode::OK, axum::Json(msg))
}
}
use sms_core::{Headers, InboundWebhook};
impl InboundWebhook for PlivoClient {
fn provider(&self) -> &'static str {
PROVIDER
}
fn parse_inbound(
&self,
_headers: &Headers,
body: &[u8],
) -> Result<sms_core::InboundMessage, sms_core::SmsError> {
let inbound: PlivoInbound = serde_urlencoded::from_bytes(body)
.map_err(|e| sms_core::SmsError::Invalid(format!("form decode: {}", e)))?;
Ok(inbound.into())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn new_sets_production_base_url() {
let client = PlivoClient::new("id", "token");
assert_eq!(client.auth_id, "id");
assert_eq!(client.auth_token, "token");
assert_eq!(client.base_url, "https://api.plivo.com");
}
#[test]
fn with_base_url_overrides() {
let client = PlivoClient::with_base_url("id", "token", "http://localhost:9999".into());
assert_eq!(client.base_url, "http://localhost:9999");
}
#[test]
fn from_env_scenarios() {
unsafe {
std::env::remove_var("PLIVO_AUTH_ID");
std::env::remove_var("PLIVO_AUTH_TOKEN");
}
let err = PlivoClient::from_env().unwrap_err();
assert!(err.to_string().contains("PLIVO_AUTH_ID"));
unsafe { std::env::set_var("PLIVO_AUTH_ID", "test-id"); }
let err = PlivoClient::from_env().unwrap_err();
assert!(err.to_string().contains("PLIVO_AUTH_TOKEN"));
unsafe { std::env::set_var("PLIVO_AUTH_TOKEN", "test-token"); }
let client = PlivoClient::from_env().unwrap();
assert_eq!(client.auth_id, "test-id");
assert_eq!(client.auth_token, "test-token");
unsafe {
std::env::remove_var("PLIVO_AUTH_ID");
std::env::remove_var("PLIVO_AUTH_TOKEN");
}
}
#[test]
fn plivo_send_request_serialization() {
let payload = PlivoSendRequest {
src: "+10005551234",
dst: "+14155551234",
text: "Hello!",
};
let j = serde_json::to_value(&payload).unwrap();
assert_eq!(j["src"], "+10005551234");
assert_eq!(j["dst"], "+14155551234");
assert_eq!(j["text"], "Hello!");
}
#[test]
fn extracts_message_uuid_from_response() {
let raw = json!({
"message": "message(s) queued",
"message_uuid": ["abc-123", "def-456"],
"api_id": "xyz"
});
let id = raw["message_uuid"][0].as_str().unwrap().to_string();
assert_eq!(id, "abc-123");
}
#[test]
fn falls_back_when_uuid_missing() {
let raw = json!({ "message": "queued" });
let id = raw
.get("message_uuid")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(sms_core::fallback_id);
assert!(uuid::Uuid::parse_str(&id).is_ok());
}
#[test]
fn inbound_conversion_with_timestamp() {
let inbound = PlivoInbound {
from: "+15550001111".into(),
to: "+15550002222".into(),
text: "Hello".into(),
r#type: Some("sms".into()),
message_uuid: Some("uuid-1".into()),
time: Some("2024-12-30T12:34:56Z".into()),
extra: json!({ "foo": "bar" }),
};
let msg: InboundMessage = inbound.into();
assert_eq!(msg.from, "+15550001111");
assert_eq!(msg.to, "+15550002222");
assert_eq!(msg.text, "Hello");
assert_eq!(msg.id, Some("uuid-1".into()));
assert_eq!(msg.provider, "plivo");
assert!(msg.timestamp.is_some());
}
#[test]
fn inbound_conversion_without_optional_fields() {
let inbound = PlivoInbound {
from: "+1".into(),
to: "+2".into(),
text: "hi".into(),
r#type: None,
message_uuid: None,
time: None,
extra: json!({}),
};
let msg: InboundMessage = inbound.into();
assert_eq!(msg.id, None);
assert_eq!(msg.timestamp, None);
assert_eq!(msg.provider, "plivo");
}
#[test]
fn inbound_conversion_bad_timestamp_still_works() {
let inbound = PlivoInbound {
from: "+1".into(),
to: "+2".into(),
text: "hi".into(),
r#type: None,
message_uuid: None,
time: Some("not-a-valid-time".into()),
extra: json!({}),
};
let msg: InboundMessage = inbound.into();
assert_eq!(msg.timestamp, None); }
#[test]
fn parse_inbound_form_encoded() {
let client = PlivoClient::new("id", "token");
let body = b"From=%2B15550001111&To=%2B15550002222&Text=Hello+World&MessageUUID=uuid-1";
let msg = client.parse_inbound(&vec![], body).unwrap();
assert_eq!(msg.from, "+15550001111");
assert_eq!(msg.to, "+15550002222");
assert_eq!(msg.text, "Hello World");
assert_eq!(msg.id, Some("uuid-1".into()));
}
#[test]
fn parse_inbound_invalid_body() {
let client = PlivoClient::new("id", "token");
let body = b"garbage data that is not form-encoded properly";
let result = client.parse_inbound(&vec![], body);
assert!(result.is_err());
}
#[test]
fn parse_inbound_minimal_fields() {
let client = PlivoClient::new("id", "token");
let body = b"From=%2B1&To=%2B2&Text=hi";
let msg = client.parse_inbound(&vec![], body).unwrap();
assert_eq!(msg.from, "+1");
assert_eq!(msg.to, "+2");
assert_eq!(msg.text, "hi");
}
#[test]
fn provider_returns_plivo() {
let client = PlivoClient::new("id", "token");
assert_eq!(InboundWebhook::provider(&client), "plivo");
}
#[test]
fn plivo_inbound_serde_roundtrip() {
let inbound = PlivoInbound {
from: "+1".into(),
to: "+2".into(),
text: "msg".into(),
r#type: Some("sms".into()),
message_uuid: Some("uuid".into()),
time: Some("2024-01-01T00:00:00Z".into()),
extra: json!({}),
};
let json_str = serde_json::to_string(&inbound).unwrap();
let deser: PlivoInbound = serde_json::from_str(&json_str).unwrap();
assert_eq!(deser.from, "+1");
assert_eq!(deser.message_uuid, Some("uuid".into()));
}
#[test]
fn owned_request_works_with_plivo_payload() {
let owned = sms_core::OwnedSendRequest::new("+14155551234", "+10005551234", "Hello!");
let borrowed = owned.as_ref();
let payload = PlivoSendRequest {
src: borrowed.from,
dst: borrowed.to,
text: borrowed.text,
};
let j = serde_json::to_value(&payload).unwrap();
assert_eq!(j["dst"], "+14155551234");
}
}