aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/src/actions
diff options
context:
space:
mode:
Diffstat (limited to 'src/actions')
-rw-r--r--src/actions/command.rs61
-rw-r--r--src/actions/desktop_notification.rs28
-rw-r--r--src/actions/email.rs142
-rw-r--r--src/actions/mod.rs120
-rw-r--r--src/actions/nostr.rs192
-rw-r--r--src/actions/ntfy.rs119
-rw-r--r--src/actions/telegram.rs56
-rw-r--r--src/actions/terminal_print.rs28
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(())
+ }
+}