commit 4c44307be7ab95c70cf026ce3f119869cd0f5cff parent 1b04b3fd172fdf97207c24c8fe11ca87d85841d8 Author: triesap <137732411+triesap@users.noreply.github.com> Date: Sat, 26 Apr 2025 21:23:54 +0000 Add `radroots-common` git submodule and refactor to workspace structure. Diffstat:
28 files changed, 792 insertions(+), 617 deletions(-)
diff --git a/.gitignore b/.gitignore @@ -14,3 +14,4 @@ git-diff.txt *.pem *keys*.json *rhi*.toml +dev*.py +\ No newline at end of file diff --git a/.gitmodules b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "crates/radroots-common"] + path = crates/radroots-common + url = git@github.com:72-61-64-72-6f-6f-74-73/crates-radroots-common.git + branch = master diff --git a/Cargo.lock b/Cargo.lock @@ -48,6 +48,21 @@ dependencies = [ ] [[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] name = "anstream" version = "0.6.18" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -340,6 +355,20 @@ dependencies = [ ] [[package]] +name = "chrono" +version = "0.4.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] name = "cipher" version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -445,6 +474,12 @@ dependencies = [ ] [[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] name = "cpufeatures" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -779,6 +814,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] name = "icu_collections" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1145,6 +1204,15 @@ dependencies = [ ] [[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] name = "object" version = "0.36.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1339,6 +1407,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" [[package]] +name = "radroots-common" +version = "0.1.0" + +[[package]] name = "rand" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1446,6 +1518,7 @@ dependencies = [ "futures", "nostr", "nostr-sdk", + "radroots-common", "serde", "serde_json", "tempfile", @@ -1453,6 +1526,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "typeshare", "uuid", ] @@ -2041,6 +2115,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" [[package]] +name = "typeshare" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19be0f411120091e76e13e5a0186d8e2bcc3e7e244afdb70152197f1a8486ceb" +dependencies = [ + "chrono", + "serde", + "serde_json", + "typeshare-annotation", +] + +[[package]] +name = "typeshare-annotation" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a615d6c2764852a2e88a4f16e9ce1ea49bb776b5872956309e170d63a042a34f" +dependencies = [ + "quote", + "syn", +] + +[[package]] name = "ucd-trie" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2268,6 +2364,65 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] +name = "windows-core" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + +[[package]] +name = "windows-result" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +dependencies = [ + "windows-link", +] + +[[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -1,23 +1,10 @@ -[package] -name = "rhi" -version = "0.1.0" -authors = ["Radroots Authors"] -license = "AGPLv3" -edition = "2024" -description = "rhizome is a Nostr data vending machine (NIP-90)" +[workspace] +members = [ + "crates/*", +] +resolver = "2" -[dependencies] -anyhow = "1.0" -clap = { version = "4", features = ["derive"] } -config = "0.15" -futures = "0.3" -nostr = { version = "0.40.0", features = ["nip04"] } -nostr-sdk = "0.40.0" -serde = "1.0" -serde_json = "1.0" -tempfile = "3.19.1" -thiserror = "1.0" -tokio = { version = "1", features = ["full"] } -tracing = "0.1" -tracing-subscriber = "0.3" -uuid = { version = "1.16.0", features = ["v4"] } +[workspace.package] +edition = "2024" +license = "AGPLv3" +version = "0.1.0" diff --git a/crates/radroots-common b/crates/radroots-common @@ -0,0 +1 @@ +Subproject commit ebe5995f57d453cbeaa34c0aeb5f17f9038cba2d diff --git a/crates/rhi/Cargo.toml b/crates/rhi/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "rhi" +version.workspace = true +edition.workspace = true +license.workspace = true +authors = ["Radroots Authors"] +description = "rhizome is a Nostr data vending machine (NIP-90)" + +[dependencies] +anyhow = "1.0" +clap = { version = "4", features = ["derive"] } +config = "0.15" +futures = "0.3" +nostr = { version = "0.40.0", features = ["nip04"] } +nostr-sdk = "0.40.0" +serde = "1.0" +serde_json = "1.0" +tempfile = "3.19.1" +thiserror = "1.0" +tokio = { version = "1", features = ["full"] } +tracing = "0.1" +tracing-subscriber = "0.3" +typeshare = "1.0.0" +uuid = { version = "1.16.0", features = ["v4"] } +radroots-common = { path = "../radroots-common" } diff --git a/config.toml b/crates/rhi/config.toml diff --git a/src/config.rs b/crates/rhi/src/config.rs diff --git a/crates/rhi/src/events/job_request.rs b/crates/rhi/src/events/job_request.rs @@ -0,0 +1,339 @@ +use std::time::Duration; + +use anyhow::Result; +use nostr::event::{Event, EventId, Tag, TagKind}; +use nostr::filter::{Alphabet, SingleLetterTag}; +use nostr::{event::Kind, key::Keys}; +use nostr_sdk::Client; +use nostr_sdk::RelayPoolNotification; +use radroots_common::KIND_JOB_REQUEST; +use tokio::time::sleep; +use tracing::{info, warn}; + +use crate::handlers::job_request_order::{JobRequestOrderError, handle_job_request_order}; +use crate::handlers::job_request_preview::handle_job_request_preview; +use crate::handlers::job_request_quote::handle_job_request_quote; +use crate::utils::nostr::{ + NostrTagsResolveError, NostrUtilsError, nostr_event_job_feedback, nostr_filter_kind, + nostr_filter_new_events, nostr_tag_at_value, nostr_tag_first_value, nostr_tag_relays_parse, + nostr_tag_slice, nostr_tags_resolve, +}; +use crate::utils::unit::MassUnitError; + +#[derive(thiserror::Error, Debug)] +pub enum JobRequestError { + #[error("{0}")] + NostrUtilsError(#[from] NostrUtilsError), + + #[error("{0}")] + MassUnit(#[from] MassUnitError), + + #[error("{0}")] + NostrTagsResolve(#[from] NostrTagsResolveError), + + #[error("Order: {0}")] + JobRequestOrder(#[from] JobRequestOrderError), + + #[error("Invalid job request input type: {0}")] + InvalidInputType(String), + + #[error("Invalid job request input marker: {0}")] + InvalidInputMarker(String), + + #[error("Deserialization error: {0}")] + Serde(#[from] serde_json::Error), + + #[error("Failure to process request")] + Failure, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum JobRequestInputType { + Url, + Event, + Job, + Text, +} + +impl TryFrom<&str> for JobRequestInputType { + type Error = JobRequestError; + + fn try_from(s: &str) -> Result<Self, Self::Error> { + match s { + "url" => Ok(Self::Url), + "event" => Ok(Self::Event), + "job" => Ok(Self::Job), + "text" => Ok(Self::Text), + other => Err(JobRequestError::InvalidInputType(other.to_string())), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum JobRequestInputMarker { + Order, + Quote, + Preview, +} + +impl TryFrom<&str> for JobRequestInputMarker { + type Error = JobRequestError; + + fn try_from(s: &str) -> Result<Self, Self::Error> { + match s { + "order" => Ok(Self::Order), + "quote" => Ok(Self::Quote), + "preview" => Ok(Self::Preview), + other => Err(JobRequestError::InvalidInputMarker(other.to_string())), + } + } +} + +#[derive(Debug, Clone)] +pub struct JobRequestInput { + pub data: String, + pub input_type: JobRequestInputType, + pub relay: Option<String>, + pub marker: Option<JobRequestInputMarker>, +} + +#[derive(Debug, Clone)] +pub struct JobRequest { + pub id: EventId, + pub inputs: Vec<JobRequestInput>, + pub output: Option<String>, + pub bid_msat: Option<u64>, + pub relays: Vec<String>, + pub service_providers: Vec<String>, + pub params: Vec<(String, String)>, + pub hashtags: Vec<String>, + pub tags: Vec<Tag>, +} + +pub async fn subscriber(keys: Keys, relays: Vec<String>) -> Result<()> { + info!("Starting subscriber for kind {}", KIND_JOB_REQUEST); + let client = Client::new(keys.clone()); + + for relay in &relays { + client.add_relay(relay).await?; + } + + let filter = nostr_filter_new_events(nostr_filter_kind(KIND_JOB_REQUEST)); + + client.connect().await; + client.subscribe(filter, None).await?; + + let mut notifications = client.notifications(); + + while let Ok(n) = notifications.recv().await { + if let RelayPoolNotification::Event { event, .. } = n { + if event.kind == Kind::Custom(KIND_JOB_REQUEST) { + let event = (*event).clone(); + let keys = keys.clone(); + let client = client.clone(); + + tokio::spawn(async move { + if let Err(err) = + handle_event(event.clone(), keys.clone(), client.clone()).await + { + let _ = handle_error(err, event, keys, client, None).await; + } + }); + } + } + } + + client.disconnect().await; + + Ok(()) +} + +async fn handle_error( + error: JobRequestError, + event: Event, + _keys: Keys, + client: Client, + _job_req: Option<JobRequest>, +) -> Result<()> { + warn!("job_request handle_error error {}", error); + warn!("job_request handle_error event {:?}", { event.clone() }); + + let builder = nostr_event_job_feedback(&event, error, "error", None)?; + let event_id = client.send_event_builder(builder).await?; + + warn!("job_request handle_error sent feedback {:?}", { + event_id.clone() + }); + Ok(()) +} + +async fn handle_event(event: Event, keys: Keys, client: Client) -> Result<(), JobRequestError> { + let job_req = parse_event(&event, &keys)?; + for job_req_input in &job_req.inputs { + let marker = job_req_input + .marker + .as_ref() + .ok_or_else(|| JobRequestError::InvalidInputMarker(job_req.id.to_string()))?; + + match marker { + JobRequestInputMarker::Order => { + process_job_request( + handle_job_request_order, + event.clone(), + keys.clone(), + client.clone(), + job_req.clone(), + job_req_input.clone(), + ) + .await; + } + JobRequestInputMarker::Quote => { + process_job_request( + handle_job_request_quote, + event.clone(), + keys.clone(), + client.clone(), + job_req.clone(), + job_req_input.clone(), + ) + .await; + } + JobRequestInputMarker::Preview => { + process_job_request( + handle_job_request_preview, + event.clone(), + keys.clone(), + client.clone(), + job_req.clone(), + job_req_input.clone(), + ) + .await; + } + } + } + + Ok(()) +} + +fn parse_event(event: &Event, keys: &Keys) -> Result<JobRequest, JobRequestError> { + let tags = nostr_tags_resolve(event, keys)?; + let mut inputs = vec![]; + let mut output = None; + let mut bid_msat = None; + let mut relays = vec![]; + let mut providers = vec![]; + let mut params = vec![]; + let mut hashtags = vec![]; + + for tag in &tags { + match tag.kind() { + TagKind::SingleLetter(l) if l == SingleLetterTag::lowercase(Alphabet::I) => { + if let Some(vals) = nostr_tag_slice(tag, 1) { + match &vals[..] { + [data, input_type, relay, marker, ..] => { + let data = data.clone(); + let input_type = JobRequestInputType::try_from(input_type.as_str())?; + let relay = relay.clone(); + let marker = JobRequestInputMarker::try_from(marker.as_str())?; + inputs.push(JobRequestInput { + data, + input_type, + relay: Some(relay), + marker: Some(marker), + }); + } + _ => continue, + } + } + } + + TagKind::SingleLetter(l) if l == SingleLetterTag::lowercase(Alphabet::T) => { + if let Some(val) = nostr_tag_first_value(tag, "t") { + hashtags.push(val); + } + } + + TagKind::Custom(ref k) if k == "output" => { + output = nostr_tag_first_value(tag, k); + } + + TagKind::Custom(ref k) if k == "bid" => { + bid_msat = nostr_tag_first_value(tag, k).and_then(|s| s.parse().ok()); + } + + TagKind::Custom(k) if k == "param" => { + if let Some(vals) = nostr_tag_slice(tag, 1) { + if vals.len() >= 2 { + params.push((vals[0].clone(), vals[1].clone())); + } + } + } + + TagKind::Relays => { + if let Some(urls) = nostr_tag_relays_parse(tag) { + relays = urls.into_iter().map(|u| u.to_string()).collect(); + } + } + + TagKind::SingleLetter(l) if l == SingleLetterTag::lowercase(Alphabet::P) => { + if let Some(pk) = nostr_tag_at_value(tag, 1) { + providers.push(pk); + } + } + + _ => {} + } + } + + Ok(JobRequest { + id: event.id, + inputs, + output, + bid_msat, + relays, + service_providers: providers, + tags, + params, + hashtags, + }) +} + +async fn process_job_request<F, Fut>( + handler: F, + event: Event, + keys: Keys, + client: Client, + job_req: JobRequest, + job_req_input: JobRequestInput, +) where + F: FnOnce(Event, Keys, Client, JobRequest, JobRequestInput) -> Fut, + Fut: std::future::Future<Output = Result<(), JobRequestError>>, +{ + if cfg!(debug_assertions) { + sleep(Duration::from_millis(500)).await; + } + + let error_event = event.clone(); + let error_job_req = job_req.clone(); + let error_keys = keys.clone(); + let error_client = client.clone(); + + if let Err(err) = handler( + event, + keys.clone(), + client.clone(), + job_req.clone(), + job_req_input.clone(), + ) + .await + { + let _ = handle_error( + err, + error_event, + error_keys, + error_client, + Some(error_job_req), + ) + .await; + } +} diff --git a/src/events/mod.rs b/crates/rhi/src/events/mod.rs diff --git a/src/handlers/job_request_order.rs b/crates/rhi/src/handlers/job_request_order.rs diff --git a/src/handlers/job_request_preview.rs b/crates/rhi/src/handlers/job_request_preview.rs diff --git a/src/handlers/job_request_quote.rs b/crates/rhi/src/handlers/job_request_quote.rs diff --git a/src/handlers/mod.rs b/crates/rhi/src/handlers/mod.rs diff --git a/crates/rhi/src/keys.rs b/crates/rhi/src/keys.rs @@ -0,0 +1,200 @@ +use anyhow::Result; +use nostr::{ + Event, Keys, + event::{EventBuilder, Kind, Tag, TagKind}, + nips::nip01::Metadata, +}; +use radroots_common::{KIND_APPLICATION_HANDLER, KIND_JOB_REQUEST}; +use serde::{Deserialize, Serialize}; +use std::{ + fs::{self, File}, + io::{BufReader, Write}, + path::{Path, PathBuf}, + str::FromStr, +}; +use tempfile::NamedTempFile; +use thiserror::Error; +use tracing::{error, warn}; +use uuid::Uuid; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +#[derive(Error, Debug)] +pub enum KeyProfileError { + #[error("Keys file does not exist at {0}")] + NotFound(PathBuf), + + #[error("Failed to open keys file at {0}: {1}")] + FileOpen(PathBuf, #[source] std::io::Error), + + #[error("Keys file already exists at {0}")] + AlreadyExists(PathBuf), + + #[error("Failed to parse keys file at {0}: {1}")] + FileParse(PathBuf, #[source] serde_json::Error), + + #[error("Failed to serialize keys: {0}")] + Serialization(#[from] serde_json::Error), + + #[error("IO error during key write: {0}")] + Io(#[from] std::io::Error), + + #[error("Failed to persist keys to disk: {0}")] + Persist(#[from] tempfile::PersistError), + + #[error("Failed to build or sign nostr event: {0}")] + NostrBuilder(#[from] nostr::event::builder::Error), + + #[error("Invalid secret key for identifier: {0}")] + InvalidSecretKey(String), +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct KeyProfile { + key: String, + identifier: String, + pub metadata: Option<Event>, + pub application_handler: Option<Event>, + + #[serde(skip)] + path: Option<PathBuf>, +} + +impl KeyProfile { + pub fn init<P: AsRef<str>>( + path_str: P, + generate: bool, + identifier_tag: Option<String>, + ) -> Result<Self, KeyProfileError> { + let path = PathBuf::from(path_str.as_ref()); + + if path.exists() { + let file = File::open(&path).map_err(|e| KeyProfileError::FileOpen(path.clone(), e))?; + let reader = BufReader::new(file); + let mut profile: KeyProfile = serde_json::from_reader(reader) + .map_err(|e| KeyProfileError::FileParse(path.clone(), e))?; + profile.path = Some(path.clone()); + + if !profile.identifier.trim().is_empty() { + if let Some(new_id) = identifier_tag { + warn!( + "Provided identifier '{}' is being ignored because the keys file already contains identifier '{}'.", + new_id, profile.identifier + ); + } + } else { + profile.identifier = identifier_tag.unwrap_or_else(|| { + warn!( + "Missing NIP-89 application handler identifier in key file, generating UUID." + ); + Uuid::new_v4().to_string() + }); + profile.persist()?; + } + + Ok(profile) + } else if generate { + let keys = Keys::generate(); + let secret = keys.secret_key(); + let identifier = match identifier_tag { + Some(identifier) => identifier, + None => { + warn!( + "Missing NIP-89 application handler identifier in key file, generating UUID." + ); + Uuid::new_v4().to_string() + } + }; + + let profile = KeyProfile { + key: secret.to_secret_hex(), + identifier, + metadata: None, + application_handler: None, + path: Some(path.clone()), + }; + + profile.atomic_write(&path)?; + Ok(profile) + } else { + Err(KeyProfileError::NotFound(path)) + } + } + + pub fn keys(&self) -> Result<Keys, KeyProfileError> { + Keys::from_str(&self.key) + .map_err(|_| KeyProfileError::InvalidSecretKey(self.identifier.clone())) + } + + fn atomic_write<P: AsRef<Path>>(&self, path: P) -> Result<(), KeyProfileError> { + let json = serde_json::to_string(self)?; + + let dir = path.as_ref().parent().unwrap_or_else(|| Path::new(".")); + let mut temp_file = NamedTempFile::new_in(dir)?; + + temp_file.write_all(json.as_bytes())?; + temp_file.as_file_mut().sync_all()?; + + #[cfg(unix)] + { + fs::set_permissions(temp_file.path(), fs::Permissions::from_mode(0o600))?; + } + + temp_file.persist(path)?; + Ok(()) + } + + fn persist(&self) -> Result<(), KeyProfileError> { + match &self.path { + Some(p) => self.atomic_write(p), + None => Err(KeyProfileError::NotFound(PathBuf::from("[unknown path]"))), + } + } + + pub async fn build_metadata( + &mut self, + metadata: &Metadata, + ) -> Result<Option<Event>, KeyProfileError> { + if self.metadata.is_none() { + let keys = self.keys()?; + let event = EventBuilder::metadata(metadata).sign(&keys).await?; + self.metadata = Some(event.clone()); + self.persist()?; + Ok(Some(event)) + } else { + Ok(None) + } + } + + pub async fn build_application_handler(&mut self) -> Result<Option<Event>, KeyProfileError> { + if self.application_handler.is_none() { + let keys = self.keys()?; + let kind_0_content = self + .metadata + .as_ref() + .expect(&format!( + "The kind 0 metadata must be initialized before kind {} descriptor", + KIND_APPLICATION_HANDLER.to_string() + )) + .content + .clone(); + + let tags: Vec<Tag> = vec![ + Tag::custom(TagKind::Custom("k".into()), [KIND_JOB_REQUEST.to_string()]), + Tag::identifier(self.identifier.to_string()), + ]; + + let event = EventBuilder::new(Kind::Custom(KIND_APPLICATION_HANDLER), kind_0_content) + .tags(tags) + .sign(&keys) + .await?; + + self.application_handler = Some(event.clone()); + self.persist()?; + Ok(Some(event)) + } else { + Ok(None) + } + } +} diff --git a/crates/rhi/src/lib.rs b/crates/rhi/src/lib.rs @@ -0,0 +1,6 @@ +pub mod config; +pub mod events; +pub mod handlers; +pub mod keys; +pub mod models; +pub mod utils; diff --git a/src/main.rs b/crates/rhi/src/main.rs diff --git a/src/models/event_classified.rs b/crates/rhi/src/models/event_classified.rs diff --git a/src/models/mod.rs b/crates/rhi/src/models/mod.rs diff --git a/crates/rhi/src/models/order_classified.rs b/crates/rhi/src/models/order_classified.rs @@ -0,0 +1,51 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[typeshare] +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct OrderClassifiedResult { + pub quantity: OrderClassifiedQuantity, + pub price: OrderClassifiedPrice, + pub discounts: Vec<OrderClassifiedDiscount>, + pub subtotal: OrderClassifiedTotal, + pub total: OrderClassifiedTotal, +} + +#[typeshare] +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct OrderClassifiedQuantity { + pub amount: f64, + pub unit: String, + pub label: String, +} + +#[typeshare] +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct OrderClassifiedPrice { + pub amount: f64, + pub currency: String, + pub quantity_amount: f64, + pub quantity_unit: String, +} + +#[typeshare] +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct OrderClassifiedDiscount { + pub discount_type: String, + pub threshold: Option<f64>, + pub threshold_unit: Option<String>, + pub discount_per_unit: Option<f64>, + pub discount_unit: Option<String>, + pub discount_percent: Option<f64>, + pub discount_amount: f64, + pub currency: String, +} + +#[typeshare] +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct OrderClassifiedTotal { + pub price_amount: f64, + pub price_currency: String, + pub quantity_amount: f64, + pub quantity_unit: String, +} diff --git a/src/utils/mod.rs b/crates/rhi/src/utils/mod.rs diff --git a/src/utils/nostr.rs b/crates/rhi/src/utils/nostr.rs diff --git a/src/utils/price.rs b/crates/rhi/src/utils/price.rs diff --git a/src/utils/unit.rs b/crates/rhi/src/utils/unit.rs diff --git a/src/events/job_request.rs b/src/events/job_request.rs @@ -1,339 +0,0 @@ -use std::time::Duration; - -use anyhow::Result; -use nostr::event::{Event, EventId, Tag, TagKind}; -use nostr::filter::{Alphabet, SingleLetterTag}; -use nostr::{event::Kind, key::Keys}; -use nostr_sdk::Client; -use nostr_sdk::RelayPoolNotification; -use tokio::time::sleep; -use tracing::{info, warn}; - -use crate::KIND_JOB_REQUEST; -use crate::handlers::job_request_order::{JobRequestOrderError, handle_job_request_order}; -use crate::handlers::job_request_preview::handle_job_request_preview; -use crate::handlers::job_request_quote::handle_job_request_quote; -use crate::utils::nostr::{ - NostrTagsResolveError, NostrUtilsError, nostr_event_job_feedback, nostr_filter_kind, - nostr_filter_new_events, nostr_tag_at_value, nostr_tag_first_value, nostr_tag_relays_parse, - nostr_tag_slice, nostr_tags_resolve, -}; -use crate::utils::unit::MassUnitError; - -#[derive(thiserror::Error, Debug)] -pub enum JobRequestError { - #[error("{0}")] - NostrUtilsError(#[from] NostrUtilsError), - - #[error("{0}")] - MassUnit(#[from] MassUnitError), - - #[error("{0}")] - NostrTagsResolve(#[from] NostrTagsResolveError), - - #[error("Order: {0}")] - JobRequestOrder(#[from] JobRequestOrderError), - - #[error("Invalid job request input type: {0}")] - InvalidInputType(String), - - #[error("Invalid job request input marker: {0}")] - InvalidInputMarker(String), - - #[error("Deserialization error: {0}")] - Serde(#[from] serde_json::Error), - - #[error("Failure to process request")] - Failure, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum JobRequestInputType { - Url, - Event, - Job, - Text, -} - -impl TryFrom<&str> for JobRequestInputType { - type Error = JobRequestError; - - fn try_from(s: &str) -> Result<Self, Self::Error> { - match s { - "url" => Ok(Self::Url), - "event" => Ok(Self::Event), - "job" => Ok(Self::Job), - "text" => Ok(Self::Text), - other => Err(JobRequestError::InvalidInputType(other.to_string())), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum JobRequestInputMarker { - Order, - Quote, - Preview, -} - -impl TryFrom<&str> for JobRequestInputMarker { - type Error = JobRequestError; - - fn try_from(s: &str) -> Result<Self, Self::Error> { - match s { - "order" => Ok(Self::Order), - "quote" => Ok(Self::Quote), - "preview" => Ok(Self::Preview), - other => Err(JobRequestError::InvalidInputMarker(other.to_string())), - } - } -} - -#[derive(Debug, Clone)] -pub struct JobRequestInput { - pub data: String, - pub input_type: JobRequestInputType, - pub relay: Option<String>, - pub marker: Option<JobRequestInputMarker>, -} - -#[derive(Debug, Clone)] -pub struct JobRequest { - pub id: EventId, - pub inputs: Vec<JobRequestInput>, - pub output: Option<String>, - pub bid_msat: Option<u64>, - pub relays: Vec<String>, - pub service_providers: Vec<String>, - pub params: Vec<(String, String)>, - pub hashtags: Vec<String>, - pub tags: Vec<Tag>, -} - -pub async fn subscriber(keys: Keys, relays: Vec<String>) -> Result<()> { - info!("Starting subscriber for kind {}", KIND_JOB_REQUEST); - let client = Client::new(keys.clone()); - - for relay in &relays { - client.add_relay(relay).await?; - } - - let filter = nostr_filter_new_events(nostr_filter_kind(KIND_JOB_REQUEST)); - - client.connect().await; - client.subscribe(filter, None).await?; - - let mut notifications = client.notifications(); - - while let Ok(n) = notifications.recv().await { - if let RelayPoolNotification::Event { event, .. } = n { - if event.kind == Kind::Custom(KIND_JOB_REQUEST) { - let event = (*event).clone(); - let keys = keys.clone(); - let client = client.clone(); - - tokio::spawn(async move { - if let Err(err) = - handle_event(event.clone(), keys.clone(), client.clone()).await - { - let _ = handle_error(err, event, keys, client, None).await; - } - }); - } - } - } - - client.disconnect().await; - - Ok(()) -} - -async fn handle_error( - error: JobRequestError, - event: Event, - _keys: Keys, - client: Client, - _job_req: Option<JobRequest>, -) -> Result<()> { - warn!("job_request handle_error error {}", error); - warn!("job_request handle_error event {:?}", { event.clone() }); - - let builder = nostr_event_job_feedback(&event, error, "error", None)?; - let event_id = client.send_event_builder(builder).await?; - - warn!("job_request handle_error sent feedback {:?}", { - event_id.clone() - }); - Ok(()) -} - -async fn handle_event(event: Event, keys: Keys, client: Client) -> Result<(), JobRequestError> { - let job_req = parse_event(&event, &keys)?; - for job_req_input in &job_req.inputs { - let marker = job_req_input - .marker - .as_ref() - .ok_or_else(|| JobRequestError::InvalidInputMarker(job_req.id.to_string()))?; - - match marker { - JobRequestInputMarker::Order => { - process_job_request( - handle_job_request_order, - event.clone(), - keys.clone(), - client.clone(), - job_req.clone(), - job_req_input.clone(), - ) - .await; - } - JobRequestInputMarker::Quote => { - process_job_request( - handle_job_request_quote, - event.clone(), - keys.clone(), - client.clone(), - job_req.clone(), - job_req_input.clone(), - ) - .await; - } - JobRequestInputMarker::Preview => { - process_job_request( - handle_job_request_preview, - event.clone(), - keys.clone(), - client.clone(), - job_req.clone(), - job_req_input.clone(), - ) - .await; - } - } - } - - Ok(()) -} - -fn parse_event(event: &Event, keys: &Keys) -> Result<JobRequest, JobRequestError> { - let tags = nostr_tags_resolve(event, keys)?; - let mut inputs = vec![]; - let mut output = None; - let mut bid_msat = None; - let mut relays = vec![]; - let mut providers = vec![]; - let mut params = vec![]; - let mut hashtags = vec![]; - - for tag in &tags { - match tag.kind() { - TagKind::SingleLetter(l) if l == SingleLetterTag::lowercase(Alphabet::I) => { - if let Some(vals) = nostr_tag_slice(tag, 1) { - match &vals[..] { - [data, input_type, relay, marker, ..] => { - let data = data.clone(); - let input_type = JobRequestInputType::try_from(input_type.as_str())?; - let relay = relay.clone(); - let marker = JobRequestInputMarker::try_from(marker.as_str())?; - inputs.push(JobRequestInput { - data, - input_type, - relay: Some(relay), - marker: Some(marker), - }); - } - _ => continue, - } - } - } - - TagKind::SingleLetter(l) if l == SingleLetterTag::lowercase(Alphabet::T) => { - if let Some(val) = nostr_tag_first_value(tag, "t") { - hashtags.push(val); - } - } - - TagKind::Custom(ref k) if k == "output" => { - output = nostr_tag_first_value(tag, k); - } - - TagKind::Custom(ref k) if k == "bid" => { - bid_msat = nostr_tag_first_value(tag, k).and_then(|s| s.parse().ok()); - } - - TagKind::Custom(k) if k == "param" => { - if let Some(vals) = nostr_tag_slice(tag, 1) { - if vals.len() >= 2 { - params.push((vals[0].clone(), vals[1].clone())); - } - } - } - - TagKind::Relays => { - if let Some(urls) = nostr_tag_relays_parse(tag) { - relays = urls.into_iter().map(|u| u.to_string()).collect(); - } - } - - TagKind::SingleLetter(l) if l == SingleLetterTag::lowercase(Alphabet::P) => { - if let Some(pk) = nostr_tag_at_value(tag, 1) { - providers.push(pk); - } - } - - _ => {} - } - } - - Ok(JobRequest { - id: event.id, - inputs, - output, - bid_msat, - relays, - service_providers: providers, - tags, - params, - hashtags, - }) -} - -async fn process_job_request<F, Fut>( - handler: F, - event: Event, - keys: Keys, - client: Client, - job_req: JobRequest, - job_req_input: JobRequestInput, -) where - F: FnOnce(Event, Keys, Client, JobRequest, JobRequestInput) -> Fut, - Fut: std::future::Future<Output = Result<(), JobRequestError>>, -{ - if cfg!(debug_assertions) { - sleep(Duration::from_millis(500)).await; - } - - let error_event = event.clone(); - let error_job_req = job_req.clone(); - let error_keys = keys.clone(); - let error_client = client.clone(); - - if let Err(err) = handler( - event, - keys.clone(), - client.clone(), - job_req.clone(), - job_req_input.clone(), - ) - .await - { - let _ = handle_error( - err, - error_event, - error_keys, - error_client, - Some(error_job_req), - ) - .await; - } -} diff --git a/src/keys.rs b/src/keys.rs @@ -1,201 +0,0 @@ -use anyhow::Result; -use nostr::{ - Event, Keys, - event::{EventBuilder, Kind, Tag, TagKind}, - nips::nip01::Metadata, -}; -use serde::{Deserialize, Serialize}; -use std::{ - fs::{self, File}, - io::{BufReader, Write}, - path::{Path, PathBuf}, - str::FromStr, -}; -use tempfile::NamedTempFile; -use thiserror::Error; -use tracing::{error, warn}; -use uuid::Uuid; - -#[cfg(unix)] -use std::os::unix::fs::PermissionsExt; - -use crate::{KIND_APPLICATION_HANDLER, KIND_JOB_REQUEST}; - -#[derive(Error, Debug)] -pub enum KeyProfileError { - #[error("Keys file does not exist at {0}")] - NotFound(PathBuf), - - #[error("Failed to open keys file at {0}: {1}")] - FileOpen(PathBuf, #[source] std::io::Error), - - #[error("Keys file already exists at {0}")] - AlreadyExists(PathBuf), - - #[error("Failed to parse keys file at {0}: {1}")] - FileParse(PathBuf, #[source] serde_json::Error), - - #[error("Failed to serialize keys: {0}")] - Serialization(#[from] serde_json::Error), - - #[error("IO error during key write: {0}")] - Io(#[from] std::io::Error), - - #[error("Failed to persist keys to disk: {0}")] - Persist(#[from] tempfile::PersistError), - - #[error("Failed to build or sign nostr event: {0}")] - NostrBuilder(#[from] nostr::event::builder::Error), - - #[error("Invalid secret key for identifier: {0}")] - InvalidSecretKey(String), -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct KeyProfile { - key: String, - identifier: String, - pub metadata: Option<Event>, - pub application_handler: Option<Event>, - - #[serde(skip)] - path: Option<PathBuf>, -} - -impl KeyProfile { - pub fn init<P: AsRef<str>>( - path_str: P, - generate: bool, - identifier_tag: Option<String>, - ) -> Result<Self, KeyProfileError> { - let path = PathBuf::from(path_str.as_ref()); - - if path.exists() { - let file = File::open(&path).map_err(|e| KeyProfileError::FileOpen(path.clone(), e))?; - let reader = BufReader::new(file); - let mut profile: KeyProfile = serde_json::from_reader(reader) - .map_err(|e| KeyProfileError::FileParse(path.clone(), e))?; - profile.path = Some(path.clone()); - - if !profile.identifier.trim().is_empty() { - if let Some(new_id) = identifier_tag { - warn!( - "Provided identifier '{}' is being ignored because the keys file already contains identifier '{}'.", - new_id, profile.identifier - ); - } - } else { - profile.identifier = identifier_tag.unwrap_or_else(|| { - warn!( - "Missing NIP-89 application handler identifier in key file, generating UUID." - ); - Uuid::new_v4().to_string() - }); - profile.persist()?; - } - - Ok(profile) - } else if generate { - let keys = Keys::generate(); - let secret = keys.secret_key(); - let identifier = match identifier_tag { - Some(identifier) => identifier, - None => { - warn!( - "Missing NIP-89 application handler identifier in key file, generating UUID." - ); - Uuid::new_v4().to_string() - } - }; - - let profile = KeyProfile { - key: secret.to_secret_hex(), - identifier, - metadata: None, - application_handler: None, - path: Some(path.clone()), - }; - - profile.atomic_write(&path)?; - Ok(profile) - } else { - Err(KeyProfileError::NotFound(path)) - } - } - - pub fn keys(&self) -> Result<Keys, KeyProfileError> { - Keys::from_str(&self.key) - .map_err(|_| KeyProfileError::InvalidSecretKey(self.identifier.clone())) - } - - fn atomic_write<P: AsRef<Path>>(&self, path: P) -> Result<(), KeyProfileError> { - let json = serde_json::to_string(self)?; - - let dir = path.as_ref().parent().unwrap_or_else(|| Path::new(".")); - let mut temp_file = NamedTempFile::new_in(dir)?; - - temp_file.write_all(json.as_bytes())?; - temp_file.as_file_mut().sync_all()?; - - #[cfg(unix)] - { - fs::set_permissions(temp_file.path(), fs::Permissions::from_mode(0o600))?; - } - - temp_file.persist(path)?; - Ok(()) - } - - fn persist(&self) -> Result<(), KeyProfileError> { - match &self.path { - Some(p) => self.atomic_write(p), - None => Err(KeyProfileError::NotFound(PathBuf::from("[unknown path]"))), - } - } - - pub async fn build_metadata( - &mut self, - metadata: &Metadata, - ) -> Result<Option<Event>, KeyProfileError> { - if self.metadata.is_none() { - let keys = self.keys()?; - let event = EventBuilder::metadata(metadata).sign(&keys).await?; - self.metadata = Some(event.clone()); - self.persist()?; - Ok(Some(event)) - } else { - Ok(None) - } - } - - pub async fn build_application_handler(&mut self) -> Result<Option<Event>, KeyProfileError> { - if self.application_handler.is_none() { - let keys = self.keys()?; - let kind_0_content = self - .metadata - .as_ref() - .expect(&format!( - "The kind 0 metadata must be initialized before kind {} descriptor", - KIND_APPLICATION_HANDLER.to_string() - )) - .content - .clone(); - - let tags: Vec<Tag> = vec![ - Tag::custom(TagKind::Custom("k".into()), [KIND_JOB_REQUEST.to_string()]), - Tag::identifier(self.identifier.to_string()), - ]; - - let event = EventBuilder::new(Kind::Custom(KIND_APPLICATION_HANDLER), kind_0_content) - .tags(tags) - .sign(&keys) - .await?; - - self.application_handler = Some(event.clone()); - self.persist()?; - Ok(Some(event)) - } else { - Ok(None) - } - } -} diff --git a/src/lib.rs b/src/lib.rs @@ -1,10 +0,0 @@ -pub mod config; -pub mod events; -pub mod handlers; -pub mod keys; -pub mod models; -pub mod utils; - -pub const KIND_JOB_REQUEST: u16 = 5300; -pub const KIND_JOB_RESPONSE: u16 = 6300; -pub const KIND_APPLICATION_HANDLER: u16 = 31990; diff --git a/src/models/order_classified.rs b/src/models/order_classified.rs @@ -1,45 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct OrderClassifiedResult { - pub quantity: OrderClassifiedQuantity, - pub price: OrderClassifiedPrice, - pub discounts: Vec<OrderClassifiedDiscount>, - pub subtotal: OrderClassifiedTotal, - pub total: OrderClassifiedTotal, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct OrderClassifiedQuantity { - pub amount: f64, - pub unit: String, - pub label: String, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct OrderClassifiedPrice { - pub amount: f64, - pub currency: String, - pub quantity_amount: f64, - pub quantity_unit: String, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct OrderClassifiedDiscount { - pub discount_type: String, - pub threshold: Option<f64>, - pub threshold_unit: Option<String>, - pub discount_per_unit: Option<f64>, - pub discount_unit: Option<String>, - pub discount_percent: Option<f64>, - pub discount_amount: f64, - pub currency: String, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct OrderClassifiedTotal { - pub price_amount: f64, - pub price_currency: String, - pub quantity_amount: f64, - pub quantity_unit: String, -}