1use chrono::{DateTime, Utc};
2use reqwest::Client;
3use std::collections::HashMap;
4use thiserror::Error;
5
6#[derive(Error, Debug)]
8pub enum SmsError {
9 #[error("SMS sending failed to: {number}, {message}")]
10 SendFailed { number: String, message: String },
11 #[error("HTTP request failed: {0}")]
12 RequestFailed(#[from] reqwest::Error),
13}
14
15bitflags::bitflags! {
16 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
18 pub struct SmsFlags: u32 {
19 const DEBUG = 0b0000_0001;
21 const NOLOG = 0b0000_0010;
23 const FLASH = 0b0000_0100;
25 const TEST = 0b0000_1000;
27 const NOBL = 0b0001_0000;
29 const CONVERT = 0b0010_0000;
31 }
32}
33
34#[derive(Debug, Clone, Copy)]
36pub enum Encoding {
37 Default,
39 EightBit,
41 Udh,
43}
44
45pub struct SmsClient {
47 api_base_url: String,
48 client: Client,
49}
50
51pub struct SmsRequest<'a> {
53 pub api_key: &'a str,
54 pub sender: &'a str,
55 pub number: &'a str,
56 pub text: &'a str,
57 pub time: Option<DateTime<Utc>>,
58 pub dlr_url: Option<&'a str>,
59 pub expired: Option<i32>,
60 pub flags: SmsFlags,
61 pub user_key: Option<&'a str>,
62 pub encoding: Encoding,
63}
64
65impl<'a> SmsRequest<'a> {
66 #[must_use]
68 pub fn new(api_key: &'a str, sender: &'a str, number: &'a str, text: &'a str) -> Self {
69 Self {
70 api_key,
71 sender,
72 number,
73 text,
74 time: None,
75 dlr_url: None,
76 expired: None,
77 flags: SmsFlags::empty(),
78 user_key: None,
79 encoding: Encoding::EightBit,
80 }
81 }
82
83 #[must_use]
85 pub fn with_time(mut self, time: DateTime<Utc>) -> Self {
86 self.time = Some(time);
87 self
88 }
89
90 #[must_use]
92 pub fn with_dlr_url(mut self, dlr_url: &'a str) -> Self {
93 self.dlr_url = Some(dlr_url);
94 self
95 }
96
97 #[must_use]
99 pub fn with_expired(mut self, expired: i32) -> Self {
100 self.expired = Some(expired);
101 self
102 }
103
104 #[must_use]
106 pub fn with_flags(mut self, flags: SmsFlags) -> Self {
107 self.flags = flags;
108 self
109 }
110
111 #[must_use]
113 pub fn with_user_key(mut self, user_key: &'a str) -> Self {
114 self.user_key = Some(user_key);
115 self
116 }
117
118 #[must_use]
120 pub fn with_encoding(mut self, encoding: Encoding) -> Self {
121 self.encoding = encoding;
122 self
123 }
124}
125
126impl SmsClient {
127 #[must_use]
129 pub fn new(api_base_url: String) -> Self {
130 Self {
131 api_base_url,
132 client: Client::new(),
133 }
134 }
135
136 pub async fn send_sms(&self, request: SmsRequest<'_>) -> Result<i32, SmsError> {
145 let mut params: HashMap<&str, String> = HashMap::new();
146
147 params.insert("api-key", request.api_key.to_string());
148 params.insert("sender", request.sender.to_string());
149 params.insert("number", request.number.trim_start_matches('+').to_string());
150 params.insert("text", request.text.to_string());
151
152 if let Some(time) = request.time {
153 params.insert("time", time.format("%Y-%m-%dT%H:%M:%S").to_string());
154 }
155
156 if let Some(dlr_url) = request.dlr_url {
157 params.insert("dlr-url", dlr_url.to_string());
158 }
159
160 if let Some(expired) = request.expired {
161 params.insert("expired", expired.to_string());
162 }
163
164 if request.flags.contains(SmsFlags::DEBUG) {
165 params.insert("flag-debug", "1".to_string());
166 }
167
168 if request.flags.contains(SmsFlags::NOLOG) {
169 params.insert("flag-nolog", "3".to_string());
170 }
171
172 if request.flags.contains(SmsFlags::FLASH) {
173 params.insert("flag-flash", "1".to_string());
174 }
175
176 if request.flags.contains(SmsFlags::TEST) {
177 params.insert("flag-test", "1".to_string());
178 }
179
180 if request.flags.contains(SmsFlags::NOBL) {
181 params.insert("flag-nobl", "1".to_string());
182 }
183
184 if request.flags.contains(SmsFlags::CONVERT) {
185 params.insert("flag-convert", "1".to_string());
186 }
187
188 if let Some(user_key) = request.user_key {
189 params.insert("user-key", user_key.to_string());
190 }
191
192 match request.encoding {
193 Encoding::Udh => {
194 params.insert("udh", "1".to_string());
195 params.insert("coding", "1".to_string());
196 }
197 Encoding::EightBit => {
198 params.insert("coding", "1".to_string());
199 }
200 Encoding::Default => {}
201 }
202
203 let url = format!("{}/send", self.api_base_url);
204 let response = self.client.get(&url).query(¶ms).send().await?;
205
206 let resp_text = response.text().await?;
207
208 let result = resp_text.trim().parse::<i32>().ok();
209
210 if let Some(code) = result {
211 if code > 100 {
212 return Ok(code);
213 }
214
215 let error_msg = get_response_code_message(code);
216 log::error!("SMS sending failed to: {}, {}", request.number, error_msg);
217
218 return Err(SmsError::SendFailed {
219 number: request.number.to_string(),
220 message: error_msg.to_string(),
221 });
222 }
223
224 log::error!("SMS sending failed to: {}, unknown error", request.number);
225 Err(SmsError::SendFailed {
226 number: request.number.to_string(),
227 message: "unknown error".to_string(),
228 })
229 }
230}
231
232fn get_response_code_message(code: i32) -> &'static str {
233 match code {
234 1 => "system internal error",
235 2 => "missing PARAM_NAME parameter",
236 3 => "unable to authenticate",
237 4 => "IP ADDRESS is not allowed",
238 5 => "invalid SENDER parameter",
239 6 => "SENDER is not allowed",
240 7 => "invalid NUMBER parameter",
241 8 => "invalid CODING parameter",
242 9 => "unable to convert TEXT",
243 10 => "length of UDH and TEXT too long",
244 11 => "empty TEXT parameter",
245 12 => "invalid TIME parameter",
246 13 => "invalid EXPIRED parameter",
247 14 => "invalid DLR-URL parameter",
248 15 => "Invalid FLAG-FLASH parameter",
249 16 => "invalid FLAG-NOLOG parameter",
250 17 => "invalid FLAG-TEST parameter",
251 18 => "invalid FLAG-NOBL parameter",
252 19 => "invalid FLAG-CONVERT parameter",
253 _ => "unknown error",
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use chrono::TimeZone;
261 use httpmock::prelude::*;
262
263 fn base_request<'a>() -> SmsRequest<'a> {
264 SmsRequest::new("k", "Alice", "+1234567890", "Hello")
265 }
266
267 #[tokio::test]
268 async fn send_sms_success_and_params() {
269 let server = MockServer::start();
270
271 let mut flags = SmsFlags::empty();
273 flags |= SmsFlags::DEBUG
274 | SmsFlags::NOLOG
275 | SmsFlags::FLASH
276 | SmsFlags::TEST
277 | SmsFlags::NOBL
278 | SmsFlags::CONVERT;
279
280 let time = Utc.with_ymd_and_hms(2025, 1, 2, 3, 4, 5).unwrap();
281
282 let req = base_request()
283 .with_time(time)
284 .with_dlr_url("https://example.com/dlr")
285 .with_expired(60)
286 .with_flags(flags)
287 .with_user_key("ukey")
288 .with_encoding(Encoding::Udh);
289
290 let m = server.mock(|when, then| {
292 when.method(GET)
293 .path("/send")
294 .query_param("api-key", "k")
295 .query_param("sender", "Alice")
296 .query_param("number", "1234567890") .query_param("text", "Hello")
298 .query_param("time", "2025-01-02T03:04:05")
299 .query_param("dlr-url", "https://example.com/dlr")
300 .query_param("expired", "60")
301 .query_param("flag-debug", "1")
302 .query_param("flag-nolog", "3")
303 .query_param("flag-flash", "1")
304 .query_param("flag-test", "1")
305 .query_param("flag-nobl", "1")
306 .query_param("flag-convert", "1")
307 .query_param("user-key", "ukey")
308 .query_param("udh", "1")
309 .query_param("coding", "1");
310 then.status(200).body("1234");
311 });
312
313 let client = SmsClient::new(server.base_url());
314 let code = client.send_sms(req).await.unwrap();
315 assert_eq!(code, 1234);
316 m.assert();
317 }
318
319 #[tokio::test]
320 async fn send_sms_api_error_mapped() {
321 let server = MockServer::start();
322 let m = server.mock(|when, then| {
323 when.method(GET).path("/send");
324 then.status(200).body("3"); });
326
327 let client = SmsClient::new(server.base_url());
328 let err = client.send_sms(base_request()).await.unwrap_err();
329 match err {
330 SmsError::SendFailed { number, message } => {
331 assert_eq!(number, "+1234567890");
332 assert_eq!(message, "unable to authenticate");
333 }
334 SmsError::RequestFailed(err) => panic!("Unexpected error type: {err}"),
335 }
336 m.assert();
337 }
338
339 #[tokio::test]
340 async fn send_sms_unknown_text_maps_to_unknown_error() {
341 let server = MockServer::start();
342 let m = server.mock(|when, then| {
343 when.method(GET).path("/send");
344 then.status(200).body("not-a-number");
345 });
346
347 let client = SmsClient::new(server.base_url());
348 let err = client.send_sms(base_request()).await.unwrap_err();
349 match err {
350 SmsError::SendFailed { number, message } => {
351 assert_eq!(number, "+1234567890");
352 assert_eq!(message, "unknown error");
353 }
354 SmsError::RequestFailed(err) => panic!("Unexpected error type: {err}"),
355 }
356 m.assert();
357 }
358
359 #[tokio::test]
360 async fn send_sms_http_failure_is_request_failed() {
361 let client = SmsClient::new("http://10.255.255.1".to_string());
363 let err = client.send_sms(base_request()).await.unwrap_err();
364 matches!(err, SmsError::RequestFailed(_));
365 }
366
367 #[test]
368 fn builder_sets_fields_and_defaults() {
369 let req = SmsRequest::new("key", "S", "N", "T");
370 assert!(req.time.is_none());
371 assert!(req.dlr_url.is_none());
372 assert!(req.expired.is_none());
373 assert!(req.user_key.is_none());
374 assert_eq!(req.flags, SmsFlags::empty());
375 matches!(req.encoding, Encoding::EightBit);
376 }
377
378 #[test]
379 fn get_response_code_message_works() {
380 assert_eq!(get_response_code_message(1), "system internal error");
381 assert_eq!(
382 get_response_code_message(19),
383 "invalid FLAG-CONVERT parameter"
384 );
385 assert_eq!(get_response_code_message(999), "unknown error");
386 }
387}