summaryrefslogtreecommitdiffstatshomepage
path: root/src/actions/nostr.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/actions/nostr.rs')
-rw-r--r--src/actions/nostr.rs192
1 files changed, 192 insertions, 0 deletions
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(())
+ }
+}