From 1ab6ecba6f509b7b76865d65c77ecebc51efd2d3 Mon Sep 17 00:00:00 2001 From: sommerfeld Date: Sun, 21 Apr 2024 16:04:38 +0100 Subject: Initial commit --- src/actions/command.rs | 61 ++++++++++ src/actions/desktop_notification.rs | 28 +++++ src/actions/email.rs | 142 ++++++++++++++++++++++ src/actions/mod.rs | 120 +++++++++++++++++++ src/actions/nostr.rs | 192 ++++++++++++++++++++++++++++++ src/actions/ntfy.rs | 119 +++++++++++++++++++ src/actions/telegram.rs | 56 +++++++++ src/actions/terminal_print.rs | 28 +++++ src/blockchain.rs | 96 +++++++++++++++ src/config.rs | 143 ++++++++++++++++++++++ src/main.rs | 179 ++++++++++++++++++++++++++++ src/message.rs | 203 +++++++++++++++++++++++++++++++ src/wallets.rs | 230 ++++++++++++++++++++++++++++++++++++ 13 files changed, 1597 insertions(+) create mode 100644 src/actions/command.rs create mode 100644 src/actions/desktop_notification.rs create mode 100644 src/actions/email.rs create mode 100644 src/actions/mod.rs create mode 100644 src/actions/nostr.rs create mode 100644 src/actions/ntfy.rs create mode 100644 src/actions/telegram.rs create mode 100644 src/actions/terminal_print.rs create mode 100644 src/blockchain.rs create mode 100644 src/config.rs create mode 100644 src/main.rs create mode 100644 src/message.rs create mode 100644 src/wallets.rs (limited to 'src') 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, + #[serde(default)] + clear_parent_env: bool, + #[serde(default)] + envs: HashMap, + working_dir: Option, +} + +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 { + 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, + credentials: Option, + connection: Option, + self_signed_cert: Option, + from: String, + to: Option, +} + +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, + message_builder: MessageBuilder, +} +impl<'a> EmailAction<'a> { + pub fn new(message_config: &'a MessageConfig, email_config: &'a EmailConfig) -> Result { + 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::::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!( + "{}", + 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 + '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 + 'a + Sync>> { + let mut result: Vec> = 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 { + 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 { + 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, + proxy: Option, + #[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 { + 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, + proxy: Option, + topic: Option, + pub credentials: Option, + #[serde(with = "NtfyPriority")] + #[serde(default)] + pub priority: Priority, + pub tags: Option>, + pub attach: Option, + pub filename: Option, + pub delay: Option, + pub email: Option, +} + +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 { + 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 { + 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(()) + } +} diff --git a/src/blockchain.rs b/src/blockchain.rs new file mode 100644 index 0000000..2f5014b --- /dev/null +++ b/src/blockchain.rs @@ -0,0 +1,96 @@ +use anyhow::{Context, Result}; +use bdk::{ + bitcoin::Network, + blockchain::{ElectrumBlockchain, GetHeight}, + electrum_client::{Client, ConfigBuilder, Socks5Config}, +}; +use serde::Deserialize; + +fn get_default_electrum_server(network: Network) -> &'static str { + match network { + Network::Bitcoin => "ssl://fulcrum.sethforprivacy.com:50002", + Network::Testnet => "ssl://electrum.blockstream.info:60002", + Network::Signet => "ssl://mempool.space:60602", + _ => panic!("unsupported network"), + } +} + +#[derive(Deserialize, Default, Debug)] +pub struct ElectrumConfig { + url: Option, + + network: Option, + + socks5: Option, + + #[serde(default)] + certificate_validation: bool, +} + +impl ElectrumConfig { + pub fn url(&self) -> &str { + self.url + .as_deref() + .unwrap_or(get_default_electrum_server(self.network())) + } + + pub fn network(&self) -> Network { + self.network.unwrap_or(Network::Bitcoin) + } + + pub fn certificate_validation(&self) -> bool { + self.certificate_validation + } + + pub fn socks5(&self) -> Option { + self.socks5.as_ref().map(Socks5Config::new) + } +} + +pub fn get_blockchain(electrum_cfg: &ElectrumConfig) -> Result { + let server_cfg = ConfigBuilder::new() + .validate_domain(electrum_cfg.certificate_validation()) + .socks5(electrum_cfg.socks5()) + .build(); + let electrum_url = electrum_cfg.url(); + let client = Client::from_config(electrum_url, server_cfg) + .with_context(|| "could not configure electrum client".to_string())?; + Ok(ElectrumBlockchain::from(client)) +} + +pub struct BlockchainState { + height: Option, + url: String, + blockchain: ElectrumBlockchain, +} + +impl BlockchainState { + pub fn new(electrum_cfg: &ElectrumConfig) -> Result { + Ok(Self { + height: Default::default(), + url: String::from(electrum_cfg.url()), + blockchain: get_blockchain(electrum_cfg)?, + }) + } + + pub fn update_height(&mut self) { + match self.blockchain.get_height() { + Ok(polled_height) => { + match self.height { + Some(h) => { + if polled_height != h { + self.height = Some(polled_height); + debug!("current block height: {}", polled_height); + } + } + None => { + self.height = Some(polled_height); + info!("connected to '{}'", self.url); + info!("current block height: {}", polled_height); + } + }; + } + Err(e) => warn!("could not reach '{}': {}", self.url, e), + }; + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..2f3e5ed --- /dev/null +++ b/src/config.rs @@ -0,0 +1,143 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, +}; + +use anyhow::{bail, Context, Result}; +use clap::Parser; +use const_format::{formatcp, map_ascii_case, Case}; +use serde::Deserialize; + +use crate::{ + actions::AnyActionConfig, blockchain::ElectrumConfig, message::MessageConfig, + wallets::WalletConfig, +}; + +#[derive(Parser, Debug)] +#[command(version, about)] +pub struct Args { + /// Path to toml configuration file + config: Option, + /// Perform configured actions on a test notification + #[arg(short, long)] + test: bool, + /// Notify for every past transaction (careful: if you have a long transaction history, this + /// can SPAM your configured actions + #[arg(short, long)] + notify_past_txs: bool, +} + +impl Args { + pub fn config(&self) -> Option<&str> { + self.config.as_deref() + } + + pub fn test(&self) -> bool { + self.test + } + + pub fn notify_past_txs(&self) -> bool { + self.notify_past_txs + } +} + +fn get_config_filename() -> &'static str { + formatcp!("{}.toml", env!("CARGO_PKG_NAME")) +} + +fn get_config_env_var() -> &'static str { + formatcp!( + "{}_CONFIG", + map_ascii_case!(Case::Upper, env!("CARGO_PKG_NAME")) + ) +} + +fn get_cwd_config_path() -> PathBuf { + PathBuf::from(".").join(get_config_filename()) +} + +fn get_config_path_impl(user_config_dir: &Path) -> PathBuf { + user_config_dir + .join(env!("CARGO_PKG_NAME")) + .join(get_config_filename()) +} + +fn get_user_config_path() -> Option { + dirs::config_dir().map(|p| get_config_path_impl(&p)) +} + +fn get_system_config_path() -> PathBuf { + get_config_path_impl(&systemd_directories::config_dir().unwrap_or(PathBuf::from("/etc"))) +} + +fn get_config_path(maybe_arg_config: &Option<&str>) -> Result { + if let Some(arg_path) = maybe_arg_config { + return Ok(PathBuf::from(arg_path)); + } + + if let Ok(env_path) = env::var(get_config_env_var()) { + return Ok(PathBuf::from(env_path)); + } + + let cwd_config_path = get_cwd_config_path(); + if cwd_config_path.try_exists().is_ok_and(|x| x) { + return Ok(cwd_config_path); + } + + if let Some(user_config_path) = get_user_config_path() { + if user_config_path.try_exists().is_ok_and(|x| x) { + return Ok(user_config_path); + } + } + + let system_config_path = get_system_config_path(); + if system_config_path.try_exists().is_ok_and(|x| x) { + return Ok(system_config_path); + } + + bail!( + "no configuration file was passed as first argument, nor by the '{}' environment variable, nor did one exist in the default search paths: '{}', '{}', '{}'", + get_config_env_var(), + get_cwd_config_path().display(), + get_user_config_path().unwrap_or_default().display(), + get_system_config_path().display() + ); +} + +#[derive(Deserialize, Debug)] +pub struct Config { + wallets: Vec, + #[serde(default)] + electrum: ElectrumConfig, + #[serde(default)] + message: MessageConfig, + #[serde(default)] + actions: Vec, +} + +impl Config { + pub fn electrum(&self) -> &ElectrumConfig { + &self.electrum + } + + pub fn wallets(&self) -> &[WalletConfig] { + &self.wallets + } + + pub fn message(&self) -> &MessageConfig { + &self.message + } + + pub fn actions(&self) -> &[AnyActionConfig] { + &self.actions + } +} + +pub fn get_config(maybe_arg_config: &Option<&str>) -> Result { + let config_path = get_config_path(maybe_arg_config)?; + info!("reading configuration from '{}'", config_path.display()); + let config_content = fs::read_to_string(&config_path) + .with_context(|| format!("could not read config file '{}'", config_path.display()))?; + toml::from_str(&config_content) + .with_context(|| format!("could not parse config file '{}'", config_path.display(),)) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..9b10935 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,179 @@ +extern crate pretty_env_logger; +#[macro_use] +extern crate log; + +use std::process::exit; +use std::time::Duration; + +use actions::Action; +use async_scoped::TokioScope; +use clap::Parser; +use human_panic::setup_panic; + +use anyhow::{bail, Context, Result}; +use tokio::signal::unix::{signal, SignalKind}; +use tokio::time::sleep; + +mod actions; +mod blockchain; +mod config; +mod message; +mod wallets; + +use crate::actions::get_actions; +use crate::message::MessageParams; +use crate::{ + blockchain::BlockchainState, + config::{get_config, Args}, + wallets::{get_wallets, SafeWalletInfo}, +}; + +fn set_logger() { + pretty_env_logger::formatted_builder() + .filter_module(env!("CARGO_PKG_NAME"), log::LevelFilter::Info) + .parse_default_env() + .init(); +} + +fn set_signal_handlers() -> Result<()> { + tokio::spawn(async move { + if let Err(e) = tokio::signal::ctrl_c().await { + return e; + } + warn!("received ctrl-c signal. Exiting..."); + exit(0) + }); + tokio::spawn(async move { + let mut stream = match signal(SignalKind::terminate()) { + Err(e) => return e, + Ok(s) => s, + }; + stream.recv().await; + warn!("received process termination signal. Exiting..."); + exit(0) + }); + tokio::spawn(async move { + let mut stream = match signal(SignalKind::hangup()) { + Err(e) => return e, + Ok(s) => s, + }; + stream.recv().await; + warn!("received process hangup signal. Exiting..."); + exit(0) + }); + Ok(()) +} + +async fn run_test_actions(actions: &[&(dyn Action<'_> + Sync)]) { + TokioScope::scope_and_block(|s| { + for &action in actions { + s.spawn(action.run(None)); + } + }); +} + +fn get_and_handle_new_txs( + wallet_info: &SafeWalletInfo, + actions: &[&(dyn Action<'_> + Sync)], +) -> Result<()> { + let mut locked_wallet_info = wallet_info.lock().unwrap(); + let txs = locked_wallet_info.get_new_txs(); + TokioScope::scope_and_block(|s| { + for tx in txs.iter() { + let params = MessageParams::new(tx, &locked_wallet_info); + s.spawn(async move { + TokioScope::scope_and_block(|s| { + for &action in actions { + s.spawn(action.run(Some(¶ms))); + } + }); + }); + } + }); + Ok(()) +} + +async fn update_blockchain_thread(blockchain_state: &mut BlockchainState) { + loop { + blockchain_state.update_height(); + sleep(Duration::from_secs(60)).await; + } +} + +async fn watch_wallet_thread(wallet_info: &SafeWalletInfo, actions: &[&(dyn Action<'_> + Sync)]) { + loop { + if let Err(e) = get_and_handle_new_txs(wallet_info, actions) { + warn!("{:?}", e); + } + } +} + +async fn initial_wallet_sync(blockchain_state: &mut BlockchainState, wallets: &[SafeWalletInfo]) { + TokioScope::scope_and_block(|s| { + s.spawn(async { blockchain_state.update_height() }); + for wallet_info in wallets { + s.spawn(async { + if let Err(e) = get_and_handle_new_txs(wallet_info, &[]) { + warn!("{:?}", e); + } + }); + } + }); +} + +async fn watch_wallets( + blockchain_state: &mut BlockchainState, + wallets: &[SafeWalletInfo], + actions: &[&(dyn Action<'_> + Sync)], +) { + TokioScope::scope_and_block(|s| { + s.spawn(update_blockchain_thread(blockchain_state)); + for wallet_info in wallets { + s.spawn(watch_wallet_thread(wallet_info, actions)); + } + }); +} + +async fn do_main() -> Result<()> { + setup_panic!(); + let args = Args::parse(); + set_logger(); + set_signal_handlers().context("failed to setup a signal termination handler")?; + + let config = get_config(&args.config())?; + + let actions = get_actions(config.message(), config.actions()).await; + if actions.is_empty() { + bail!("no actions properly configured"); + } + let actions_ref = actions.iter().map(Box::as_ref).collect::>(); + + if args.test() { + run_test_actions(&actions_ref).await; + return Ok(()); + } + + let mut blockchain_state = BlockchainState::new(config.electrum())?; + + let wallets = get_wallets(config.wallets(), config.electrum()); + if wallets.is_empty() { + bail!("no wallets properly configured"); + } + + if !args.notify_past_txs() { + info!("initial wallet sync"); + initial_wallet_sync(&mut blockchain_state, &wallets).await; + } + info!("listening for new relevant events"); + watch_wallets(&mut blockchain_state, &wallets, &actions_ref).await; + + Ok(()) +} + +#[tokio::main] +async fn main() { + if let Err(e) = do_main().await { + error!("{:?}", e); + exit(1); + } +} diff --git a/src/message.rs b/src/message.rs new file mode 100644 index 0000000..419bb4d --- /dev/null +++ b/src/message.rs @@ -0,0 +1,203 @@ +extern crate chrono; +extern crate strfmt; + +use anyhow::{bail, Context, Result}; +use bdk::{bitcoin::Network, TransactionDetails}; +use chrono::DateTime; +use serde::Deserialize; +use strfmt::strfmt; + +use crate::wallets::WalletInfo; + +pub struct MessageParams<'a, 'b> { + tx: &'a TransactionDetails, + wallet: &'b str, + total_balance: u64, + current_height: u32, + network: Network, +} + +impl<'a, 'b> MessageParams<'a, 'b> { + pub fn new(tx: &'a TransactionDetails, wallet: &'b WalletInfo) -> Self { + Self { + tx, + wallet: wallet.name(), + total_balance: wallet.total_balance().unwrap_or_default(), + current_height: wallet.get_height().unwrap_or_default(), + network: wallet.get_network(), + } + } + + fn tx_net(&self) -> i64 { + (self.tx.received as i64) - (self.tx.sent as i64) + } + + fn tx_height(&self) -> Option { + self.tx.confirmation_time.as_ref().map(|x| x.height) + } + + fn confs(&self) -> u32 { + let current_height = self.current_height; + self.tx_height() + .map(|h| { + if current_height >= h { + current_height - h + } else { + 0 + } + }) + .unwrap_or_default() + } + + fn conf_timestamp(&self) -> String { + self.tx + .confirmation_time + .as_ref() + .map(|x| { + DateTime::from_timestamp(x.timestamp as i64, 0) + .unwrap_or_default() + .format("%Y-%m-%d %H:%M:%S UTC") + .to_string() + }) + .unwrap_or_default() + } + + fn txid(&self) -> String { + self.tx.txid.to_string() + } + fn txid_short(&self) -> String { + let txid = self.txid(); + format!("{}...{}", &txid[..6], &txid[txid.len() - 6..]) + } + + fn tx(&self) -> &TransactionDetails { + self.tx + } + + pub fn network(&self) -> Network { + self.network + } +} + +#[derive(Deserialize, Debug, PartialEq, Copy, Clone)] +pub enum MessageFormat { + Plain, + Markdown, + Html, +} + +#[derive(Deserialize, Default, Debug)] +pub struct BlockExplorers { + mainnet: Option, + testnet: Option, + signet: Option, +} + +impl BlockExplorers { + fn mainnet(&self) -> &str { + self.mainnet + .as_deref() + .unwrap_or("https://mempool.space/tx/{txid}") + } + + fn testnet(&self) -> &str { + self.testnet + .as_deref() + .unwrap_or("https://mempool.space/testnet/tx/{txid}") + } + + fn signet(&self) -> &str { + self.signet + .as_deref() + .unwrap_or("https://mempool.space/signet/tx/{txid}") + } + + pub fn get_tx_url_template(&self, network: &Network) -> Result<&str> { + Ok(match network { + Network::Bitcoin => self.mainnet(), + Network::Testnet => self.testnet(), + Network::Signet => self.signet(), + _ => bail!("unsupported network"), + }) + } + pub fn get_tx_url(&self, network: &Network, txid: &str) -> Result { + let template = self.get_tx_url_template(network)?; + strfmt!(template, txid => txid.to_string()) + .with_context(|| format!("bad block explorer URL template '{}'", template)) + } +} + +#[derive(Deserialize, Default, Debug)] +pub struct MessageConfig { + subject: Option, + body: Option, + format: Option, + #[serde(default)] + block_explorers: BlockExplorers, +} + +impl MessageConfig { + pub fn subject_template(&self) -> &str { + self.subject + .as_deref() + .unwrap_or("[{wallet}] new transaction") + } + + pub fn body_template(&self) -> &str { + self.body + .as_deref() + .unwrap_or("net: {tx_net} sats, balance: {total_balance} sats, txid: {txid_short}") + } + + pub fn replace_template_params( + &self, + template: &str, + params: &MessageParams, + ) -> Result { + strfmt!(template, + tx_net => params.tx_net(), + wallet => params.wallet.to_string(), + total_balance => params.total_balance, + txid => params.txid(), + txid_short => params.txid_short(), + received => params.tx().received, + sent => params.tx().sent, + fee => params.tx().fee.unwrap_or_default(), + current_height => params.current_height, + tx_height => params.tx_height().unwrap_or_default(), + confs => params.confs(), + conf_timestamp => params.conf_timestamp(), + tx_url => self.get_tx_url(Some(params))? + ) + .with_context(|| format!("invalid template '{}'", template)) + } + + pub fn subject(&self, params: Option<&MessageParams>) -> Result { + match params { + Some(p) => self.replace_template_params(self.subject_template(), p), + None => Ok(self.subject_template().to_string()), + } + } + + pub fn body(&self, params: Option<&MessageParams>) -> Result { + match params { + Some(p) => self.replace_template_params(self.body_template(), p), + None => Ok(self.body_template().to_string()), + } + } + + #[allow(dead_code)] + pub fn format(&self) -> &MessageFormat { + self.format.as_ref().unwrap_or(&MessageFormat::Plain) + } + + pub fn get_tx_url(&self, params: Option<&MessageParams>) -> Result { + match params { + Some(p) => self.block_explorers.get_tx_url(&p.network(), &p.txid()), + None => Ok(self + .block_explorers + .get_tx_url_template(&Network::Bitcoin)? + .to_string()), + } + } +} diff --git a/src/wallets.rs b/src/wallets.rs new file mode 100644 index 0000000..ca43e0a --- /dev/null +++ b/src/wallets.rs @@ -0,0 +1,230 @@ +use std::{ + collections::{hash_map::DefaultHasher, HashSet}, + hash::{Hash, Hasher}, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use anyhow::{Context, Result}; +use bdk::{ + bitcoin::{bip32::ExtendedPubKey, Network, Txid}, + blockchain::{ElectrumBlockchain, GetHeight}, + sled, + template::{Bip44Public, Bip49Public, Bip84Public, Bip86Public}, + KeychainKind, SyncOptions, TransactionDetails, Wallet, +}; +use serde::Deserialize; + +use crate::blockchain::{get_blockchain, ElectrumConfig}; + +#[derive(Deserialize, Debug, Clone, Copy)] +#[serde(rename_all = "snake_case")] +pub enum AddressKind { + Legacy, + NestedSegwit, + Segwit, + Taproot, +} + +#[derive(Deserialize, Debug)] +pub struct XpubSpec { + name: String, + xpub: String, + kind: Option, +} + +impl XpubSpec { + pub fn kind(&self) -> AddressKind { + self.kind.unwrap_or(AddressKind::Segwit) + } + + pub fn xpub(&self) -> &str { + &self.xpub + } + + pub fn name(&self) -> &str { + &self.name + } +} + +#[derive(Deserialize, Debug, Hash)] +pub struct DescriptorsSpec { + name: String, + primary: String, + change: Option, +} + +impl DescriptorsSpec { + pub fn get_hash(&self) -> String { + let mut s = DefaultHasher::new(); + self.hash(&mut s); + s.finish().to_string() + } + + pub fn primary(&self) -> &str { + &self.primary + } + + pub fn change(&self) -> Option<&String> { + self.change.as_ref() + } + + pub fn name(&self) -> &str { + &self.name + } +} + +#[derive(Deserialize, Debug)] +#[serde(untagged)] +pub enum WalletConfig { + Xpub(XpubSpec), + Descriptors(DescriptorsSpec), +} + +impl WalletConfig { + pub fn name(&self) -> &str { + match self { + WalletConfig::Xpub(xpub_spec) => xpub_spec.name(), + WalletConfig::Descriptors(descriptors_spec) => descriptors_spec.name(), + } + } +} + +fn get_cache_dir(db_name: &str) -> PathBuf { + dirs::cache_dir() + .unwrap_or(PathBuf::from("cache")) + .join(env!("CARGO_PKG_NAME")) + .join(db_name) +} + +fn get_xpub_wallet(xpub_spec: &XpubSpec, network: Network) -> Result> { + let xpub: ExtendedPubKey = xpub_spec.xpub().parse().unwrap(); + let fingerprint = xpub.fingerprint(); + let sled = sled::open(get_cache_dir(&fingerprint.to_string()))?.open_tree("wallet")?; + match xpub_spec.kind() { + AddressKind::Legacy => Wallet::new( + Bip44Public(xpub, fingerprint, KeychainKind::External), + Some(Bip44Public(xpub, fingerprint, KeychainKind::Internal)), + network, + sled, + ), + AddressKind::NestedSegwit => Wallet::new( + Bip49Public(xpub, fingerprint, KeychainKind::External), + Some(Bip49Public(xpub, fingerprint, KeychainKind::Internal)), + network, + sled, + ), + AddressKind::Segwit => Wallet::new( + Bip84Public(xpub, fingerprint, KeychainKind::External), + Some(Bip84Public(xpub, fingerprint, KeychainKind::Internal)), + network, + sled, + ), + AddressKind::Taproot => Wallet::new( + Bip86Public(xpub, fingerprint, KeychainKind::External), + Some(Bip86Public(xpub, fingerprint, KeychainKind::Internal)), + network, + sled, + ), + } + .with_context(|| format!("invalid xpub wallet '{}'", xpub)) +} + +fn get_descriptors_wallet( + descriptors_spec: &DescriptorsSpec, + network: Network, +) -> Result> { + let sled = sled::open(get_cache_dir(&descriptors_spec.get_hash()))?.open_tree("wallet")?; + Wallet::new( + descriptors_spec.primary(), + descriptors_spec.change().map(String::as_ref), + network, + sled, + ) + .with_context(|| format!("invalid descriptor wallet '{:?}'", descriptors_spec)) +} + +fn get_wallet(wallet_config: &WalletConfig, network: Network) -> Result> { + match &wallet_config { + WalletConfig::Xpub(xpub_spec) => get_xpub_wallet(xpub_spec, network), + WalletConfig::Descriptors(descriptors_spec) => { + get_descriptors_wallet(descriptors_spec, network) + } + } +} + +pub struct WalletInfo { + name: String, + wallet: Wallet, + old_txs: HashSet, + blockchain: ElectrumBlockchain, +} + +pub type SafeWalletInfo = Arc>; + +impl WalletInfo { + pub fn name(&self) -> &str { + &self.name + } + + pub fn get_height(&self) -> Result { + self.blockchain.get_height() + } + + pub fn get_network(&self) -> Network { + self.wallet.network() + } + + pub fn total_balance(&self) -> Result { + self.wallet.get_balance().map(|b| b.get_total()) + } + + pub fn get_new_txs(&mut self) -> Vec { + debug!("[{}] syncing wallet", self.name); + if let Err(e) = self.wallet.sync(&self.blockchain, SyncOptions::default()) { + warn!("[{}] cannot sync wallet: {}", self.name, e); + return Default::default(); + } + let tx_list = match self.wallet.list_transactions(false) { + Ok(txs) => txs, + Err(e) => { + warn!("[{}] cannot retrieve transactions: {}", self.name, e); + Default::default() + } + }; + + let new_txs: Vec = tx_list + .iter() + .filter(|&tx| !self.old_txs.contains(&tx.txid)) + .cloned() + .collect(); + new_txs.iter().for_each(|tx| { + self.old_txs.insert(tx.txid); + }); + new_txs + } +} + +pub fn get_wallets( + wallet_configs: &[WalletConfig], + electrum_cfg: &ElectrumConfig, +) -> Vec { + let mut result: Vec = vec![]; + for wallet_config in wallet_configs.iter() { + let name = wallet_config.name(); + match get_wallet(wallet_config, electrum_cfg.network()) { + Ok(w) => { + result.push(Arc::new(Mutex::new(WalletInfo { + name: name.to_string(), + wallet: w, + old_txs: Default::default(), + blockchain: get_blockchain(electrum_cfg).unwrap(), + }))); + } + Err(e) => { + error!("[{}] cannot setup wallet: {}", name, e); + } + } + } + result +} -- cgit v1.2.3-70-g09d2