aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorFranck Cuny <franck@fcuny.net>2022-11-19 18:48:12 -0800
committerFranck Cuny <franck@fcuny.net>2022-11-19 19:01:01 -0800
commitd78070d130c32227a2788591d3efd321da702658 (patch)
tree55e34a15a8ff3b53714c55974bd738e48a5bbcb9 /src
parentmeta: ignore artifact created by `nix build` (diff)
downloadsendsms-d78070d130c32227a2788591d3efd321da702658.tar.gz
feat: a CLI to send sms via twilio's API
I want to get an SMS when some of my machines are rebooted. This tool let me do just that. The CLI takes as argument the path to a configuration file that contains credentials to the API. It also has a number of sub commands, and each sub command is associated with a specific action. For each action, a section in the configuration file is expected. The two actions currently supported are: - `reboot` - `hello` `hello` is a simple one used for testing, it sends "hello world". `reboot` is used to create a message that contains the host's name and the IP address of the network interface specified in the configuration file.
Diffstat (limited to 'src')
-rw-r--r--src/client.rs178
-rw-r--r--src/config.rs27
-rwxr-xr-x[-rw-r--r--]src/main.rs51
-rwxr-xr-xsrc/message.rs118
4 files changed, 373 insertions, 1 deletions
diff --git a/src/client.rs b/src/client.rs
new file mode 100644
index 0000000..2e38ba9
--- /dev/null
+++ b/src/client.rs
@@ -0,0 +1,178 @@
+use serde::Deserialize;
+use std::collections::HashMap;
+use std::fmt::{self, Display, Formatter};
+
+#[derive(Debug, Clone)]
+pub struct TwilioClient<'a> {
+ // the HTTP client
+ pub client: reqwest::blocking::Client,
+ // the base URL
+ pub base_url: reqwest::Url,
+ // account SID
+ pub account_sid: &'a String,
+ // authentication token
+ pub auth_token: &'a String,
+}
+
+const TWILIO_API_PATH: &str = "/2010-04-01/Accounts";
+
+// list of possible values: https://www.twilio.com/docs/sms/api/message-resource#message-status-values
+#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
+#[allow(non_camel_case_types)]
+pub enum MessageStatus {
+ accepted,
+ scheduled,
+ queued,
+ sending,
+ sent,
+ receiving,
+ received,
+ delivered,
+ undelivered,
+ failed,
+ read,
+ canceled,
+}
+
+impl fmt::Display for MessageStatus {
+ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+ write!(f, "{:?}", self)
+ }
+}
+
+// response from twilio's API
+#[derive(Deserialize, Debug, Clone)]
+pub struct Message {
+ pub status: MessageStatus,
+}
+
+// error from twilio's API
+#[derive(Deserialize, Debug, Clone)]
+pub struct Error {
+ pub status: u16,
+ pub code: Option<u16>,
+ pub message: String,
+ pub more_info: Option<String>,
+}
+
+impl Display for Error {
+ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+ write!(f, "({}) {}", self.status, self.message)?;
+ Ok(())
+ }
+}
+
+impl<'a> TwilioClient<'a> {
+ pub fn new(account_sid: &'a String, auth_token: &'a String, base_url: reqwest::Url) -> Self {
+ let client = reqwest::blocking::Client::builder()
+ .user_agent("fcuny/send-sms")
+ .build()
+ .unwrap();
+
+ Self {
+ client,
+ base_url,
+ account_sid,
+ auth_token,
+ }
+ }
+
+ // send an SMS through twilio
+ pub fn send(&self, message: crate::message::Message) -> Result<Message, Error> {
+ let path = format!("{}/{}/Messages.json", TWILIO_API_PATH, self.account_sid);
+
+ let mut url = self.base_url.clone();
+ url.set_path(&path);
+
+ let mut form = HashMap::new();
+ form.insert("From", message.from);
+ form.insert("To", message.to);
+ form.insert("Body", message.body);
+
+ let response = self
+ .client
+ .post(url.as_str())
+ .basic_auth(self.account_sid, Some(self.auth_token))
+ .form(&form)
+ .send()
+ .unwrap();
+
+ match response.status() {
+ reqwest::StatusCode::CREATED | reqwest::StatusCode::OK => {}
+ _ => {
+ let error_response: Error = response.json().unwrap();
+ return Err(error_response);
+ }
+ };
+
+ Ok(response.json().unwrap())
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use crate::client::TwilioClient;
+ use crate::message::Message;
+ use httpmock::prelude::*;
+ use serde_json::json;
+
+ fn get_message() -> Message {
+ Message {
+ to: Some("1234".to_string()),
+ from: Some("1234".to_string()),
+ body: Some("test".to_string()),
+ }
+ }
+
+ #[test]
+ fn test_send_message() {
+ let server = MockServer::start();
+ let mock = server.mock(|when, then| {
+ when.method(POST)
+ .path("/2010-04-01/Accounts/test/Messages.json");
+ then.status(200).json_body(json!({"status": "queued"}));
+ });
+
+ let message = get_message();
+
+ let account_sid = "test".to_string();
+ let auth_token = "test".to_string();
+ let client = TwilioClient::new(
+ &account_sid,
+ &auth_token,
+ reqwest::Url::parse(&server.base_url()).unwrap(),
+ );
+
+ let result = client.send(message);
+ mock.assert();
+ let msg: crate::client::Message = result.unwrap();
+ assert_eq!(msg.status, crate::client::MessageStatus::queued);
+ }
+
+ #[test]
+ fn test_send_message_error() {
+ let server = MockServer::start();
+ let mock_invalid = server.mock(|when, then| {
+ when.method(POST)
+ .path("/2010-04-01/Accounts/test/Messages.json");
+ then.status(400).json_body(json!({
+ "status": 400,
+ "message": "The 'From' number +15005550001 is not a valid phone number, shortcode, or alphanumeric sender ID",
+ }));
+ });
+
+ let message = get_message();
+
+ let account_sid = "test".to_string();
+ let auth_token = "test".to_string();
+ let client = TwilioClient::new(
+ &account_sid,
+ &auth_token,
+ reqwest::Url::parse(&server.base_url()).unwrap(),
+ );
+
+ let result = client.send(message);
+ mock_invalid.assert();
+ assert!(matches!(result, Err(crate::client::Error { .. })));
+ }
+}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..29145b8
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,27 @@
+use serde::Deserialize;
+use std::path::PathBuf;
+
+#[derive(Deserialize, Debug)]
+pub struct Config {
+ pub to: String,
+ pub from: String,
+ pub account_sid: String,
+ pub auth_token: String,
+ pub reboot: RebootConfig,
+ pub hello: HelloConfig,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct RebootConfig {
+ pub ifname: String,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct HelloConfig {}
+
+impl Config {
+ pub fn load_from_file(filename: &PathBuf) -> std::io::Result<Config> {
+ let content = std::fs::read_to_string(filename)?;
+ Ok(toml::from_str(&content)?)
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index e7a11a9..f6e4a37 100644..100755
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,52 @@
+mod client;
+mod config;
+mod message;
+
+use clap::{crate_version, Parser};
+use std::path::PathBuf;
+use std::process::exit;
+
+#[derive(Parser, Debug)]
+#[clap(author, version, about, long_about = None)]
+#[clap(version = crate_version!())]
+struct Args {
+ #[clap(short, long, value_parser)]
+ config: PathBuf,
+
+ #[clap(subcommand)]
+ subcmd: message::MessageKind,
+}
+
+const TWILIO_BASE_URL: &str = "https://api.twilio.com";
+
fn main() {
- println!("Hello, world!");
+ let args = Args::parse();
+
+ let config: config::Config = match config::Config::load_from_file(&args.config) {
+ Ok(config) => config,
+ Err(e) => {
+ eprintln!("unable to load data from {}: {}", args.config.display(), e);
+ exit(1);
+ }
+ };
+
+ let msg = message::Message::builder()
+ .from(config.from.to_owned())
+ .to(config.to.to_owned())
+ .with_action(args.subcmd, &config)
+ .build();
+
+ let client = client::TwilioClient::new(
+ &config.account_sid,
+ &config.auth_token,
+ reqwest::Url::parse(TWILIO_BASE_URL).unwrap(),
+ );
+
+ match client.send(msg) {
+ Ok(m) => println!("message sent successfully: status is {:?}", m.status),
+ Err(error) => {
+ eprintln!("failed to send the message: {}", error);
+ exit(1);
+ }
+ }
}
diff --git a/src/message.rs b/src/message.rs
new file mode 100755
index 0000000..5fbd5fa
--- /dev/null
+++ b/src/message.rs
@@ -0,0 +1,118 @@
+use clap::Parser;
+use gethostname::gethostname;
+use serde::Deserialize;
+use std::net::IpAddr;
+
+#[derive(Parser, Debug)]
+pub enum MessageKind {
+ Hello,
+ Reboot,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct MessageBuilder {
+ pub from: Option<String>,
+ pub to: Option<String>,
+ pub body: Option<String>,
+}
+
+impl MessageBuilder {
+ fn new() -> Self {
+ Self {
+ from: None,
+ to: None,
+ body: None,
+ }
+ }
+
+ pub fn from(mut self, from: impl Into<String>) -> Self {
+ self.from = Some(from.into());
+ self
+ }
+
+ pub fn to(mut self, to: impl Into<String>) -> Self {
+ self.to = Some(to.into());
+ self
+ }
+
+ pub fn with_action(mut self, action: MessageKind, config: &crate::config::Config) -> Self {
+ let body = match action {
+ MessageKind::Reboot => reboot(&config.reboot),
+ MessageKind::Hello => hello(),
+ };
+ self.body = Some(body);
+ self
+ }
+
+ pub fn build(self) -> Message {
+ let Self { from, to, body } = self;
+ Message { from, to, body }
+ }
+}
+
+#[derive(Deserialize, Debug)]
+pub struct Message {
+ pub from: Option<String>,
+ pub to: Option<String>,
+ pub body: Option<String>,
+}
+
+impl Message {
+ pub fn builder() -> MessageBuilder {
+ MessageBuilder::new()
+ }
+}
+
+fn hello() -> String {
+ String::from("hello world")
+}
+
+fn reboot(config: &crate::config::RebootConfig) -> String {
+ let ipaddr_v4 = if_addrs::get_if_addrs()
+ .unwrap_or_default()
+ .into_iter()
+ .find(|iface| iface.name == config.ifname)
+ .and_then(|iface| match iface.ip() {
+ IpAddr::V4(addr) => Some(addr),
+ IpAddr::V6(_) => None,
+ })
+ .expect("there should be an ipv4 address");
+
+ let hostname = gethostname()
+ .into_string()
+ .expect("failed to get the hostname");
+
+ format!(
+ "{} has rebooted. The IP address for the interface {} is {}.",
+ hostname, config.ifname, ipaddr_v4
+ )
+}
+
+#[cfg(test)]
+mod test {
+ use crate::message::Message;
+
+ #[test]
+ fn test_create_message() {
+ let from = "1".to_string();
+ let to = "2".to_string();
+ let account_sid = "test".to_string();
+ let auth_token = "test".to_string();
+ let cfg = crate::config::Config {
+ to,
+ from,
+ account_sid,
+ auth_token,
+ reboot: crate::config::RebootConfig {
+ ifname: "eth0".to_string(),
+ },
+ hello: crate::config::HelloConfig {},
+ };
+ let msg = Message::builder()
+ .from("1")
+ .to("2")
+ .with_action(crate::message::MessageKind::Hello, &cfg)
+ .build();
+ assert_eq!(msg.body, Some("hello world".to_string()));
+ }
+}