diff options
| author | 2024-04-21 16:04:38 +0100 | |
|---|---|---|
| committer | 2024-04-21 16:04:38 +0100 | |
| commit | 1ab6ecba6f509b7b76865d65c77ecebc51efd2d3 (patch) | |
| tree | a9b92e15769d483560d5799569b14c985b9c3ea5 /src/actions | |
| download | sentrum-1ab6ecba6f509b7b76865d65c77ecebc51efd2d3.tar.gz sentrum-1ab6ecba6f509b7b76865d65c77ecebc51efd2d3.tar.bz2 sentrum-1ab6ecba6f509b7b76865d65c77ecebc51efd2d3.zip | |
Initial commitv0.1.0
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(()) +    } +} | 
