diff options
Diffstat (limited to 'src/actions')
-rw-r--r-- | src/actions/command.rs | 61 | ||||
-rw-r--r-- | src/actions/desktop_notification.rs | 28 | ||||
-rw-r--r-- | src/actions/email.rs | 142 | ||||
-rw-r--r-- | src/actions/mod.rs | 120 | ||||
-rw-r--r-- | src/actions/nostr.rs | 192 | ||||
-rw-r--r-- | src/actions/ntfy.rs | 119 | ||||
-rw-r--r-- | src/actions/telegram.rs | 56 | ||||
-rw-r--r-- | src/actions/terminal_print.rs | 28 |
8 files changed, 746 insertions, 0 deletions
diff --git a/src/actions/command.rs b/src/actions/command.rs new file mode 100644 index 0000000..28d951a --- /dev/null +++ b/src/actions/command.rs @@ -0,0 +1,61 @@ +use std::collections::HashMap; +use std::process::Command; + +use super::Action; +use crate::message::MessageConfig; +use crate::message::MessageParams; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; + +#[derive(Deserialize, Debug)] +pub struct CommandConfig { + cmd: String, + #[serde(default)] + args: Vec<String>, + #[serde(default)] + clear_parent_env: bool, + #[serde(default)] + envs: HashMap<String, String>, + working_dir: Option<String>, +} + +pub struct CommandAction<'a> { + message_config: &'a MessageConfig, + cmd_config: &'a CommandConfig, +} + +impl<'a> CommandAction<'a> { + pub fn new(message_config: &'a MessageConfig, cmd_config: &'a CommandConfig) -> Result<Self> { + Ok(Self { + message_config, + cmd_config, + }) + } +} + +#[async_trait] +impl Action<'_> for CommandAction<'_> { + async fn run(&self, params: Option<&MessageParams<'_, '_>>) -> Result<()> { + let mut cmd = Command::new(&self.cmd_config.cmd); + for arg in self.cmd_config.args.iter() { + cmd.arg(if let Some(p) = params { + self.message_config.replace_template_params(arg, p)? + } else { + arg.clone() + }); + } + + if self.cmd_config.clear_parent_env { + cmd.env_clear(); + } + cmd.envs(&self.cmd_config.envs); + + if let Some(working_dir) = &self.cmd_config.working_dir { + cmd.current_dir(working_dir); + } + + cmd.status()?; + Ok(()) + } +} diff --git a/src/actions/desktop_notification.rs b/src/actions/desktop_notification.rs new file mode 100644 index 0000000..d831a59 --- /dev/null +++ b/src/actions/desktop_notification.rs @@ -0,0 +1,28 @@ +use super::Action; +use crate::message::MessageConfig; +use crate::message::MessageParams; +use anyhow::Result; +use async_trait::async_trait; + +#[derive(Debug)] +pub struct DesktopNotificationAction<'a> { + message_config: &'a MessageConfig, +} + +impl<'a> DesktopNotificationAction<'a> { + pub fn new(message_config: &'a MessageConfig) -> Self { + Self { message_config } + } +} + +#[async_trait] +impl Action<'_> for DesktopNotificationAction<'_> { + async fn run(&self, params: Option<&MessageParams<'_, '_>>) -> Result<()> { + use notify_rust::Notification; + Notification::new() + .summary(&self.message_config.subject(params)?) + .body(&self.message_config.body(params)?) + .show()?; + Ok(()) + } +} diff --git a/src/actions/email.rs b/src/actions/email.rs new file mode 100644 index 0000000..272f650 --- /dev/null +++ b/src/actions/email.rs @@ -0,0 +1,142 @@ +use super::Action; +use crate::message::MessageConfig; +use crate::message::MessageFormat; +use crate::message::MessageParams; +use anyhow::{Context, Result}; +use async_trait::async_trait; +use lettre::message::header::ContentType; +use lettre::message::MessageBuilder; +use lettre::message::MultiPart; +use lettre::message::SinglePart; +use lettre::transport::smtp::authentication::Credentials; +use lettre::transport::smtp::client::Tls; +use lettre::transport::smtp::client::TlsParametersBuilder; +use lettre::AsyncSmtpTransport; +use lettre::AsyncTransport; +use lettre::Message; +use lettre::Tokio1Executor; +use serde::Deserialize; + +#[derive(Deserialize, Debug, Copy, Clone)] +#[serde(rename_all = "lowercase")] +pub enum EmailConnectionType { + Plain, + StartTls, + Tls, +} + +#[derive(Deserialize, Debug)] +pub struct EmailConfig { + server: String, + port: Option<u16>, + credentials: Option<Credentials>, + connection: Option<EmailConnectionType>, + self_signed_cert: Option<bool>, + from: String, + to: Option<String>, +} + +impl EmailConfig { + pub fn server(&self) -> &str { + &self.server + } + + pub fn connection(&self) -> EmailConnectionType { + self.connection.unwrap_or(EmailConnectionType::Tls) + } + + pub fn self_signed_cert(&self) -> bool { + self.self_signed_cert.unwrap_or(false) + } + + pub fn port(&self) -> u16 { + self.port.unwrap_or(match self.connection() { + EmailConnectionType::Tls => 587, + EmailConnectionType::StartTls => 465, + EmailConnectionType::Plain => 25, + }) + } + + pub fn to(&self) -> &str { + self.to.as_deref().unwrap_or(self.from.as_ref()) + } +} + +pub struct EmailAction<'a> { + message_config: &'a MessageConfig, + mailer: AsyncSmtpTransport<Tokio1Executor>, + message_builder: MessageBuilder, +} +impl<'a> EmailAction<'a> { + pub fn new(message_config: &'a MessageConfig, email_config: &'a EmailConfig) -> Result<Self> { + let tls_builder = TlsParametersBuilder::new(email_config.server().into()) + .dangerous_accept_invalid_certs(email_config.self_signed_cert()); + let tls_parameters = tls_builder.build()?; + + let mut smtp_builder = + AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(email_config.server()) + .port(email_config.port()) + .tls(match email_config.connection() { + EmailConnectionType::Tls => Tls::Wrapper(tls_parameters), + EmailConnectionType::StartTls => Tls::Required(tls_parameters), + EmailConnectionType::Plain => Tls::None, + }); + if let Some(cred) = &email_config.credentials { + smtp_builder = smtp_builder.credentials(cred.clone()) + } + Ok(Self { + message_config, + mailer: smtp_builder.build(), + message_builder: Message::builder() + .from( + email_config + .from + .parse() + .with_context(|| format!("invalid from address '{}'", email_config.from))?, + ) + .to(email_config + .to() + .parse() + .with_context(|| format!("invalid to address '{}'", email_config.to()))?), + }) + } +} + +#[async_trait] +impl Action<'_> for EmailAction<'_> { + async fn run(&self, params: Option<&MessageParams<'_, '_>>) -> Result<()> { + let body = self.message_config.body(params)?; + let html_body = match self.message_config.format() { + MessageFormat::Markdown => format!( + "<!DOCTYPE html><html><body>{}</body></html>", + markdown::to_html(&body) + ), + MessageFormat::Html => body.clone(), + MessageFormat::Plain => Default::default(), + }; + let email_builder = self + .message_builder + .clone() + .subject(self.message_config.subject(params)?); + let email = match self.message_config.format() { + MessageFormat::Plain => email_builder + .header(ContentType::TEXT_PLAIN) + .body(body.clone())?, + MessageFormat::Markdown | MessageFormat::Html => email_builder.multipart( + MultiPart::alternative() + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_PLAIN) + .body(body.clone()), + ) + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_HTML) + .body(html_body.clone()), + ), + )?, + }; + self.mailer.send(email).await?; + Ok(()) + } +} diff --git a/src/actions/mod.rs b/src/actions/mod.rs new file mode 100644 index 0000000..14ab279 --- /dev/null +++ b/src/actions/mod.rs @@ -0,0 +1,120 @@ +use std::fmt; + +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; + +use crate::message::MessageConfig; +use crate::message::MessageParams; + +mod command; +#[cfg(feature = "desktop")] +mod desktop_notification; +#[cfg(feature = "email")] +mod email; +#[cfg(feature = "nostr")] +mod nostr; +#[cfg(feature = "ntfy")] +mod ntfy; +#[cfg(feature = "telegram")] +mod telegram; +mod terminal_print; + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "snake_case")] +#[serde(tag = "type")] +pub enum AnyActionConfig { + TerminalPrint, + Command(self::command::CommandConfig), + #[cfg(feature = "desktop")] + DesktopNotification, + #[cfg(feature = "ntfy")] + Ntfy(self::ntfy::NtfyConfig), + #[cfg(feature = "email")] + Email(self::email::EmailConfig), + #[cfg(feature = "telegram")] + Telegram(self::telegram::TelegramConfig), + #[cfg(feature = "nostr")] + Nostr(self::nostr::NostrConfig), +} + +impl fmt::Display for AnyActionConfig { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + AnyActionConfig::TerminalPrint => write!(f, "terminal_print"), + AnyActionConfig::Command(_) => write!(f, "command"), + #[cfg(feature = "desktop")] + AnyActionConfig::DesktopNotification => write!(f, "desktop_notification"), + #[cfg(feature = "ntfy")] + AnyActionConfig::Ntfy(_) => write!(f, "ntfy"), + #[cfg(feature = "email")] + AnyActionConfig::Email(_) => write!(f, "email"), + #[cfg(feature = "telegram")] + AnyActionConfig::Telegram(_) => write!(f, "telegram"), + #[cfg(feature = "nostr")] + AnyActionConfig::Nostr(_) => write!(f, "nostr"), + } + } +} + +#[async_trait] +pub trait Action<'a> { + async fn run(&self, params: Option<&MessageParams<'_, '_>>) -> Result<()>; +} + +pub async fn get_action<'a>( + message_config: &'a MessageConfig, + action_config: &'a AnyActionConfig, +) -> Result<Box<dyn Action<'a> + 'a + Sync>> { + Ok(match action_config { + AnyActionConfig::TerminalPrint => Box::new(self::terminal_print::TerminalPrintAction::new( + message_config, + )), + AnyActionConfig::Command(config) => { + Box::new(self::command::CommandAction::new(message_config, config)?) + } + #[cfg(feature = "desktop")] + AnyActionConfig::DesktopNotification => Box::new( + self::desktop_notification::DesktopNotificationAction::new(message_config), + ), + #[cfg(feature = "ntfy")] + AnyActionConfig::Ntfy(config) => { + Box::new(self::ntfy::NtfyAction::new(message_config, config)?) + } + #[cfg(feature = "email")] + AnyActionConfig::Email(config) => { + Box::new(self::email::EmailAction::new(message_config, config)?) + } + #[cfg(feature = "telegram")] + AnyActionConfig::Telegram(config) => { + Box::new(self::telegram::TelegramAction::new(message_config, config)?) + } + #[cfg(feature = "nostr")] + AnyActionConfig::Nostr(config) => { + Box::new(self::nostr::NostrAction::new(message_config, config).await?) + } + }) +} + +pub async fn get_actions<'a>( + message_config: &'a MessageConfig, + actions_config: &'a [AnyActionConfig], +) -> Vec<Box<dyn Action<'a> + 'a + Sync>> { + let mut result: Vec<Box<dyn Action + Sync>> = Default::default(); + + // TODO: parallelize this. It's hard because the result vector needs to be shared. + for action_config in actions_config { + debug!("registering action '{}'", action_config); + match get_action(message_config, action_config).await { + Ok(action) => { + info!("registered action '{}'", action_config); + result.push(action); + } + Err(e) => { + warn!("could not register action '{}': {}", action_config, e); + } + } + } + + result +} diff --git a/src/actions/nostr.rs b/src/actions/nostr.rs new file mode 100644 index 0000000..aebccba --- /dev/null +++ b/src/actions/nostr.rs @@ -0,0 +1,192 @@ +use super::Action; +use crate::message::MessageConfig; +use crate::message::MessageParams; +use anyhow::{Context, Result}; +use async_scoped::TokioScope; +use async_trait::async_trait; +use const_format::formatcp; +use nostr_relay_pool::RelayOptions; +use nostr_sdk::nips::nip05; +use nostr_sdk::serde_json::from_reader; +use nostr_sdk::serde_json::to_string; +use nostr_sdk::Client; +use nostr_sdk::Keys; +use nostr_sdk::Metadata; +use nostr_sdk::PublicKey; +use nostr_sdk::ToBech32; +use serde::Deserialize; +use serde::Serialize; +use std::fs::File; +use std::io::BufReader; +use std::io::Write; +use std::net::SocketAddr; +use std::path::PathBuf; + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct NostrData { + key: String, + metadata_set: bool, +} + +impl Default for NostrData { + fn default() -> Self { + NostrData { + key: Keys::generate().secret_key().unwrap().to_bech32().unwrap(), + metadata_set: false, + } + } +} + +fn get_nostr_data_filepath() -> PathBuf { + dirs::cache_dir() + .unwrap_or(PathBuf::from("cache")) + .join(env!("CARGO_PKG_NAME")) + .join("nostr.json") +} + +fn get_nostr_data() -> Result<NostrData> { + let path = get_nostr_data_filepath(); + match File::open(&path) { + Ok(file) => { + let reader = BufReader::new(file); + from_reader(reader) + .with_context(|| format!("cannot read nostr data from '{}'", path.display())) + } + Err(_) => { + let nostr_data = NostrData::default(); + let mut file = File::create(&path)?; + file.write_all(to_string(&nostr_data)?.as_bytes()) + .with_context(|| format!("could not write nostr data to '{}'", path.display()))?; + Ok(nostr_data) + } + } +} + +fn get_default_relays() -> Vec<String> { + vec![ + "wss://nostr.bitcoiner.social", + "wss://nostr.oxtr.dev", + "wss://nostr.orangepill.dev", + "wss://relay.damus.io", + ] + .into_iter() + .map(String::from) + .collect() +} + +fn get_default_bot_metadata() -> Metadata { + Metadata::new() + .name(formatcp!("{}bot", env!("CARGO_PKG_NAME"))) + .display_name(formatcp!("{} bot", env!("CARGO_PKG_NAME"))) + .about(env!("CARGO_PKG_DESCRIPTION")) + .website(env!("CARGO_PKG_REPOSITORY").parse().unwrap()) + .picture("https://robohash.org/sentrumbot.png".parse().unwrap()) + .banner( + "https://void.cat/d/HX1pPeqz21hvneLDibs5JD.webp" + .parse() + .unwrap(), + ) + .lud06(formatcp!( + "https://sommerfeld.dev/.well-known/lnurlp/{}", + env!("CARGO_PKG_NAME") + )) + .lud16(formatcp!("{}@sommerfeld.dev", env!("CARGO_PKG_NAME"))) +} + +fn mark_bot_metadata_as_set(mut nostr_data: NostrData) -> Result<()> { + let path = get_nostr_data_filepath(); + nostr_data.metadata_set = true; + let mut file = File::create(&path)?; + file.write_all(to_string(&nostr_data)?.as_bytes()) + .with_context(|| format!("could not write nostr data to '{}'", path.display()))?; + Ok(()) +} + +#[derive(Deserialize, Debug)] +pub struct NostrConfig { + #[serde(default = "get_default_relays")] + relays: Vec<String>, + proxy: Option<SocketAddr>, + #[serde(default = "get_default_bot_metadata")] + bot_metadata: Metadata, + #[serde(default)] + resend_bot_metadata: bool, + recipient: String, + #[serde(default)] + sealed_dm: bool, +} + +impl NostrConfig {} + +pub struct NostrAction<'a> { + message_config: &'a MessageConfig, + client: Client, + recipient: PublicKey, + sealed_dm: bool, +} + +impl<'a> NostrAction<'a> { + pub async fn new( + message_config: &'a MessageConfig, + nostr_config: &'a NostrConfig, + ) -> Result<Self> { + let nostr_data = get_nostr_data()?; + let keys = Keys::parse(&nostr_data.key) + .with_context(|| format!("could not parse nostr secret key '{}'", nostr_data.key))?; + + let client = Client::new(&keys); + + let relay_opts = RelayOptions::new().read(false).proxy(nostr_config.proxy); + TokioScope::scope_and_block(|s| { + for relay in nostr_config.relays.iter() { + s.spawn(client.add_relay_with_opts(relay.clone(), relay_opts.clone())); + } + }); + + client.connect().await; + + if !nostr_data.metadata_set || nostr_config.resend_bot_metadata { + client.set_metadata(&nostr_config.bot_metadata).await?; + mark_bot_metadata_as_set(nostr_data)?; + } + + let recipient = match PublicKey::parse(&nostr_config.recipient) { + Ok(p) => p, + Err(e) => { + nip05::get_profile(&nostr_config.recipient, nostr_config.proxy) + .await + .with_context(|| { + format!("invalid recipient '{}': {}", nostr_config.recipient, e) + })? + .public_key + } + }; + + Ok(Self { + message_config, + client, + recipient, + sealed_dm: nostr_config.sealed_dm, + }) + } +} + +#[async_trait] +impl Action<'_> for NostrAction<'_> { + async fn run(&self, params: Option<&MessageParams<'_, '_>>) -> Result<()> { + let subject = self.message_config.subject(params)?; + let body = self.message_config.body(params)?; + let message = format!("{}\n{}", subject, body); + + if self.sealed_dm { + self.client + .send_sealed_msg(self.recipient, message, None) + .await?; + } else { + self.client + .send_direct_msg(self.recipient, message, None) + .await?; + } + Ok(()) + } +} diff --git a/src/actions/ntfy.rs b/src/actions/ntfy.rs new file mode 100644 index 0000000..7d83b87 --- /dev/null +++ b/src/actions/ntfy.rs @@ -0,0 +1,119 @@ +use super::Action; +use crate::message::MessageConfig; +use crate::message::MessageFormat; +use crate::message::MessageParams; +use anyhow::Result; +use async_trait::async_trait; +use ntfy::Auth; +use ntfy::Dispatcher; +use ntfy::Payload; +use ntfy::Priority; +use ntfy::Url; +use serde::Deserialize; + +#[derive(Deserialize, Debug)] +#[serde(remote = "Priority")] +#[serde(rename_all = "snake_case")] +pub enum NtfyPriority { + Max = 5, + High = 4, + Default = 3, + Low = 2, + Min = 1, +} + +#[derive(Deserialize, Debug)] +pub struct NtfyCredentials { + username: String, + password: String, +} + +#[derive(Deserialize, Debug)] +pub struct NtfyConfig { + url: Option<String>, + proxy: Option<String>, + topic: Option<String>, + pub credentials: Option<NtfyCredentials>, + #[serde(with = "NtfyPriority")] + #[serde(default)] + pub priority: Priority, + pub tags: Option<Vec<String>>, + pub attach: Option<Url>, + pub filename: Option<String>, + pub delay: Option<String>, + pub email: Option<String>, +} + +impl NtfyConfig { + pub fn url(&self) -> &str { + self.url.as_deref().unwrap_or("https://ntfy.sh") + } + + pub fn topic(&self) -> &str { + self.topic.as_deref().unwrap_or(env!("CARGO_PKG_NAME")) + } +} + +pub struct NtfyAction<'a> { + message_config: &'a MessageConfig, + dispatcher: Dispatcher, + payload_template: Payload, +} + +impl<'a> NtfyAction<'a> { + pub fn new(message_config: &'a MessageConfig, ntfy_config: &'a NtfyConfig) -> Result<Self> { + let mut dispatcher_builder = Dispatcher::builder(ntfy_config.url()); + if let Some(cred) = &ntfy_config.credentials { + dispatcher_builder = + dispatcher_builder.credentials(Auth::new(&cred.username, &cred.password)); + } + if let Some(proxy) = &ntfy_config.proxy { + dispatcher_builder = dispatcher_builder.proxy(proxy); + } + + let mut payload = Payload::new(ntfy_config.topic()) + .markdown(match message_config.format() { + MessageFormat::Plain => false, + MessageFormat::Markdown => true, + MessageFormat::Html => true, + }) + .priority(ntfy_config.priority.clone()) + .tags( + ntfy_config + .tags + .as_deref() + .unwrap_or(&["rotating_light".to_string()]), + ); + if let Some(attach) = &ntfy_config.attach { + payload = payload.attach(attach.clone()); + } + if let Some(filename) = &ntfy_config.filename { + payload = payload.filename(filename.clone()); + } + if let Some(delay) = &ntfy_config.delay { + payload = payload.delay(delay.parse()?); + } + if let Some(email) = &ntfy_config.email { + payload = payload.email(email.clone()); + } + Ok(Self { + message_config, + dispatcher: dispatcher_builder.build()?, + payload_template: payload, + }) + } +} + +#[async_trait] +impl Action<'_> for NtfyAction<'_> { + async fn run(&self, params: Option<&MessageParams<'_, '_>>) -> Result<()> { + let payload = self + .payload_template + .clone() + .title(self.message_config.subject(params)?) + .message(self.message_config.body(params)?) + .click(self.message_config.get_tx_url(params)?.parse()?); + self.dispatcher.send(&payload).await?; + Ok(()) + } +} diff --git a/src/actions/telegram.rs b/src/actions/telegram.rs new file mode 100644 index 0000000..b489864 --- /dev/null +++ b/src/actions/telegram.rs @@ -0,0 +1,56 @@ +use super::Action; +use crate::message::MessageConfig; +use crate::message::MessageParams; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; +use teloxide::requests::Requester; +use teloxide::types::UserId; +use teloxide::Bot; + +#[derive(Deserialize, Debug)] +pub struct TelegramConfig { + bot_token: String, + user_id: u64, +} + +impl TelegramConfig { + pub fn bot_token(&self) -> &str { + &self.bot_token + } + + pub fn user_id(&self) -> u64 { + self.user_id + } +} + +pub struct TelegramAction<'a> { + message_config: &'a MessageConfig, + bot: Bot, + user_id: UserId, +} + +impl<'a> TelegramAction<'a> { + pub fn new( + message_config: &'a MessageConfig, + telegram_config: &'a TelegramConfig, + ) -> Result<Self> { + Ok(Self { + message_config, + bot: Bot::new(telegram_config.bot_token()), + user_id: UserId(telegram_config.user_id()), + }) + } +} + +#[async_trait] +impl Action<'_> for TelegramAction<'_> { + async fn run(&self, params: Option<&MessageParams<'_, '_>>) -> Result<()> { + let subject = self.message_config.subject(params)?; + let body = self.message_config.body(params)?; + self.bot + .send_message(self.user_id, format!("{}\n{}", subject, body)) + .await?; + Ok(()) + } +} diff --git a/src/actions/terminal_print.rs b/src/actions/terminal_print.rs new file mode 100644 index 0000000..02536c7 --- /dev/null +++ b/src/actions/terminal_print.rs @@ -0,0 +1,28 @@ +use super::Action; +use crate::message::MessageConfig; +use crate::message::MessageParams; +use anyhow::Result; +use async_trait::async_trait; + +#[derive(Debug)] +pub struct TerminalPrintAction<'a> { + message_config: &'a MessageConfig, +} + +impl<'a> TerminalPrintAction<'a> { + pub fn new(message_config: &'a MessageConfig) -> Self { + Self { message_config } + } +} + +#[async_trait] +impl Action<'_> for TerminalPrintAction<'_> { + async fn run(&self, params: Option<&MessageParams<'_, '_>>) -> Result<()> { + println!( + "{}\n{}\n", + self.message_config.subject(params)?, + self.message_config.body(params)? + ); + Ok(()) + } +} |