This commit is contained in:
Ember Moth
2026-07-05 20:27:58 +08:00
commit 2744c70c5c
837 changed files with 53059 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "sms"
version = "0.1.0"
edition = "2021"
[dependencies]
async-trait = "0.1"
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
hmac = "0.12"
sha1 = "0.10"
sha2 = "0.10"
md5 = "0.7"
chrono = "0.4"
+38
View File
@@ -0,0 +1,38 @@
use serde::{Deserialize, Serialize};
/// Unified config struct covering all 4 providers.
/// Fields not used by a given provider are simply ignored.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SmsConfig {
// ── AlibabaCloud ────────────────────────────────────────────────
/// AccessKeyId
#[serde(default)]
pub access: String,
/// AccessKeySecret / MD5Key / AuthToken / Password
#[serde(default)]
pub secret: String,
/// AlibabaCloud: SignName
#[serde(default)]
pub sign_name: String,
/// AlibabaCloud: Endpoint (default: dysmsapi.ap-southeast-1.aliyuncs.com)
#[serde(default)]
pub endpoint: String,
/// AlibabaCloud: TemplateCode
#[serde(default)]
pub template_code: String,
// ── Smsbao / Abosend / Twilio ────────────────────────────────────
/// Go template string, e.g. "Your code is {{.code}}"
#[serde(default)]
pub template: String,
// ── Abosend ─────────────────────────────────────────────────────
/// Override API base URL (default: https://smsapi.abosend.com)
#[serde(default)]
pub api_domain: String,
// ── Twilio ───────────────────────────────────────────────────────
/// Sending phone number (e.g. "+12015551234")
#[serde(default)]
pub phone_number: String,
}
+13
View File
@@ -0,0 +1,13 @@
use crate::config::SmsConfig;
use crate::platform::Platform;
use crate::providers::{abosend::AbosendSender, alibabacloud::AlibabaCloudSender, smsbao::SmsbaoSender, twilio::TwilioSender};
use crate::sender::Sender;
pub fn create_sender(platform: Platform, config: SmsConfig) -> Box<dyn Sender> {
match platform {
Platform::AlibabaCloud => Box::new(AlibabaCloudSender::new(config)),
Platform::Smsbao => Box::new(SmsbaoSender::new(config)),
Platform::Abosend => Box::new(AbosendSender::new(config)),
Platform::Twilio => Box::new(TwilioSender::new(config)),
}
}
+10
View File
@@ -0,0 +1,10 @@
pub mod config;
pub mod factory;
pub mod platform;
pub mod providers;
pub mod sender;
pub use config::SmsConfig;
pub use factory::create_sender;
pub use platform::Platform;
pub use sender::Sender;
+33
View File
@@ -0,0 +1,33 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Platform {
AlibabaCloud,
Smsbao,
Abosend,
Twilio,
}
impl Platform {
pub fn from_str(s: &str) -> Option<Self> {
match s {
"AlibabaCloud" => Some(Platform::AlibabaCloud),
"smsbao" => Some(Platform::Smsbao),
"abosend" => Some(Platform::Abosend),
"twilio" => Some(Platform::Twilio),
_ => None,
}
}
}
impl std::fmt::Display for Platform {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Platform::AlibabaCloud => "AlibabaCloud",
Platform::Smsbao => "smsbao",
Platform::Abosend => "abosend",
Platform::Twilio => "twilio",
};
write!(f, "{}", s)
}
}
+108
View File
@@ -0,0 +1,108 @@
use anyhow::Context;
use reqwest::Client;
use serde::Deserialize;
use crate::config::SmsConfig;
use crate::sender::Sender;
const BASE_URL: &str = "https://smsapi.abosend.com";
pub struct AbosendSender {
config: SmsConfig,
client: Client,
base_url: String,
}
impl AbosendSender {
pub fn new(config: SmsConfig) -> Self {
let base_url = if config.api_domain.is_empty() {
BASE_URL.to_string()
} else {
config.api_domain.clone()
};
Self {
config,
client: Client::new(),
base_url,
}
}
}
#[derive(Debug, Deserialize)]
struct AbosendResponse {
code: i32,
message: String,
}
/// Render a Go-style template string: replace `{{.code}}` with `code`.
fn render_template(template: &str, code: &str) -> String {
template.replace("{{.code}}", code)
}
/// Compute MD5 hex string (lowercase).
fn md5_hex(input: &str) -> String {
let digest = md5::compute(input.as_bytes());
format!("{:x}", digest)
}
#[async_trait::async_trait]
impl Sender for AbosendSender {
async fn send(&self, area: &str, phone: &str, code: &str, _expire: u32) -> anyhow::Result<()> {
let content = render_template(&self.config.template, code);
// rand is a 6-digit numeric string; we derive it from current time nanos
let rand_num = format!(
"{:06}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos()
% 1_000_000
);
// sign = md5(orgCode + content + rand + md5key), uppercase
let sign_input = format!(
"{}{}{}{}",
self.config.access, content, rand_num, self.config.secret
);
let sign = md5_hex(&sign_input).to_uppercase();
let body = serde_json::json!({
"orgCode": self.config.access,
"mobileArea": format!("+{}", area),
"mobiles": format!("{}{}", area, phone),
"content": content,
"rand": rand_num,
"sign": sign,
});
let url = format!("{}/v2/api/sendSMS", self.base_url);
let resp = self
.client
.post(&url)
.json(&body)
.send()
.await
.context("Abosend: HTTP request failed")?;
let status = resp.status();
if !status.is_success() {
anyhow::bail!("Abosend: HTTP {}", status);
}
let result: AbosendResponse = resp
.json()
.await
.context("Abosend: failed to parse response")?;
if result.code != 200 {
anyhow::bail!(
"Abosend: send failed, code={}, message={}",
result.code,
result.message
);
}
Ok(())
}
}
+179
View File
@@ -0,0 +1,179 @@
use anyhow::Context;
use reqwest::Client;
use serde::Deserialize;
use serde_json::json;
use crate::config::SmsConfig;
use crate::sender::Sender;
const DEFAULT_ENDPOINT: &str = "dysmsapi.ap-southeast-1.aliyuncs.com";
pub struct AlibabaCloudSender {
config: SmsConfig,
client: Client,
}
impl AlibabaCloudSender {
pub fn new(config: SmsConfig) -> Self {
Self {
config,
client: Client::new(),
}
}
}
/// Minimal response body from Dysmsapi SendSms
#[derive(Debug, Deserialize)]
struct SendSmsResponse {
#[serde(rename = "Code")]
code: String,
#[serde(rename = "Message")]
message: String,
}
/// Build the canonical query string and HMAC-SHA1 signature required by
/// Alibaba Cloud's Dysmsapi (RPC-style, API version 2017-05-25).
///
/// Reference:
/// https://help.aliyun.com/document_detail/101341.html
fn sign_request(
access_key_id: &str,
access_key_secret: &str,
params: &mut Vec<(String, String)>,
) -> anyhow::Result<String> {
use base64::{engine::general_purpose::STANDARD, Engine};
use hmac::{Hmac, Mac};
use sha1::Sha1;
#[allow(unused_imports)]
use sha2::Sha256;
// Common system parameters
let nonce = uuid_v4_simple();
let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
params.push(("AccessKeyId".to_string(), access_key_id.to_string()));
params.push(("Format".to_string(), "JSON".to_string()));
params.push(("SignatureMethod".to_string(), "HMAC-SHA1".to_string()));
params.push(("SignatureNonce".to_string(), nonce));
params.push(("SignatureVersion".to_string(), "1.0".to_string()));
params.push(("Timestamp".to_string(), timestamp));
params.push(("Version".to_string(), "2017-05-25".to_string()));
// Sort by key
params.sort_by(|a, b| a.0.cmp(&b.0));
// Percent-encode each key=value pair then join with &
let canonical = params
.iter()
.map(|(k, v)| {
format!(
"{}={}",
percent_encode(k),
percent_encode(v)
)
})
.collect::<Vec<_>>()
.join("&");
let string_to_sign = format!(
"GET&{}&{}",
percent_encode("/"),
percent_encode(&canonical)
);
// Sign with HMAC-SHA1 using "<secret>&" as key
let signing_key = format!("{}&", access_key_secret);
let mut mac = Hmac::<Sha1>::new_from_slice(signing_key.as_bytes())
.context("HMAC-SHA1 init failed")?;
mac.update(string_to_sign.as_bytes());
let signature = STANDARD.encode(mac.finalize().into_bytes());
params.push(("Signature".to_string(), signature.clone()));
// Rebuild final query
let query = params
.iter()
.map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v)))
.collect::<Vec<_>>()
.join("&");
Ok(query)
}
fn percent_encode(s: &str) -> String {
let mut encoded = String::new();
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
| b'-' | b'_' | b'.' | b'~' => {
encoded.push(b as char);
}
_ => {
encoded.push_str(&format!("%{:02X}", b));
}
}
}
encoded
}
fn uuid_v4_simple() -> String {
// Generate a UUID-like random string without external dep
use std::time::{SystemTime, UNIX_EPOCH};
let t = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
format!("{:08x}-{:04x}-4{:03x}", t, t >> 16, t & 0xfff)
}
#[async_trait::async_trait]
impl Sender for AlibabaCloudSender {
async fn send(&self, area: &str, phone: &str, code: &str, _expire: u32) -> anyhow::Result<()> {
let endpoint = if self.config.endpoint.is_empty() {
DEFAULT_ENDPOINT
} else {
&self.config.endpoint
};
let template_param = json!({ "code": code }).to_string();
let phone_number = format!("{}{}", area, phone);
let mut params: Vec<(String, String)> = vec![
("Action".to_string(), "SendSms".to_string()),
("PhoneNumbers".to_string(), phone_number),
("SignName".to_string(), self.config.sign_name.clone()),
("TemplateCode".to_string(), self.config.template_code.clone()),
("TemplateParam".to_string(), template_param),
];
let query = sign_request(&self.config.access, &self.config.secret, &mut params)?;
let url = format!("https://{}/{}", endpoint, query);
let resp = self
.client
.get(&url)
.send()
.await
.context("AlibabaCloud: HTTP request failed")?;
let status = resp.status();
let body = resp.text().await.context("AlibabaCloud: failed to read body")?;
if !status.is_success() {
anyhow::bail!("AlibabaCloud: HTTP {} — {}", status, body);
}
let result: SendSmsResponse =
serde_json::from_str(&body).context("AlibabaCloud: failed to parse response")?;
if result.code != "OK" {
anyhow::bail!(
"AlibabaCloud: SendSms failed, code={}, message={}",
result.code,
result.message
);
}
Ok(())
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod alibabacloud;
pub mod abosend;
pub mod smsbao;
pub mod twilio;
+86
View File
@@ -0,0 +1,86 @@
use anyhow::Context;
use reqwest::Client;
use crate::config::SmsConfig;
use crate::sender::Sender;
const BASE_URL: &str = "https://api.smsbao.com";
pub struct SmsbaoSender {
config: SmsConfig,
client: Client,
}
impl SmsbaoSender {
pub fn new(config: SmsConfig) -> Self {
Self {
config,
client: Client::new(),
}
}
}
/// Render a Go-style template: replace `{{.code}}` with `code`.
fn render_template(template: &str, code: &str) -> String {
template.replace("{{.code}}", code)
}
/// MD5 hex (lowercase).
fn md5_hex(input: &str) -> String {
let digest = md5::compute(input.as_bytes());
format!("{:x}", digest)
}
/// Map SMSBao numeric response body to an error description.
fn parse_smsbao_error(body: &str) -> anyhow::Result<()> {
match body.trim() {
"0" => Ok(()),
"30" => anyhow::bail!("SMSBao: password error"),
"40" => anyhow::bail!("SMSBao: account not found"),
"41" => anyhow::bail!("SMSBao: insufficient balance"),
"43" => anyhow::bail!("SMSBao: IP address restrictions"),
"50" => anyhow::bail!("SMSBao: content contains sensitive words"),
"51" => anyhow::bail!("SMSBao: mobile number is incorrect"),
other => anyhow::bail!("SMSBao: unknown error code: {}", other),
}
}
#[async_trait::async_trait]
impl Sender for SmsbaoSender {
async fn send(&self, area: &str, phone: &str, code: &str, _expire: u32) -> anyhow::Result<()> {
let content = render_template(&self.config.template, code);
let password_md5 = md5_hex(&self.config.secret);
// Domestic (China, area == "86") → /sms, just mobile number
// International → /wsms, prepend +area to number
let (api_path, mobile) = if area == "86" {
("/sms", phone.to_string())
} else {
("/wsms", format!("+{}{}", area, phone))
};
let url = format!("{}{}", BASE_URL, api_path);
let resp = self
.client
.get(&url)
.query(&[
("u", self.config.access.as_str()),
("p", &password_md5),
("m", &mobile),
("c", &content),
])
.send()
.await
.context("SMSBao: HTTP request failed")?;
let status = resp.status();
let body = resp.text().await.context("SMSBao: failed to read body")?;
if !status.is_success() {
anyhow::bail!("SMSBao: HTTP {} — {}", status, body);
}
parse_smsbao_error(&body)?;
Ok(())
}
}
+82
View File
@@ -0,0 +1,82 @@
use anyhow::Context;
use base64::{engine::general_purpose::STANDARD, Engine};
use reqwest::Client;
use serde::Deserialize;
use crate::config::SmsConfig;
use crate::sender::Sender;
pub struct TwilioSender {
config: SmsConfig,
client: Client,
}
impl TwilioSender {
pub fn new(config: SmsConfig) -> Self {
Self {
config,
client: Client::new(),
}
}
}
/// Render Go-style template: replace `{{.code}}` with `code`.
fn render_template(template: &str, code: &str) -> String {
template.replace("{{.code}}", code)
}
/// Twilio Messages API response (partial — only error fields matter)
#[derive(Debug, Deserialize)]
struct TwilioMessageResponse {
error_code: Option<i32>,
error_message: Option<String>,
}
#[async_trait::async_trait]
impl Sender for TwilioSender {
async fn send(&self, area: &str, phone: &str, code: &str, _expire: u32) -> anyhow::Result<()> {
let to = format!("+{}{}", area, phone);
let body_text = render_template(&self.config.template, code);
// Twilio REST API: POST /2010-04-01/Accounts/{AccountSid}/Messages.json
// Auth: HTTP Basic (AccountSid : AuthToken)
let account_sid = &self.config.access;
let auth_token = &self.config.secret;
let url = format!(
"https://api.twilio.com/2010-04-01/Accounts/{}/Messages.json",
account_sid
);
let credentials = STANDARD.encode(format!("{}:{}", account_sid, auth_token));
let resp = self
.client
.post(&url)
.header("Authorization", format!("Basic {}", credentials))
.form(&[
("To", to.as_str()),
("From", self.config.phone_number.as_str()),
("Body", body_text.as_str()),
])
.send()
.await
.context("Twilio: HTTP request failed")?;
let status = resp.status();
let raw = resp.text().await.context("Twilio: failed to read body")?;
if !status.is_success() {
anyhow::bail!("Twilio: HTTP {} — {}", status, raw);
}
let result: TwilioMessageResponse =
serde_json::from_str(&raw).context("Twilio: failed to parse response")?;
if let Some(err_code) = result.error_code {
let msg = result.error_message.unwrap_or_default();
anyhow::bail!("Twilio: send failed, error_code={}, message={}", err_code, msg);
}
Ok(())
}
}
+4
View File
@@ -0,0 +1,4 @@
#[async_trait::async_trait]
pub trait Sender: Send + Sync {
async fn send(&self, area: &str, phone: &str, code: &str, expire: u32) -> anyhow::Result<()>;
}