commit 160e24603ea94affc98e788bbcf437c1254bece1
parent 023f017adbd475cbd91a69621b878675bf3cc0c9
Author: triesap <137732411+triesap@users.noreply.github.com>
Date: Mon, 14 Apr 2025 17:09:54 +0000
Adds reference event validation to job requests order handler. Creates NIP-99 classified events interface.
Diffstat:
10 files changed, 473 insertions(+), 32 deletions(-)
diff --git a/src/events/job_request.rs b/src/events/job_request.rs
@@ -7,25 +7,35 @@ use nostr_sdk::RelayPoolNotification;
use tracing::{info, warn};
use crate::KIND_JOB_REQUEST;
-use crate::handlers::job_request_order::handle_job_request_order;
+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::{
- nostr_event_job_request_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,
+ NostrTagsResolveError, nostr_event_job_request_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("Order request error: {0}")]
+ JobRequestOrder(#[from] JobRequestOrderError),
+
+ #[error("Mass unit error: {0}")]
+ MassUnit(#[from] MassUnitError),
+
+ #[error("Mass unit error: {0}")]
+ NostrTagsResolve(#[from] NostrTagsResolveError),
+
#[error("Invalid job request input type: {0}")]
InvalidInputType(String),
#[error("Invalid job request input marker: {0}")]
InvalidInputMarker(String),
- #[error("Failure to resolve event tags: {0}")]
- TagResolution(#[from] anyhow::Error),
+ #[error("Deserialization error: {0}")]
+ Serde(#[from] serde_json::Error),
#[error("Failure to process request")]
Failure,
@@ -135,9 +145,9 @@ pub async fn subscriber(keys: Keys, relays: Vec<String>) -> Result<()> {
async fn handle_error(
error: JobRequestError,
event: Event,
- keys: Keys,
+ _keys: Keys,
client: Client,
- job_req: Option<JobRequest>,
+ _job_req: Option<JobRequest>,
) -> Result<()> {
warn!("job_request handle_error error {}", error);
warn!("job_request handle_error event {:?}", { event.clone() });
@@ -153,8 +163,8 @@ async fn handle_error(
async fn handle_event(event: Event, keys: Keys, client: Client) -> Result<(), JobRequestError> {
let job_req = parse_event(&event, &keys)?;
- for input in &job_req.inputs {
- let marker = input
+ 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()))?;
@@ -164,9 +174,10 @@ async fn handle_event(event: Event, keys: Keys, client: Client) -> Result<(), Jo
process_job_request(
handle_job_request_order,
event.clone(),
- job_req.clone(),
keys.clone(),
client.clone(),
+ job_req.clone(),
+ job_req_input.clone(),
)
.await;
}
@@ -174,9 +185,10 @@ async fn handle_event(event: Event, keys: Keys, client: Client) -> Result<(), Jo
process_job_request(
handle_job_request_quote,
event.clone(),
- job_req.clone(),
keys.clone(),
client.clone(),
+ job_req.clone(),
+ job_req_input.clone(),
)
.await;
}
@@ -184,9 +196,10 @@ async fn handle_event(event: Event, keys: Keys, client: Client) -> Result<(), Jo
process_job_request(
handle_job_request_preview,
event.clone(),
- job_req.clone(),
keys.clone(),
client.clone(),
+ job_req.clone(),
+ job_req_input.clone(),
)
.await;
}
@@ -197,7 +210,7 @@ async fn handle_event(event: Event, keys: Keys, client: Client) -> Result<(), Jo
}
fn parse_event(event: &Event, keys: &Keys) -> Result<JobRequest, JobRequestError> {
- let tags = nostr_tags_resolve(event, keys).map_err(JobRequestError::TagResolution)?;
+ let tags = nostr_tags_resolve(event, keys)?;
let mut inputs = vec![];
let mut output = None;
let mut bid_msat = None;
@@ -282,11 +295,12 @@ fn parse_event(event: &Event, keys: &Keys) -> Result<JobRequest, JobRequestError
async fn process_job_request<F, Fut>(
handler: F,
event: Event,
- job_req: JobRequest,
keys: Keys,
client: Client,
+ job_req: JobRequest,
+ job_req_input: JobRequestInput,
) where
- F: FnOnce(Event, JobRequest, Keys, Client) -> Fut,
+ F: FnOnce(Event, Keys, Client, JobRequest, JobRequestInput) -> Fut,
Fut: std::future::Future<Output = Result<(), JobRequestError>>,
{
let error_event = event.clone();
@@ -294,7 +308,15 @@ async fn process_job_request<F, Fut>(
let error_keys = keys.clone();
let error_client = client.clone();
- if let Err(err) = handler(event, job_req.clone(), keys.clone(), client.clone()).await {
+ if let Err(err) = handler(
+ event,
+ keys.clone(),
+ client.clone(),
+ job_req.clone(),
+ job_req_input.clone(),
+ )
+ .await
+ {
let _ = handle_error(
err,
error_event,
diff --git a/src/handlers/job_request_order.rs b/src/handlers/job_request_order.rs
@@ -1,17 +1,93 @@
use anyhow::Result;
use nostr::{event::Event, key::Keys};
use nostr_sdk::Client;
+use serde::Deserialize;
+use thiserror::Error;
use tracing::info;
-use crate::events::job_request::{JobRequest, JobRequestError};
+use crate::{
+ events::job_request::{JobRequest, JobRequestError, JobRequestInput},
+ models::event_classified::EventClassified,
+ utils::nostr::nostr_fetch_event_by_id,
+};
+
+#[derive(Debug, Error)]
+pub enum JobRequestOrderError {
+ #[error("Failure to parse the reference event {0}")]
+ ReferenceEventParse(String),
+
+ #[error("Failure to fetch the reference event {0}")]
+ ReferenceEventFetch(String),
+
+ #[error("Reference event not found {0}")]
+ ReferenceEventMissing(String),
+}
+
+#[derive(Debug, Deserialize)]
+pub struct JobRequestOrderDataQuantity {
+ pub amount: f64,
+ pub unit: String,
+ pub count: u32,
+ pub mass_g: f64,
+ pub label: String,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct JobRequestOrderDataPrice {
+ pub amount: f64,
+ pub currency: String,
+ pub quantity_amount: f64,
+ pub quantity_unit: String,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct JobRequestOrderDataOrder {
+ pub price: JobRequestOrderDataPrice,
+ pub quantity: JobRequestOrderDataQuantity,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct JobRequestOrderDataEvent {
+ pub id: String,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct JobRequestOrderData {
+ pub event: JobRequestOrderDataEvent,
+ pub order: JobRequestOrderDataOrder,
+}
pub async fn handle_job_request_order(
- event: Event,
- job_req: JobRequest,
- keys: Keys,
+ _event: Event,
+ _keys: Keys,
client: Client,
+ _job_req: JobRequest,
+ job_req_input: JobRequestInput,
) -> Result<(), JobRequestError> {
- info!("handle_job_request_order job_req: {:?}", job_req);
+ let order_data: JobRequestOrderData = serde_json::from_str(&job_req_input.data)?;
+
+ info!("handle_job_request_order order_data: {:?}", order_data);
+
+ let fetched_ref_event: Option<Event> =
+ nostr_fetch_event_by_id(client.clone(), &order_data.event.id.clone())
+ .await
+ .map_err(|_| JobRequestOrderError::ReferenceEventFetch(order_data.event.id.clone()))?;
+ let ref_event: &Event =
+ fetched_ref_event
+ .as_ref()
+ .ok_or(JobRequestOrderError::ReferenceEventMissing(
+ order_data.event.id.clone(),
+ ))?;
+
+ info!("handle_job_request_order ref_event: {:?}", ref_event);
+
+ let ref_classified = EventClassified::from_event(ref_event)
+ .map_err(|_| JobRequestOrderError::ReferenceEventParse(order_data.event.id.clone()))?;
+
+ info!(
+ "handle_job_request_order ref_classified: {:?}",
+ ref_classified
+ );
Ok(())
}
diff --git a/src/handlers/job_request_preview.rs b/src/handlers/job_request_preview.rs
@@ -3,13 +3,14 @@ use nostr::{event::Event, key::Keys};
use nostr_sdk::Client;
use tracing::info;
-use crate::events::job_request::{JobRequest, JobRequestError};
+use crate::events::job_request::{JobRequest, JobRequestError, JobRequestInput};
pub async fn handle_job_request_preview(
- event: Event,
+ _event: Event,
+ _keys: Keys,
+ _client: Client,
job_req: JobRequest,
- keys: Keys,
- client: Client,
+ _job_req_input: JobRequestInput,
) -> Result<(), JobRequestError> {
info!("handle_job_request_preview job_req: {:?}", job_req);
diff --git a/src/handlers/job_request_quote.rs b/src/handlers/job_request_quote.rs
@@ -3,13 +3,14 @@ use nostr::{event::Event, key::Keys};
use nostr_sdk::Client;
use tracing::info;
-use crate::events::job_request::{JobRequest, JobRequestError};
+use crate::events::job_request::{JobRequest, JobRequestError, JobRequestInput};
pub async fn handle_job_request_quote(
- event: Event,
+ _event: Event,
+ _keys: Keys,
+ _client: Client,
job_req: JobRequest,
- keys: Keys,
- client: Client,
+ _job_req_input: JobRequestInput,
) -> Result<(), JobRequestError> {
info!("handle_job_request_quote job_req: {:?}", job_req);
diff --git a/src/lib.rs b/src/lib.rs
@@ -2,6 +2,7 @@ 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;
diff --git a/src/models/event_classified.rs b/src/models/event_classified.rs
@@ -0,0 +1,204 @@
+use anyhow::Result;
+use nostr::{EventId, event::Event};
+use serde::{Deserialize, Serialize};
+
+use crate::utils::{
+ nostr::{
+ nostr_tag_match_geohash, nostr_tag_match_l, nostr_tag_match_location,
+ nostr_tag_match_summary, nostr_tag_match_title, nostr_tags_match,
+ },
+ unit::MassUnit,
+};
+
+#[derive(Debug, Serialize, Deserialize, Clone, Default)]
+pub struct EventClassifiedGeolocation {
+ pub geohash: Option<String>,
+ pub lat: f64,
+ pub lng: f64,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone, Default)]
+pub struct EventClassifiedLocation {
+ pub address: String,
+ pub region: String,
+ pub country: String,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct EventClassifiedQuantity {
+ pub amount: f64,
+ pub unit: MassUnit,
+ pub label: String,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct EventClassifiedPrice {
+ pub amount: f64,
+ pub currency: String,
+ pub quantity_amount: f64,
+ pub quantity_unit: MassUnit,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone, Default)]
+pub struct EventClassifiedListing {
+ pub key: String,
+ pub category: String,
+ pub process: Option<String>,
+ pub lot: Option<String>,
+ pub profile: Option<String>,
+ pub year: Option<String>,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone, Default)]
+pub struct EventClassifiedBasis {
+ pub title: String,
+ pub summary: String,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct EventClassified {
+ pub id: EventId,
+ pub basis: EventClassifiedBasis,
+ pub listing: EventClassifiedListing,
+ pub prices: Vec<EventClassifiedPrice>,
+ pub quantities: Vec<EventClassifiedQuantity>,
+ pub location: Option<EventClassifiedLocation>,
+ pub geolocation: Option<EventClassifiedGeolocation>,
+}
+
+impl EventClassified {
+ pub fn from_event(event: &Event) -> Result<Self> {
+ let mut prices = Vec::new();
+ let mut quantities = Vec::new();
+ let mut basis = EventClassifiedBasis::default();
+ let mut listing = EventClassifiedListing::default();
+
+ let mut address: Option<String> = None;
+ let mut region: Option<String> = None;
+ let mut country: Option<String> = None;
+ let mut lat: Option<f64> = None;
+ let mut lng: Option<f64> = None;
+ let mut geohash: Option<String> = None;
+
+ for tag in event.tags.iter() {
+ if let Some((key, values)) = nostr_tags_match(tag) {
+ match key {
+ "quantity" if values.len() >= 3 => {
+ let amount_str = &values[0];
+ let unit_str = &values[1];
+ let label = &values[2];
+
+ if let (Ok(amount), Ok(unit)) =
+ (amount_str.parse::<f64>(), unit_str.parse::<MassUnit>())
+ {
+ quantities.push(EventClassifiedQuantity {
+ amount,
+ unit,
+ label: label.clone(),
+ });
+ }
+ }
+ "price" if values.len() >= 4 => {
+ let amount_str = &values[0];
+ let currency = &values[1];
+ let quantity_amount_str = &values[2];
+ let quantity_unit_str = &values[3];
+
+ if let (Ok(amount), Ok(quantity_amount), Ok(quantity_unit)) = (
+ amount_str.parse::<f64>(),
+ quantity_amount_str.parse::<f64>(),
+ quantity_unit_str.to_lowercase().parse::<MassUnit>(),
+ ) {
+ prices.push(EventClassifiedPrice {
+ amount,
+ currency: currency.clone(),
+ quantity_amount,
+ quantity_unit,
+ });
+ }
+ }
+ "key" if !values.is_empty() => listing.key = values[0].clone(),
+ "category" if !values.is_empty() => listing.category = values[0].clone(),
+ "process" if !values.is_empty() => listing.process = Some(values[0].clone()),
+ "lot" if !values.is_empty() => listing.lot = Some(values[0].clone()),
+ "profile" if !values.is_empty() => listing.profile = Some(values[0].clone()),
+ "year" if !values.is_empty() => listing.year = Some(values[0].clone()),
+ _ => {}
+ }
+ }
+
+ if let Some((kind, value)) = nostr_tag_match_l(tag) {
+ let precision = value.to_string().split('.').nth(1).map_or(0, |s| s.len());
+
+ match kind {
+ "dd.lat" => {
+ let current_precision = lat
+ .map(|v| v.to_string().split('.').nth(1).map_or(0, |s| s.len()))
+ .unwrap_or(0);
+ if precision > current_precision {
+ lat = Some(value);
+ }
+ }
+ "dd.lon" => {
+ let current_precision = lng
+ .map(|v| v.to_string().split('.').nth(1).map_or(0, |s| s.len()))
+ .unwrap_or(0);
+ if precision > current_precision {
+ lng = Some(value);
+ }
+ }
+ _ => {}
+ }
+ }
+
+ if let Some((addr, reg, coun)) = nostr_tag_match_location(tag) {
+ address = Some(addr.to_string());
+ region = Some(reg.to_string());
+ country = Some(coun.to_string());
+ }
+
+ if let Some(g) = nostr_tag_match_geohash(tag) {
+ if geohash
+ .as_ref()
+ .map_or(true, |current| g.len() > current.len())
+ {
+ geohash = Some(g);
+ }
+ }
+
+ if let Some(title) = nostr_tag_match_title(tag) {
+ basis.title = title;
+ }
+
+ if let Some(summary) = nostr_tag_match_summary(tag) {
+ basis.summary = summary;
+ }
+ }
+
+ let location = if address.is_some() || region.is_some() || country.is_some() {
+ Some(EventClassifiedLocation {
+ address: address.unwrap_or_default(),
+ region: region.unwrap_or_default(),
+ country: country.unwrap_or_default(),
+ })
+ } else {
+ None
+ };
+
+ let geolocation = if let (Some(lat), Some(lng)) = (lat, lng) {
+ Some(EventClassifiedGeolocation { geohash, lat, lng })
+ } else {
+ None
+ };
+
+ Ok(Self {
+ id: event.id,
+ basis,
+ listing,
+ prices,
+ quantities,
+ location,
+ geolocation,
+ })
+ }
+}
diff --git a/src/models/mod.rs b/src/models/mod.rs
@@ -0,0 +1 @@
+pub mod event_classified;
diff --git a/src/utils/mod.rs b/src/utils/mod.rs
@@ -1 +1,2 @@
pub mod nostr;
+pub mod unit;
diff --git a/src/utils/nostr.rs b/src/utils/nostr.rs
@@ -1,6 +1,8 @@
+use std::borrow::Cow;
+
use anyhow::Result;
use nostr::{
- event::{Event, EventBuilder, Kind, Tag, TagKind, TagStandard},
+ event::{Event, EventBuilder, EventId, Kind, Tag, TagKind, TagStandard},
filter::Filter,
key::{Keys, PublicKey},
nips::{
@@ -9,6 +11,8 @@ use nostr::{
},
types::{RelayUrl, Timestamp},
};
+use nostr_sdk::Client;
+use nostr_sdk::RelayPoolNotification;
use thiserror::Error;
use crate::events::job_request::JobRequestError;
@@ -48,6 +52,57 @@ pub fn nostr_tag_relays_parse(tag: &Tag) -> Option<&Vec<RelayUrl>> {
}
}
+pub fn nostr_tags_match<'a>(tag: &'a Tag) -> Option<(&'a str, &'a [String])> {
+ if let TagKind::Custom(Cow::Borrowed(key)) = tag.kind() {
+ Some((key, &tag.as_slice()[1..]))
+ } else {
+ None
+ }
+}
+
+pub fn nostr_tag_match_l(tag: &Tag) -> Option<(&str, f64)> {
+ let values = tag.as_slice();
+
+ if values.len() >= 3 && values[0].eq_ignore_ascii_case("l") {
+ if let Ok(value) = values[1].parse::<f64>() {
+ return Some((values[2].as_str(), value));
+ }
+ }
+
+ None
+}
+
+pub fn nostr_tag_match_location(tag: &Tag) -> Option<(&str, &str, &str)> {
+ let values = tag.as_slice();
+
+ if values.len() >= 4 && values[0] == "location" {
+ Some((values[1].as_str(), values[2].as_str(), values[3].as_str()))
+ } else {
+ None
+ }
+}
+
+pub fn nostr_tag_match_geohash(tag: &Tag) -> Option<String> {
+ match tag.as_standardized()? {
+ TagStandard::Geohash(geohash) => Some(geohash.clone()),
+ _ => None,
+ }
+}
+
+pub fn nostr_tag_match_title(tag: &Tag) -> Option<String> {
+ match tag.as_standardized()? {
+ TagStandard::Title(title) => Some(title.clone()),
+ _ => None,
+ }
+}
+
+pub fn nostr_tag_match_summary(tag: &Tag) -> Option<String> {
+ match tag.as_standardized()? {
+ TagStandard::Summary(summary) => Some(summary.clone()),
+ _ => None,
+ }
+}
+
pub fn nostr_event_job_request_feedback(
event: &Event,
error: JobRequestError,
@@ -62,6 +117,25 @@ pub fn nostr_event_job_request_feedback(
Ok(builder)
}
+pub async fn nostr_fetch_event_by_id(client: Client, id: &str) -> Result<Option<Event>> {
+ let event_id = EventId::from_hex(id)?;
+ let filter = Filter::new().id(event_id);
+
+ 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.id == event_id {
+ return Ok(Some(*event));
+ }
+ }
+ }
+
+ Ok(None)
+}
+
#[derive(Debug, Error)]
pub enum NostrTagsResolveError {
#[error("Missing public key tag in encrypted event: {0:?}")]
@@ -77,7 +151,7 @@ pub enum NostrTagsResolveError {
ParseError(#[from] serde_json::Error),
}
-pub fn nostr_tags_resolve(event: &Event, keys: &Keys) -> Result<Vec<Tag>> {
+pub fn nostr_tags_resolve(event: &Event, keys: &Keys) -> Result<Vec<Tag>, NostrTagsResolveError> {
if event.tags.iter().any(|t| t.kind() == TagKind::Encrypted) {
let recipient = event
.tags
diff --git a/src/utils/unit.rs b/src/utils/unit.rs
@@ -0,0 +1,60 @@
+use serde::{Deserialize, Serialize};
+use std::{fmt, str::FromStr};
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+pub enum MassUnitError {
+ #[error("Invalid mass unit: {0}")]
+ InvalidUnit(String),
+
+ #[error("Invalid mass amount: {0}")]
+ InvalidAmount(f64),
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone)]
+#[serde(rename_all = "lowercase")]
+pub enum MassUnit {
+ G,
+ Kg,
+ Lb,
+}
+
+impl MassUnit {
+ pub fn to_grams(&self, amount: f64) -> Result<f64, MassUnitError> {
+ if !amount.is_finite() || amount.is_nan() {
+ return Err(MassUnitError::InvalidAmount(amount));
+ }
+
+ let grams = match self {
+ MassUnit::G => amount,
+ MassUnit::Kg => amount * 1000.0,
+ MassUnit::Lb => amount * 453.592,
+ };
+
+ Ok(grams)
+ }
+}
+
+impl fmt::Display for MassUnit {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let unit_str = match self {
+ MassUnit::G => "g",
+ MassUnit::Kg => "kg",
+ MassUnit::Lb => "lb",
+ };
+ write!(f, "{unit_str}")
+ }
+}
+
+impl FromStr for MassUnit {
+ type Err = MassUnitError;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s {
+ "g" => Ok(MassUnit::G),
+ "kg" => Ok(MassUnit::Kg),
+ "lb" => Ok(MassUnit::Lb),
+ other => Err(MassUnitError::InvalidUnit(other.to_string())),
+ }
+ }
+}