diff --git a/Cargo.lock b/Cargo.lock index 7eaddce..9afd76f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -147,6 +147,28 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-utility" version = "0.3.1" @@ -977,6 +999,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.31" @@ -1036,6 +1064,12 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "gloo-timers" version = "0.3.0" @@ -1728,21 +1762,23 @@ dependencies = [ "log", "mostro-core", "nostr-sdk", - "openssl", "pretty_env_logger", "reqwest", + "rstest", "serde", "serde_json", + "serial_test", "sqlx", "tokio", + "tokio-test", "uuid", ] [[package]] name = "mostro-core" -version = "0.6.49" +version = "0.6.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "628171bedc33fbc0b5b7c783566047541954155776ff2e41cbbea517657cf9c5" +checksum = "c8bf32904269e30059c5354a3f379a90ebb6afa7355995d624ec6403062ccd47" dependencies = [ "argon2", "base64 0.22.1", @@ -1948,15 +1984,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" -[[package]] -name = "openssl-src" -version = "300.4.2+3.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "168ce4e058f975fe43e89d9ccf78ca668601887ae736090aacc23ae353c298e2" -dependencies = [ - "cc", -] - [[package]] name = "openssl-sys" version = "0.9.109" @@ -1965,7 +1992,6 @@ checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" dependencies = [ "cc", "libc", - "openssl-src", "pkg-config", "vcpkg", ] @@ -2110,6 +2136,15 @@ dependencies = [ "log", ] +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.92" @@ -2242,6 +2277,12 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "reqwest" version = "0.12.23" @@ -2317,12 +2358,50 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] + [[package]] name = "rustc-demangle" version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.42" @@ -2423,6 +2502,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "schannel" version = "0.1.27" @@ -2460,6 +2548,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "secp256k1" version = "0.29.1" @@ -2513,20 +2607,36 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" -version = "1.0.219" +version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.225" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" dependencies = [ "proc-macro2", "quote", @@ -2557,6 +2667,31 @@ dependencies = [ "serde", ] +[[package]] +name = "serial_test" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" +dependencies = [ + "futures", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3093,6 +3228,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-test" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +dependencies = [ + "async-stream", + "bytes", + "futures-core", + "tokio", + "tokio-stream", +] + [[package]] name = "tokio-tungstenite" version = "0.26.2" @@ -3122,6 +3270,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a197c0ec7d131bfc6f7e82c8442ba1595aeab35da7adbf05b6b73cd06a16b6be" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2ad0b7ae9cfeef5605163839cb9221f453399f15cfb5c10be9885fcf56611f9" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" +dependencies = [ + "winnow", +] + [[package]] name = "tower" version = "0.5.2" @@ -3736,6 +3914,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" diff --git a/Cargo.toml b/Cargo.toml index 37d52ea..3509092 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,10 +39,15 @@ uuid = { version = "1.18.1", features = [ dotenvy = "0.15.6" lightning-invoice = { version = "0.33.2", features = ["std"] } reqwest = { version = "0.12.23", features = ["json"] } -mostro-core = "0.6.49" +mostro-core = "0.6.50" lnurl-rs = "0.9.0" pretty_env_logger = "0.5.0" openssl = { version = "0.10.73", features = ["vendored"] } sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio-native-tls"] } bip39 = { version = "2.2.0", features = ["rand"] } dirs = "6.0.0" + +[dev-dependencies] +tokio-test = "0.4" +serial_test = "3.1" +rstest = "0.26.1" \ No newline at end of file diff --git a/src/cli.rs b/src/cli.rs index 5d7b70a..c123706 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -11,9 +11,8 @@ pub mod rate_user; pub mod restore; pub mod send_dm; pub mod send_msg; -pub mod take_buy; pub mod take_dispute; -pub mod take_sell; +pub mod take_order; use crate::cli::add_invoice::execute_add_invoice; use crate::cli::adm_send_dm::execute_adm_send_dm; @@ -27,16 +26,16 @@ use crate::cli::new_order::execute_new_order; use crate::cli::rate_user::execute_rate_user; use crate::cli::restore::execute_restore; use crate::cli::send_dm::execute_send_dm; -use crate::cli::send_msg::execute_send_msg; -use crate::cli::take_buy::execute_take_buy; use crate::cli::take_dispute::execute_take_dispute; -use crate::cli::take_sell::execute_take_sell; +use crate::cli::take_order::execute_take_order; use crate::db::{connect, User}; use crate::util; use anyhow::{Error, Result}; use clap::{Parser, Subcommand}; +use mostro_core::prelude::*; use nostr_sdk::prelude::*; +use sqlx::SqlitePool; use std::{ env::{set_var, var}, str::FromStr, @@ -44,6 +43,17 @@ use std::{ use take_dispute::*; use uuid::Uuid; +#[derive(Debug)] +pub struct Context { + pub client: Client, + pub identity_keys: Keys, + pub trade_keys: Keys, + pub trade_index: i64, + pub pool: SqlitePool, + pub context_keys: Keys, + pub mostro_pubkey: PublicKey, +} + #[derive(Parser)] #[command( name = "mostro-cli", @@ -161,7 +171,7 @@ pub enum Commands { #[clap(default_value_t = 30)] since: i64, /// If true, get messages from counterparty, otherwise from Mostro - #[arg(short)] + #[arg(short, long)] from_user: bool, }, /// Get direct messages sent to any trade keys @@ -178,7 +188,7 @@ pub enum Commands { #[clap(default_value_t = 30)] since: i64, /// If true, get messages from counterparty, otherwise from Mostro - #[arg(short)] + #[arg(short, long)] from_user: bool, }, /// Send direct message to a user @@ -283,12 +293,34 @@ pub enum Commands { }, } +fn get_env_var(cli: &Cli) { + // Init logger + if cli.verbose { + set_var("RUST_LOG", "info"); + pretty_env_logger::init(); + } + + if let Some(ref mostro_pubkey) = cli.mostropubkey { + set_var("MOSTRO_PUBKEY", mostro_pubkey.clone()); + } + let _pubkey = var("MOSTRO_PUBKEY").expect("$MOSTRO_PUBKEY env var needs to be set"); + + if let Some(ref relays) = cli.relays { + set_var("RELAYS", relays.clone()); + } + + if let Some(ref pow) = cli.pow { + set_var("POW", pow.clone()); + } + + if cli.secret { + set_var("SECRET", "true"); + } +} + // Check range with two values value fn check_fiat_range(s: &str) -> Result<(i64, Option)> { if s.contains('-') { - let min: i64; - let max: i64; - // Get values from CLI let values: Vec<&str> = s.split('-').collect(); @@ -298,17 +330,12 @@ fn check_fiat_range(s: &str) -> Result<(i64, Option)> { }; // Get ranged command - if let Err(e) = values[0].parse::() { - return Err(e.into()); - } else { - min = values[0].parse().unwrap(); - } - - if let Err(e) = values[1].parse::() { - return Err(e.into()); - } else { - max = values[1].parse().unwrap(); - } + let min = values[0] + .parse::() + .map_err(|e| anyhow::anyhow!("Invalid min value: {}", e))?; + let max = values[1] + .parse::() + .map_err(|e| anyhow::anyhow!("Invalid max value: {}", e))?; // Check min below max if min >= max { @@ -326,118 +353,106 @@ fn check_fiat_range(s: &str) -> Result<(i64, Option)> { pub async fn run() -> Result<()> { let cli = Cli::parse(); - // Init logger - if cli.verbose { - set_var("RUST_LOG", "info"); - pretty_env_logger::init(); - } + let ctx = init_context(&cli).await?; - if cli.mostropubkey.is_some() { - set_var("MOSTRO_PUBKEY", cli.mostropubkey.unwrap()); + if let Some(cmd) = &cli.command { + cmd.run(&ctx).await?; } - let pubkey = var("MOSTRO_PUBKEY").expect("$MOSTRO_PUBKEY env var needs to be set"); - if cli.relays.is_some() { - set_var("RELAYS", cli.relays.unwrap()); - } + println!("Bye Bye!"); - if cli.pow.is_some() { - set_var("POW", cli.pow.unwrap()); - } + Ok(()) +} - if cli.secret { - set_var("SECRET", "true"); - } +async fn init_context(cli: &Cli) -> Result { + // Get environment variables + get_env_var(cli); + // Initialize database pool let pool = connect().await?; + + // Get identity keys let identity_keys = User::get_identity_keys(&pool) .await .map_err(|e| anyhow::anyhow!("Failed to get identity keys: {}", e))?; + // Get trade keys let (trade_keys, trade_index) = User::get_next_trade_keys(&pool) .await .map_err(|e| anyhow::anyhow!("Failed to get trade keys: {}", e))?; - // Mostro pubkey - let mostro_key = PublicKey::from_str(&pubkey)?; + // Load private key of user or admin - must be present in .env file + let context_keys = std::env::var("NSEC_PRIVKEY") + .map_err(|e| anyhow::anyhow!("NSEC_PRIVKEY not set: {}", e))? + .parse::() + .map_err(|e| anyhow::anyhow!("Failed to get context keys: {}", e))?; + + // Resolve Mostro pubkey from env (required for all flows) + let mostro_pubkey = PublicKey::from_str( + &std::env::var("MOSTRO_PUBKEY") + .map_err(|e| anyhow::anyhow!("Failed to get MOSTRO_PUBKEY: {}", e))?, + )?; - // Call function to connect to relays + // Connect to Nostr relays let client = util::connect_nostr().await?; - if let Some(cmd) = cli.command { - match &cmd { - Commands::ConversationKey { pubkey } => { - execute_conversation_key(&trade_keys, PublicKey::from_str(pubkey)?).await? + Ok(Context { + client, + identity_keys, + trade_keys, + trade_index, + pool, + context_keys, + mostro_pubkey, + }) +} + +impl Commands { + pub async fn run(&self, ctx: &Context) -> Result<()> { + match self { + // Simple order message commands + Commands::FiatSent { order_id } + | Commands::Release { order_id } + | Commands::Dispute { order_id } + | Commands::Cancel { order_id } => { + crate::util::run_simple_order_msg(self.clone(), order_id, ctx).await } - Commands::ListOrders { - status, - currency, - kind, - } => execute_list_orders(kind, currency, status, mostro_key, &client).await?, - Commands::TakeSell { + + // DM commands with pubkey parsing + Commands::SendDm { + pubkey, order_id, - invoice, - amount, + message, } => { - execute_take_sell( - order_id, - invoice, - *amount, - &identity_keys, - &trade_keys, - trade_index, - mostro_key, - &client, - ) - .await? + execute_send_dm(PublicKey::from_str(pubkey)?, &ctx.client, order_id, message).await } - Commands::TakeBuy { order_id, amount } => { - execute_take_buy( + Commands::DmToUser { + pubkey, + order_id, + message, + } => { + execute_dm_to_user( + PublicKey::from_str(pubkey)?, + &ctx.client, order_id, - *amount, - &identity_keys, - &trade_keys, - trade_index, - mostro_key, - &client, + message, + &ctx.pool, ) - .await? + .await } - Commands::AddInvoice { order_id, invoice } => { - execute_add_invoice(order_id, invoice, &identity_keys, mostro_key, &client).await? - } - Commands::GetDm { since, from_user } => { - execute_get_dm(since, trade_index, &client, *from_user, false, &mostro_key).await? - } - Commands::GetDmUser { since } => { - execute_get_dm_user(since, &client, &mostro_key).await? - } - Commands::GetAdminDm { since, from_user } => { - execute_get_dm(since, trade_index, &client, *from_user, true, &mostro_key).await? - } - Commands::FiatSent { order_id } - | Commands::Release { order_id } - | Commands::Dispute { order_id } - | Commands::Cancel { order_id } => { - execute_send_msg( - cmd.clone(), - Some(*order_id), - Some(&identity_keys), - mostro_key, - &client, - None, - ) - .await? + Commands::AdmSendDm { pubkey, message } => { + execute_adm_send_dm(PublicKey::from_str(pubkey)?, &ctx.client, message).await } - Commands::AdmAddSolver { npubkey } => { - let id_key = match std::env::var("NSEC_PRIVKEY") { - Ok(id_key) => Keys::parse(&id_key)?, - Err(e) => { - anyhow::bail!("NSEC_PRIVKEY not set: {e}"); - } - }; - execute_admin_add_solver(npubkey, &id_key, &trade_keys, mostro_key, &client).await? + Commands::ConversationKey { pubkey } => { + execute_conversation_key(&ctx.trade_keys, PublicKey::from_str(pubkey)?).await } + + // Order management commands + Commands::ListOrders { + status, + currency, + kind, + } => execute_list_orders(kind, currency, status, ctx).await, Commands::NewOrder { kind, fiat_code, @@ -456,76 +471,44 @@ pub async fn run() -> Result<()> { payment_method, premium, invoice, - &identity_keys, - &trade_keys, - trade_index, - mostro_key, - &client, + ctx, expiration_days, ) - .await? - } - Commands::Rate { order_id, rating } => { - execute_rate_user(order_id, rating, &identity_keys, mostro_key, &client).await?; - } - Commands::Restore {} => { - execute_restore(&identity_keys, mostro_key, &client).await?; - } - Commands::AdmSettle { order_id } => { - let id_key = match std::env::var("NSEC_PRIVKEY") { - Ok(id_key) => Keys::parse(&id_key)?, - Err(e) => { - anyhow::bail!("NSEC_PRIVKEY not set: {e}"); - } - }; - execute_admin_settle_dispute(order_id, &id_key, &trade_keys, mostro_key, &client) - .await?; + .await } - Commands::AdmCancel { order_id } => { - let id_key = match std::env::var("NSEC_PRIVKEY") { - Ok(id_key) => Keys::parse(&id_key)?, - Err(e) => { - anyhow::bail!("NSEC_PRIVKEY not set: {e}"); - } - }; - execute_admin_cancel_dispute(order_id, &id_key, &trade_keys, mostro_key, &client) - .await?; - } - Commands::AdmTakeDispute { dispute_id } => { - let id_key = match std::env::var("NSEC_PRIVKEY") { - Ok(id_key) => Keys::parse(&id_key)?, - Err(e) => { - anyhow::bail!("NSEC_PRIVKEY not set: {e}"); - } - }; - - execute_take_dispute(dispute_id, &id_key, &trade_keys, mostro_key, &client).await? - } - Commands::AdmListDisputes {} => execute_list_disputes(mostro_key, &client).await?, - Commands::SendDm { - pubkey, + Commands::TakeSell { order_id, - message, - } => { - let pubkey = PublicKey::from_str(pubkey)?; - execute_send_dm(pubkey, &client, order_id, message).await? + invoice, + amount, + } => execute_take_order(order_id, Action::TakeSell, invoice, *amount, ctx).await, + Commands::TakeBuy { order_id, amount } => { + execute_take_order(order_id, Action::TakeBuy, &None, *amount, ctx).await } - Commands::DmToUser { - pubkey, - order_id, - message, - } => { - let pubkey = PublicKey::from_str(pubkey)?; - execute_dm_to_user(pubkey, &client, order_id, message).await? + Commands::AddInvoice { order_id, invoice } => { + execute_add_invoice(order_id, invoice, ctx).await } - Commands::AdmSendDm { pubkey, message } => { - let pubkey = PublicKey::from_str(pubkey)?; - execute_adm_send_dm(pubkey, &client, message).await? + Commands::Rate { order_id, rating } => execute_rate_user(order_id, rating, ctx).await, + + // DM retrieval commands + Commands::GetDm { since, from_user } => { + execute_get_dm(Some(since), false, from_user, ctx).await + } + Commands::GetDmUser { since } => execute_get_dm_user(since, ctx).await, + Commands::GetAdminDm { since, from_user } => { + execute_get_dm(Some(since), true, from_user, ctx).await } - }; - } - println!("Bye Bye!"); + // Admin commands + Commands::AdmListDisputes {} => execute_list_disputes(ctx).await, + Commands::AdmAddSolver { npubkey } => execute_admin_add_solver(npubkey, ctx).await, + Commands::AdmSettle { order_id } => execute_admin_settle_dispute(order_id, ctx).await, + Commands::AdmCancel { order_id } => execute_admin_cancel_dispute(order_id, ctx).await, + Commands::AdmTakeDispute { dispute_id } => execute_take_dispute(dispute_id, ctx).await, - Ok(()) + // Simple commands + Commands::Restore {} => { + execute_restore(&ctx.identity_keys, ctx.mostro_pubkey, &ctx.client).await + } + } + } } diff --git a/src/cli/add_invoice.rs b/src/cli/add_invoice.rs index e9ee8ef..ebf4d61 100644 --- a/src/cli/add_invoice.rs +++ b/src/cli/add_invoice.rs @@ -1,6 +1,5 @@ -use crate::db::connect; -use crate::util::send_message_sync; -use crate::{db::Order, lightning::is_valid_invoice}; +use crate::util::{send_dm, wait_for_dm}; +use crate::{cli::Context, db::Order, lightning::is_valid_invoice}; use anyhow::Result; use lnurl::lightning_address::LightningAddress; use mostro_core::prelude::*; @@ -8,24 +7,21 @@ use nostr_sdk::prelude::*; use std::str::FromStr; use uuid::Uuid; -pub async fn execute_add_invoice( - order_id: &Uuid, - invoice: &str, - identity_keys: &Keys, - mostro_key: PublicKey, - client: &Client, -) -> Result<()> { - let pool = connect().await?; - let mut order = Order::get_by_id(&pool, &order_id.to_string()).await?; +pub async fn execute_add_invoice(order_id: &Uuid, invoice: &str, ctx: &Context) -> Result<()> { + let order = Order::get_by_id(&ctx.pool, &order_id.to_string()).await?; let trade_keys = order .trade_keys .clone() .ok_or(anyhow::anyhow!("Missing trade keys"))?; - let trade_keys = Keys::parse(&trade_keys)?; + let order_trade_keys = Keys::parse(&trade_keys)?; + println!( + "Order trade keys: {:?}", + order_trade_keys.public_key().to_hex() + ); println!( - "Sending a lightning invoice {} to mostro pubId {}", - order_id, mostro_key + "Sending a lightning invoice for order {} to mostro pubId {}", + order_id, ctx.mostro_pubkey ); // Check invoice string let ln_addr = LightningAddress::from_str(invoice); @@ -35,11 +31,12 @@ pub async fn execute_add_invoice( match is_valid_invoice(invoice) { Ok(i) => Some(Payload::PaymentRequest(None, i.to_string(), None)), Err(e) => { - println!("Invalid invoice: {}", e); - None + return Err(anyhow::anyhow!("Invalid invoice: {}", e)); } } }; + + // Create request id let request_id = Uuid::new_v4().as_u128() as u64; // Create AddInvoice message let add_invoice_message = Message::new_order( @@ -50,31 +47,50 @@ pub async fn execute_add_invoice( payload, ); - let dm = send_message_sync( - client, - Some(identity_keys), - &trade_keys, - mostro_key, - add_invoice_message, - true, - false, - ) - .await?; + // Serialize the message + let message_json = add_invoice_message + .as_json() + .map_err(|_| anyhow::anyhow!("Failed to serialize message"))?; - dm.iter().for_each(|el| { - let message = el.0.get_inner_message_kind(); - if message.request_id == Some(request_id) && message.action == Action::WaitingSellerToPay { - println!("Now we should wait for the seller to pay the invoice"); - } + // Subscribe to gift wrap events - ONLY NEW ONES WITH LIMIT 0 + let subscription = Filter::new() + .pubkey(order_trade_keys.clone().public_key()) + .kind(nostr_sdk::Kind::GiftWrap) + .limit(0); + + let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::WaitForEvents(1)); + ctx.client.subscribe(subscription, Some(opts)).await?; + + // Clone the keys and client for the async call + let identity_keys_clone = ctx.identity_keys.clone(); + let client_clone = ctx.client.clone(); + let mostro_pubkey_clone = ctx.mostro_pubkey; + let order_trade_keys_clone = order_trade_keys.clone(); + + // Spawn a new task to send the DM + // This is so we can wait for the gift wrap event in the main thread + tokio::spawn(async move { + let _ = send_dm( + &client_clone, + Some(&identity_keys_clone), + &order_trade_keys, + &mostro_pubkey_clone, + message_json, + None, + false, + ) + .await; }); - match order - .set_status(Status::WaitingPayment.to_string()) - .save(&pool) - .await - { - Ok(_) => println!("Order status updated"), - Err(e) => println!("Failed to update order status: {}", e), - } + // Wait for the DM to be sent from mostro and update the order + wait_for_dm( + &ctx.client, + &order_trade_keys_clone, + request_id, + None, + Some(order), + &ctx.pool, + ) + .await?; Ok(()) } diff --git a/src/cli/adm_send_dm.rs b/src/cli/adm_send_dm.rs index f8d0566..f4993dc 100644 --- a/src/cli/adm_send_dm.rs +++ b/src/cli/adm_send_dm.rs @@ -14,11 +14,14 @@ pub async fn execute_adm_send_dm( } }; - println!("SENDING DM with admin keys: {}", admin_keys.public_key().to_hex()); + println!( + "SENDING DM with admin keys: {}", + admin_keys.public_key().to_hex() + ); send_admin_gift_wrap_dm(client, &admin_keys, &receiver, message).await?; println!("Admin gift wrap message sent to {}", receiver); Ok(()) -} \ No newline at end of file +} diff --git a/src/cli/dm_to_user.rs b/src/cli/dm_to_user.rs index 9b8e835..2288e80 100644 --- a/src/cli/dm_to_user.rs +++ b/src/cli/dm_to_user.rs @@ -1,6 +1,7 @@ use crate::{db::Order, util::send_gift_wrap_dm}; use anyhow::Result; use nostr_sdk::prelude::*; +use sqlx::SqlitePool; use uuid::Uuid; pub async fn execute_dm_to_user( @@ -8,20 +9,25 @@ pub async fn execute_dm_to_user( client: &Client, order_id: &Uuid, message: &str, + pool: &SqlitePool, ) -> Result<()> { - let pool = crate::db::connect().await?; - - let order = Order::get_by_id(&pool, &order_id.to_string()) + // Get the order + let order = Order::get_by_id(pool, &order_id.to_string()) .await .map_err(|_| anyhow::anyhow!("order {} not found", order_id))?; + // Get the trade keys let trade_keys = match order.trade_keys.as_ref() { Some(trade_keys) => Keys::parse(trade_keys)?, None => anyhow::bail!("No trade_keys found for this order"), }; - println!("SENDING DM with trade keys: {}", trade_keys.public_key().to_hex()); + // Send the DM + println!( + "SENDING DM with trade keys: {}", + trade_keys.public_key().to_hex() + ); send_gift_wrap_dm(client, &trade_keys, &receiver, message).await?; Ok(()) -} \ No newline at end of file +} diff --git a/src/cli/get_dm.rs b/src/cli/get_dm.rs index 9f46943..d5edf38 100644 --- a/src/cli/get_dm.rs +++ b/src/cli/get_dm.rs @@ -1,116 +1,37 @@ use anyhow::Result; -use chrono::DateTime; -use mostro_core::prelude::*; -use nostr_sdk::prelude::*; +use mostro_core::prelude::Message; use crate::{ - db::{connect, Order, User}, - util::get_direct_messages, + cli::Context, + parser::dms::print_direct_messages, + util::{fetch_events_list, Event, ListKind}, }; pub async fn execute_get_dm( - since: &i64, - trade_index: i64, - client: &Client, - from_user: bool, + since: Option<&i64>, admin: bool, - mostro_pubkey: &PublicKey, + from_user: &bool, + ctx: &Context, ) -> Result<()> { - let mut dm: Vec<(Message, u64)> = Vec::new(); - let pool = connect().await?; - if !admin { - for index in 1..=trade_index { - let keys = User::get_trade_keys(&pool, index).await?; - let dm_temp = - get_direct_messages(client, &keys, *since, from_user, Some(mostro_pubkey)).await; - dm.extend(dm_temp); - } - } else { - let id_key = match std::env::var("NSEC_PRIVKEY") { - Ok(id_key) => Keys::parse(&id_key)?, - Err(e) => { - println!("Failed to get mostro admin private key: {}", e); - std::process::exit(1); - } - }; - let dm_temp = - get_direct_messages(client, &id_key, *since, from_user, Some(mostro_pubkey)).await; - dm.extend(dm_temp); - } + // Get the list kind + let list_kind = match (admin, from_user) { + (true, true) => ListKind::PrivateDirectMessagesUser, + (true, false) => ListKind::DirectMessagesAdmin, + (false, true) => ListKind::PrivateDirectMessagesUser, + (false, false) => ListKind::DirectMessagesUser, + }; - if dm.is_empty() { - println!(); - println!("No new messages"); - println!(); - } else { - for m in dm.iter() { - let message = m.0.get_inner_message_kind(); - let date = DateTime::from_timestamp(m.1 as i64, 0).unwrap(); - if message.id.is_some() { - println!( - "Mostro sent you this message for order id: {} at {}", - m.0.get_inner_message_kind().id.unwrap(), - date - ); - } - if let Some(payload) = &message.payload { - match payload { - Payload::PaymentRequest(_, inv, _) => { - println!(); - println!("Pay this invoice to continue --> {}", inv); - println!(); - } - Payload::TextMessage(text) => { - println!(); - println!("{text}"); - println!(); - } - Payload::Dispute(id, info) => { - println!("Action: {}", message.action); - println!("Dispute id: {}", id); - if let Some(info) = info { - println!(); - println!("Dispute info: {:#?}", info); - println!(); - } - } - Payload::CantDo(Some(cant_do_reason)) => { - println!(); - println!("Error: {:?}", cant_do_reason); - println!(); - } - Payload::Order(new_order) if message.action == Action::NewOrder => { - if new_order.id.is_some() { - let db_order = - Order::get_by_id(&pool, &new_order.id.unwrap().to_string()).await; - if db_order.is_err() { - let trade_index = message.trade_index.unwrap(); - let trade_keys = User::get_trade_keys(&pool, trade_index).await?; - let _ = Order::new(&pool, new_order.clone(), &trade_keys, None) - .await - .map_err(|e| { - anyhow::anyhow!("Failed to create DB order: {:?}", e) - })?; - } - } - println!(); - println!("Order: {:#?}", new_order); - println!(); - } - _ => { - println!(); - println!("Action: {}", message.action); - println!("Payload: {:#?}", message.payload); - println!(); - } - } - } else { - println!(); - println!("Action: {}", message.action); - println!("Payload: {:#?}", message.payload); - println!(); - } + // Fetch the requested events + let all_fetched_events = { fetch_events_list(list_kind, None, None, None, ctx, since).await? }; + + // Extract (Message, u64) tuples from Event::MessageTuple variants + let mut dm_events: Vec<(Message, u64)> = Vec::new(); + for event in all_fetched_events { + if let Event::MessageTuple(tuple) = event { + dm_events.push(*tuple); } } + + print_direct_messages(&dm_events, &ctx.pool).await?; Ok(()) } diff --git a/src/cli/get_dm_user.rs b/src/cli/get_dm_user.rs index de7a64f..8577065 100644 --- a/src/cli/get_dm_user.rs +++ b/src/cli/get_dm_user.rs @@ -1,30 +1,41 @@ +use crate::cli::Context; use crate::{db::Order, util::get_direct_messages_from_trade_keys}; use anyhow::Result; use comfy_table::modifiers::UTF8_ROUND_CORNERS; use comfy_table::presets::UTF8_FULL; use comfy_table::Table; use mostro_core::prelude::*; -use nostr_sdk::prelude::*; -pub async fn execute_get_dm_user(since: &i64, client: &Client, mostro_pubkey: &PublicKey) -> Result<()> { - let pool = crate::db::connect().await?; - +pub async fn execute_get_dm_user(since: &i64, ctx: &Context) -> Result<()> { // Get all trade keys from orders - let mut trade_keys_hex = Order::get_all_trade_keys(&pool).await?; - - // Add admin private key to search for messages sent TO admin - if let Ok(admin_privkey_hex) = std::env::var("NSEC_PRIVKEY") { - trade_keys_hex.push(admin_privkey_hex); + let mut trade_keys_hex = Order::get_all_trade_keys(&ctx.pool).await?; + + // Include admin pubkey so we also fetch messages sent TO admin + let admin_pubkey_hex = ctx.mostro_pubkey.to_hex(); + if !trade_keys_hex.iter().any(|k| k == &admin_pubkey_hex) { + trade_keys_hex.push(admin_pubkey_hex); } - + // De-duplicate any repeated keys coming from DB/admin + trade_keys_hex.sort(); + trade_keys_hex.dedup(); + if trade_keys_hex.is_empty() { - println!("No trade keys found in orders and NSEC_PRIVKEY not set"); + println!("No trade keys found in orders"); return Ok(()); } - - println!("Searching for DMs in {} trade keys...", trade_keys_hex.len()); - - let direct_messages = get_direct_messages_from_trade_keys(client, trade_keys_hex, *since, mostro_pubkey).await; + + println!( + "Searching for DMs in {} trade keys...", + trade_keys_hex.len() + ); + + let direct_messages = get_direct_messages_from_trade_keys( + &ctx.client, + trade_keys_hex, + *since, + &ctx.mostro_pubkey, + ) + .await?; if direct_messages.is_empty() { println!("You don't have any direct messages in your trade keys"); @@ -59,4 +70,4 @@ pub async fn execute_get_dm_user(since: &i64, client: &Client, mostro_pubkey: &P println!("{table}"); println!(); Ok(()) -} \ No newline at end of file +} diff --git a/src/cli/list_disputes.rs b/src/cli/list_disputes.rs index 6951647..feff25b 100644 --- a/src/cli/list_disputes.rs +++ b/src/cli/list_disputes.rs @@ -1,17 +1,19 @@ use anyhow::Result; -use nostr_sdk::prelude::*; -use crate::pretty_table::print_disputes_table; -use crate::util::get_disputes_list; +use crate::cli::Context; +use crate::parser::disputes::print_disputes_table; +use crate::util::{fetch_events_list, ListKind}; -pub async fn execute_list_disputes(mostro_key: PublicKey, client: &Client) -> Result<()> { +pub async fn execute_list_disputes(ctx: &Context) -> Result<()> { + // Print mostro pubkey println!( "Requesting disputes from mostro pubId - {}", - mostro_key.clone() + &ctx.mostro_pubkey ); // Get orders from relays - let table_of_disputes = get_disputes_list(mostro_key, client).await?; + let table_of_disputes = + fetch_events_list(ListKind::Disputes, None, None, None, ctx, None).await?; let table = print_disputes_table(table_of_disputes)?; println!("{table}"); diff --git a/src/cli/list_orders.rs b/src/cli/list_orders.rs index 6558437..56891c3 100644 --- a/src/cli/list_orders.rs +++ b/src/cli/list_orders.rs @@ -1,61 +1,68 @@ +use crate::cli::Context; +use crate::parser::orders::print_orders_table; +use crate::util::{fetch_events_list, ListKind}; use anyhow::Result; use mostro_core::prelude::*; -use nostr_sdk::prelude::*; use std::str::FromStr; -use crate::pretty_table::print_orders_table; -use crate::util::get_orders_list; - +#[allow(clippy::too_many_arguments)] pub async fn execute_list_orders( kind: &Option, currency: &Option, status: &Option, - mostro_key: PublicKey, - client: &Client, + ctx: &Context, ) -> Result<()> { // Used to get upper currency string to check against a list of tickers let mut upper_currency: Option = None; - let mut status_checked: Option = Some(Status::from_str("pending").unwrap()); + // Default status is pending + let mut status_checked: Option = Some(Status::Pending); + // Default kind is none let mut kind_checked: Option = None; // New check against strings if let Some(s) = status { - status_checked = Some(Status::from_str(s).expect("Not valid status! Please check")); + status_checked = Some( + Status::from_str(s) + .map_err(|e| anyhow::anyhow!("Not valid status '{}': {:?}", s, e))?, + ); } - println!( - "You are searching orders with status {:?}", - status_checked.unwrap() - ); - // New check against strings + // Print status requested + if let Some(status) = &status_checked { + println!("You are searching orders with status {:?}", status); + } + // New check against strings for kind if let Some(k) = kind { kind_checked = Some( - mostro_core::order::Kind::from_str(k).expect("Not valid order kind! Please check"), + mostro_core::order::Kind::from_str(k) + .map_err(|e| anyhow::anyhow!("Not valid order kind '{}': {:?}", k, e))?, ); - println!("You are searching {} orders", kind_checked.unwrap()); + if let Some(kind) = &kind_checked { + println!("You are searching {} orders", kind); + } } // Uppercase currency if let Some(curr) = currency { upper_currency = Some(curr.to_uppercase()); - println!( - "You are searching orders with currency {}", - upper_currency.clone().unwrap() - ); + if let Some(currency) = &upper_currency { + println!("You are searching orders with currency {}", currency); + } } println!( "Requesting orders from mostro pubId - {}", - mostro_key.clone() + &ctx.mostro_pubkey ); // Get orders from relays - let table_of_orders = get_orders_list( - mostro_key, - status_checked.unwrap(), + let table_of_orders = fetch_events_list( + ListKind::Orders, + status_checked, upper_currency, kind_checked, - client, + ctx, + None, ) .await?; let table = print_orders_table(table_of_orders)?; diff --git a/src/cli/new_order.rs b/src/cli/new_order.rs index 26af7b1..4bf5ed7 100644 --- a/src/cli/new_order.rs +++ b/src/cli/new_order.rs @@ -1,3 +1,6 @@ +use crate::cli::Context; +use crate::parser::orders::print_order_preview; +use crate::util::{send_dm, uppercase_first, wait_for_dm}; use anyhow::Result; use mostro_core::prelude::*; use nostr_sdk::prelude::*; @@ -7,10 +10,6 @@ use std::process; use std::str::FromStr; use uuid::Uuid; -use crate::db::{connect, Order, User}; -use crate::pretty_table::print_order_preview; -use crate::util::{send_message_sync, uppercase_first}; - pub type FiatNames = HashMap; #[allow(clippy::too_many_arguments)] @@ -22,11 +21,7 @@ pub async fn execute_new_order( payment_method: &str, premium: &i64, invoice: &Option, - identity_keys: &Keys, - trade_keys: &Keys, - trade_index: i64, - mostro_key: PublicKey, - client: &Client, + ctx: &Context, expiration_days: &i64, ) -> Result<()> { // Uppercase currency @@ -48,7 +43,8 @@ pub async fn execute_new_order( } let kind = uppercase_first(kind); // New check against strings - let kind_checked = mostro_core::order::Kind::from_str(&kind).unwrap(); + let kind_checked = mostro_core::order::Kind::from_str(&kind) + .map_err(|_| anyhow::anyhow!("Invalid order kind"))?; let expires_at = match *expiration_days { 0 => None, _ => { @@ -66,6 +62,7 @@ pub async fn execute_new_order( } else { (fiat_amount.0, None, None) }; + let small_order = SmallOrder::new( None, Some(kind_checked), @@ -88,7 +85,8 @@ pub async fn execute_new_order( let order_content = Payload::Order(small_order.clone()); // Print order preview - let ord_preview = print_order_preview(order_content.clone()).unwrap(); + let ord_preview = print_order_preview(order_content.clone()) + .map_err(|e| anyhow::anyhow!("Failed to generate order preview: {}", e))?; println!("{ord_preview}"); let mut user_input = String::new(); let _input = stdin(); @@ -114,82 +112,63 @@ pub async fn execute_new_order( let message = Message::new_order( None, Some(request_id), - Some(trade_index), + Some(ctx.trade_index), Action::NewOrder, Some(order_content), ); - let dm = send_message_sync( - client, - Some(identity_keys), - trade_keys, - mostro_key, - message, - true, - false, + // Send dm to receiver pubkey + println!( + "SENDING DM with trade keys: {:?}", + ctx.trade_keys.public_key().to_hex() + ); + + // Serialize the message + let message_json = message + .as_json() + .map_err(|_| anyhow::anyhow!("Failed to serialize message"))?; + + // Clone the keys and client for the async call + let identity_keys_clone = ctx.identity_keys.clone(); + let trade_keys_clone = ctx.trade_keys.clone(); + let client_clone = ctx.client.clone(); + let mostro_pubkey_clone = ctx.mostro_pubkey; + + // Subscribe to gift wrap events - ONLY NEW ONES WITH LIMIT 0 + let subscription = Filter::new() + .pubkey(ctx.trade_keys.public_key()) + .kind(nostr_sdk::Kind::GiftWrap) + .limit(0); + + let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::WaitForEvents(1)); + + ctx.client.subscribe(subscription, Some(opts)).await?; + + // Spawn a new task to send the DM + // This is so we can wait for the gift wrap event in the main thread + tokio::spawn(async move { + let _ = send_dm( + &client_clone, + Some(&identity_keys_clone), + &trade_keys_clone, + &mostro_pubkey_clone, + message_json, + None, + false, + ) + .await; + }); + + // Wait for the DM to be sent from mostro + wait_for_dm( + &ctx.client, + &ctx.trade_keys, + request_id, + Some(ctx.trade_index), + None, + &ctx.pool, ) .await?; - let order_id = dm - .iter() - .find_map(|el| { - let message = el.0.get_inner_message_kind(); - if message.request_id == Some(request_id) { - match message.action { - Action::NewOrder => { - if let Some(Payload::Order(order)) = message.payload.as_ref() { - return order.id; - } - } - Action::CantDo => { - if let Some(Payload::CantDo(Some(cant_do_reason))) = &message.payload { - match cant_do_reason { - CantDoReason::OutOfRangeFiatAmount | CantDoReason::OutOfRangeSatsAmount => { - println!("Error: Amount is outside the allowed range. Please check the order's min/max limits."); - } - _ => { - println!("Unknown reason: {:?}", message.payload); - } - } - } else { - println!("Unknown reason: {:?}", message.payload); - return None; - } - } - _ => { - println!("Unknown action: {:?}", message.action); - return None; - } - } - } - None - }) - .or_else(|| { - println!("Error: No matching order found in response"); - None - }); - - if let Some(order_id) = order_id { - println!("Order id {} created", order_id); - // Create order in db - let pool = connect().await?; - let db_order = Order::new(&pool, small_order, trade_keys, Some(request_id as i64)) - .await - .map_err(|e| anyhow::anyhow!("Failed to create DB order: {:?}", e))?; - // Update last trade index - match User::get(&pool).await { - Ok(mut user) => { - user.set_last_trade_index(trade_index); - if let Err(e) = user.save(&pool).await { - println!("Failed to update user: {}", e); - } - } - Err(e) => println!("Failed to get user: {}", e), - } - let db_order_id = db_order - .id - .clone() - .ok_or(anyhow::anyhow!("Missing order id"))?; - Order::save_new_id(&pool, db_order_id, order_id.to_string()).await?; - } + Ok(()) } diff --git a/src/cli/rate_user.rs b/src/cli/rate_user.rs index 2a297a3..eb83452 100644 --- a/src/cli/rate_user.rs +++ b/src/cli/rate_user.rs @@ -3,43 +3,35 @@ use mostro_core::prelude::*; use nostr_sdk::prelude::*; use uuid::Uuid; -use crate::{ - db::{connect, Order}, - util::send_message_sync, -}; +const RATING_BOUNDARIES: [u8; 5] = [1, 2, 3, 4, 5]; -pub async fn execute_rate_user( - order_id: &Uuid, - rating: &u8, - identity_keys: &Keys, - mostro_key: PublicKey, - client: &Client, -) -> Result<()> { - // User rating - let rating_content; +use crate::{cli::Context, db::Order, util::send_dm}; - // Check boundaries - if let 1..=5 = *rating { - rating_content = Payload::RatingUser(*rating); +// Get the user rate +fn get_user_rate(rating: &u8) -> Result { + if let Some(rating) = RATING_BOUNDARIES.iter().find(|r| r == &rating) { + Ok(Payload::RatingUser(*rating)) } else { - println!("Rating must be in the range 1 - 5"); - std::process::exit(0); + Err(anyhow::anyhow!("Rating must be in the range 1 - 5")) } +} - let pool = connect().await?; - - let trade_keys = if let Ok(order_to_vote) = Order::get_by_id(&pool, &order_id.to_string()).await - { - match order_to_vote.trade_keys.as_ref() { - Some(trade_keys) => Keys::parse(trade_keys)?, - None => { - anyhow::bail!("No trade_keys found for this order"); +pub async fn execute_rate_user(order_id: &Uuid, rating: &u8, ctx: &Context) -> Result<()> { + // Check boundaries + let rating_content = get_user_rate(rating)?; + + // Get the trade keys + let trade_keys = + if let Ok(order_to_vote) = Order::get_by_id(&ctx.pool, &order_id.to_string()).await { + match order_to_vote.trade_keys.as_ref() { + Some(trade_keys) => Keys::parse(trade_keys)?, + None => { + return Err(anyhow::anyhow!("No trade_keys found for this order")); + } } - } - } else { - println!("order {} not found", order_id); - std::process::exit(0) - }; + } else { + return Err(anyhow::anyhow!("order {} not found", order_id)); + }; // Create rating message of counterpart let rate_message = Message::new_order( @@ -48,18 +40,20 @@ pub async fn execute_rate_user( None, Action::RateUser, Some(rating_content), - ); + ) + .as_json() + .map_err(|_| anyhow::anyhow!("Failed to serialize message"))?; - send_message_sync( - client, - Some(identity_keys), + send_dm( + &ctx.client, + Some(&ctx.identity_keys), &trade_keys, - mostro_key, + &ctx.mostro_pubkey, rate_message, - true, + None, false, ) .await?; - std::process::exit(0); + Ok(()) } diff --git a/src/cli/send_dm.rs b/src/cli/send_dm.rs index b60efb3..f07a142 100644 --- a/src/cli/send_dm.rs +++ b/src/cli/send_dm.rs @@ -1,4 +1,4 @@ -use crate::{db::Order, util::send_message_sync}; +use crate::{db::Order, util::send_dm}; use anyhow::Result; use mostro_core::prelude::*; use nostr_sdk::prelude::*; @@ -15,7 +15,9 @@ pub async fn execute_send_dm( None, Action::SendDm, Some(Payload::TextMessage(message.to_string())), - ); + ) + .as_json() + .map_err(|_| anyhow::anyhow!("Failed to serialize message"))?; let pool = crate::db::connect().await?; @@ -32,7 +34,7 @@ pub async fn execute_send_dm( std::process::exit(0) }; - send_message_sync(client, None, &trade_keys, receiver, message, true, true).await?; + send_dm(client, None, &trade_keys, &receiver, message, None, false).await?; Ok(()) } diff --git a/src/cli/send_msg.rs b/src/cli/send_msg.rs index be6f2e3..6701b59 100644 --- a/src/cli/send_msg.rs +++ b/src/cli/send_msg.rs @@ -1,20 +1,16 @@ +use crate::cli::{Commands, Context}; use crate::db::{Order, User}; -use crate::util::send_message_sync; -use crate::{cli::Commands, db::connect}; +use crate::util::{send_dm, wait_for_dm}; use anyhow::Result; use mostro_core::prelude::*; use nostr_sdk::prelude::*; -use sqlx::SqlitePool; -use std::process; use uuid::Uuid; pub async fn execute_send_msg( command: Commands, order_id: Option, - identity_keys: Option<&Keys>, - mostro_key: PublicKey, - client: &Client, + ctx: &Context, text: Option<&str>, ) -> Result<()> { // Map CLI command to action @@ -27,30 +23,29 @@ pub async fn execute_send_msg( Commands::AdmSettle { .. } => Action::AdminSettle, Commands::AdmAddSolver { .. } => Action::AdminAddSolver, _ => { - eprintln!("Not a valid command!"); - process::exit(0); + return Err(anyhow::anyhow!("Invalid command for send msg")); } }; println!( "Sending {} command for order {:?} to mostro pubId {}", - requested_action, order_id, mostro_key + requested_action, + order_id.as_ref(), + &ctx.mostro_pubkey ); - let pool = connect().await?; - // Determine payload let payload = match requested_action { - Action::FiatSent | Action::Release => create_next_trade_payload(&pool, &order_id).await?, + Action::FiatSent | Action::Release => create_next_trade_payload(ctx, &order_id).await?, _ => text.map(|t| Payload::TextMessage(t.to_string())), }; // Update last trade index if next trade payload if let Some(Payload::NextTrade(_, trade_index)) = &payload { // Update last trade index - match User::get(&pool).await { + match User::get(&ctx.pool).await { Ok(mut user) => { user.set_last_trade_index(*trade_index as i64); - if let Err(e) = user.save(&pool).await { + if let Err(e) = user.save(&ctx.pool).await { println!("Failed to update user: {}", e); } } @@ -58,42 +53,73 @@ pub async fn execute_send_msg( } } + // Create request id let request_id = Uuid::new_v4().as_u128() as u64; // Create and send the message let message = Message::new_order(order_id, Some(request_id), None, requested_action, payload); - // println!("Sending message: {:#?}", message); + let idkey = ctx.identity_keys.to_owned(); if let Some(order_id) = order_id { - handle_order_response( - &pool, - client, - identity_keys, - mostro_key, - message, - order_id, - request_id, - ) - .await?; - } else { - println!("Error: Missing order ID"); + let order = Order::get_by_id(&ctx.pool, &order_id.to_string()).await?; + + if let Some(trade_keys_str) = order.trade_keys.clone() { + let trade_keys = Keys::parse(&trade_keys_str)?; + // Subscribe to gift wrap events - ONLY NEW ONES WITH LIMIT 0 + let subscription = Filter::new() + .pubkey(trade_keys.public_key()) + .kind(nostr_sdk::Kind::GiftWrap) + .limit(0); + + let opts = + SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::WaitForEvents(1)); + // Subscribe to gift wrap events + ctx.client.subscribe(subscription, Some(opts)).await?; + // Send DM + let message_json = message + .as_json() + .map_err(|e| anyhow::anyhow!("Failed to serialize message: {e}"))?; + send_dm( + &ctx.client, + Some(&idkey), + &trade_keys, + &ctx.mostro_pubkey, + message_json, + None, + false, + ) + .await + .map_err(|e| anyhow::anyhow!("Failed to send DM: {e}"))?; + + // Wait for the DM to be sent from mostro + wait_for_dm( + &ctx.client, + &trade_keys, + request_id, + None, + Some(order), + &ctx.pool, + ) + .await + .map_err(|e| anyhow::anyhow!("Failed to wait for DM: {e}"))?; + } } Ok(()) } async fn create_next_trade_payload( - pool: &SqlitePool, + ctx: &Context, order_id: &Option, ) -> Result> { if let Some(order_id) = order_id { - let order = Order::get_by_id(pool, &order_id.to_string()).await?; + let order = Order::get_by_id(&ctx.pool, &order_id.to_string()).await?; if let (Some(_), Some(min_amount), Some(max_amount)) = (order.is_mine, order.min_amount, order.max_amount) { if max_amount - order.fiat_amount >= min_amount { - let (trade_keys, trade_index) = User::get_next_trade_keys(pool).await?; + let (trade_keys, trade_index) = User::get_next_trade_keys(&ctx.pool).await?; return Ok(Some(Payload::NextTrade( trade_keys.public_key().to_string(), trade_index.try_into()?, @@ -103,84 +129,3 @@ async fn create_next_trade_payload( } Ok(None) } - -async fn handle_order_response( - pool: &SqlitePool, - client: &Client, - identity_keys: Option<&Keys>, - mostro_key: PublicKey, - message: Message, - order_id: Uuid, - request_id: u64, -) -> Result<()> { - let order = Order::get_by_id(pool, &order_id.to_string()).await; - - match order { - Ok(order) => { - if let Some(trade_keys_str) = order.trade_keys { - let trade_keys = Keys::parse(&trade_keys_str)?; - let dm = send_message_sync( - client, - identity_keys, - &trade_keys, - mostro_key, - message, - true, - false, - ) - .await?; - process_order_response(dm, pool, &trade_keys, request_id).await?; - } else { - println!("Error: Missing trade keys for order {}", order_id); - } - } - Err(e) => { - println!("Error: {}", e); - } - } - - Ok(()) -} - -async fn process_order_response( - dm: Vec<(Message, u64)>, - pool: &SqlitePool, - trade_keys: &Keys, - request_id: u64, -) -> Result<()> { - for (message, _) in dm { - let kind = message.get_inner_message_kind(); - if let Some(req_id) = kind.request_id { - if req_id != request_id { - continue; - } - - match kind.action { - Action::NewOrder => { - if let Some(Payload::Order(order)) = kind.payload.as_ref() { - Order::new(pool, order.clone(), trade_keys, Some(request_id as i64)) - .await - .map_err(|e| anyhow::anyhow!("Failed to create new order: {}", e))?; - return Ok(()); - } - } - Action::Canceled => { - if let Some(id) = kind.id { - // Verify order exists before deletion - if Order::get_by_id(pool, &id.to_string()).await.is_ok() { - Order::delete_by_id(pool, &id.to_string()) - .await - .map_err(|e| anyhow::anyhow!("Failed to delete order: {}", e))?; - return Ok(()); - } else { - return Err(anyhow::anyhow!("Order not found: {}", id)); - } - } - } - _ => (), - } - } - } - - Ok(()) -} diff --git a/src/cli/take_buy.rs b/src/cli/take_buy.rs deleted file mode 100644 index 55abc8a..0000000 --- a/src/cli/take_buy.rs +++ /dev/null @@ -1,111 +0,0 @@ -use anyhow::Result; -use mostro_core::prelude::*; -use nostr_sdk::prelude::*; -use uuid::Uuid; - -use crate::{ - db::{connect, Order, User}, - util::send_message_sync, -}; - -pub async fn execute_take_buy( - order_id: &Uuid, - amount: Option, - identity_keys: &Keys, - trade_keys: &Keys, - trade_index: i64, - mostro_key: PublicKey, - client: &Client, -) -> Result<()> { - println!( - "Request of take buy order {} from mostro pubId {}", - order_id, - mostro_key.clone() - ); - let request_id = Uuid::new_v4().as_u128() as u64; - let payload = amount.map(|amt: u32| Payload::Amount(amt as i64)); - // Create takebuy message - let take_buy_message = Message::new_order( - Some(*order_id), - Some(request_id), - Some(trade_index), - Action::TakeBuy, - payload, - ); - - let dm = send_message_sync( - client, - Some(identity_keys), - trade_keys, - mostro_key, - take_buy_message, - true, - false, - ) - .await?; - - let pool = connect().await?; - - let order = dm.iter().find_map(|el| { - let message = el.0.get_inner_message_kind(); - if message.request_id == Some(request_id) { - match message.action { - Action::PayInvoice => { - if let Some(Payload::PaymentRequest(order, invoice, _)) = &message.payload { - println!( - "Mostro sent you this hold invoice for order id: {}", - order - .as_ref() - .and_then(|o| o.id) - .map_or("unknown".to_string(), |id| id.to_string()) - ); - println!(); - println!("Pay this invoice to continue --> {}", invoice); - println!(); - return order.clone(); - } - } - Action::CantDo => { - if let Some(Payload::CantDo(Some(cant_do_reason))) = &message.payload { - match cant_do_reason { - CantDoReason::OutOfRangeFiatAmount | CantDoReason::OutOfRangeSatsAmount => { - println!("Error: Amount is outside the allowed range. Please check the order's min/max limits."); - } - _ => { - println!("Unknown reason: {:?}", message.payload); - } - } - } else { - println!("Unknown reason: {:?}", message.payload); - return None; - } - } - _ => { - println!("Unknown action: {:?}", message.action); - return None; - } - } - } - None - }); - if let Some(o) = order { - match Order::new(&pool, o, trade_keys, Some(request_id as i64)).await { - Ok(order) => { - println!("Order {} created", order.id.unwrap()); - // Update last trade index to be used in next trade - match User::get(&pool).await { - Ok(mut user) => { - user.set_last_trade_index(trade_index); - if let Err(e) = user.save(&pool).await { - println!("Failed to update user: {}", e); - } - } - Err(e) => println!("Failed to get user: {}", e), - } - } - Err(e) => println!("{}", e), - } - } - - Ok(()) -} diff --git a/src/cli/take_dispute.rs b/src/cli/take_dispute.rs index 61bb2e3..9da4a8a 100644 --- a/src/cli/take_dispute.rs +++ b/src/cli/take_dispute.rs @@ -1,21 +1,13 @@ use anyhow::Result; use mostro_core::prelude::*; -use nostr_sdk::prelude::*; use uuid::Uuid; -use crate::util::send_message_sync; +use crate::{cli::Context, util::send_dm}; -pub async fn execute_admin_add_solver( - npubkey: &str, - identity_keys: &Keys, - trade_keys: &Keys, - mostro_key: PublicKey, - client: &Client, -) -> Result<()> { +pub async fn execute_admin_add_solver(npubkey: &str, ctx: &Context) -> Result<()> { println!( "Request of add solver with pubkey {} from mostro pubId {}", - npubkey, - mostro_key.clone() + npubkey, &ctx.mostro_pubkey ); // Create takebuy message let take_dispute_message = Message::new_dispute( @@ -24,15 +16,17 @@ pub async fn execute_admin_add_solver( None, Action::AdminAddSolver, Some(Payload::TextMessage(npubkey.to_string())), - ); - - send_message_sync( - client, - Some(identity_keys), - trade_keys, - mostro_key, + ) + .as_json() + .map_err(|_| anyhow::anyhow!("Failed to serialize message"))?; + + send_dm( + &ctx.client, + Some(&ctx.identity_keys), + &ctx.trade_keys, + &ctx.mostro_pubkey, take_dispute_message, - true, + None, false, ) .await?; @@ -40,31 +34,30 @@ pub async fn execute_admin_add_solver( Ok(()) } -pub async fn execute_admin_cancel_dispute( - dispute_id: &Uuid, - identity_keys: &Keys, - trade_keys: &Keys, - mostro_key: PublicKey, - client: &Client, -) -> Result<()> { +pub async fn execute_admin_cancel_dispute(dispute_id: &Uuid, ctx: &Context) -> Result<()> { println!( "Request of cancel dispute {} from mostro pubId {}", dispute_id, - mostro_key.clone() + ctx.mostro_pubkey.clone() ); // Create takebuy message let take_dispute_message = - Message::new_dispute(Some(*dispute_id), None, None, Action::AdminCancel, None); + Message::new_dispute(Some(*dispute_id), None, None, Action::AdminCancel, None) + .as_json() + .map_err(|_| anyhow::anyhow!("Failed to serialize message"))?; - println!("identity_keys: {:?}", identity_keys.public_key.to_string()); + println!( + "identity_keys: {:?}", + ctx.identity_keys.public_key.to_string() + ); - send_message_sync( - client, - Some(identity_keys), - trade_keys, - mostro_key, + send_dm( + &ctx.client, + Some(&ctx.identity_keys), + &ctx.trade_keys, + &ctx.mostro_pubkey, take_dispute_message, - true, + None, false, ) .await?; @@ -72,31 +65,30 @@ pub async fn execute_admin_cancel_dispute( Ok(()) } -pub async fn execute_admin_settle_dispute( - dispute_id: &Uuid, - identity_keys: &Keys, - trade_keys: &Keys, - mostro_key: PublicKey, - client: &Client, -) -> Result<()> { +pub async fn execute_admin_settle_dispute(dispute_id: &Uuid, ctx: &Context) -> Result<()> { println!( "Request of take dispute {} from mostro pubId {}", dispute_id, - mostro_key.clone() + ctx.mostro_pubkey.clone() ); // Create takebuy message let take_dispute_message = - Message::new_dispute(Some(*dispute_id), None, None, Action::AdminSettle, None); + Message::new_dispute(Some(*dispute_id), None, None, Action::AdminSettle, None) + .as_json() + .map_err(|_| anyhow::anyhow!("Failed to serialize message"))?; - println!("identity_keys: {:?}", identity_keys.public_key.to_string()); + println!( + "identity_keys: {:?}", + ctx.identity_keys.public_key.to_string() + ); - send_message_sync( - client, - Some(identity_keys), - trade_keys, - mostro_key, + send_dm( + &ctx.client, + Some(&ctx.identity_keys), + &ctx.trade_keys, + &ctx.mostro_pubkey, take_dispute_message, - true, + None, false, ) .await?; @@ -104,17 +96,11 @@ pub async fn execute_admin_settle_dispute( Ok(()) } -pub async fn execute_take_dispute( - dispute_id: &Uuid, - identity_keys: &Keys, - trade_keys: &Keys, - mostro_key: PublicKey, - client: &Client, -) -> Result<()> { +pub async fn execute_take_dispute(dispute_id: &Uuid, ctx: &Context) -> Result<()> { println!( "Request of take dispute {} from mostro pubId {}", dispute_id, - mostro_key.clone() + ctx.mostro_pubkey.clone() ); // Create takebuy message let take_dispute_message = Message::new_dispute( @@ -123,17 +109,22 @@ pub async fn execute_take_dispute( None, Action::AdminTakeDispute, None, - ); + ) + .as_json() + .map_err(|_| anyhow::anyhow!("Failed to serialize message"))?; - println!("identity_keys: {:?}", identity_keys.public_key.to_string()); + println!( + "identity_keys: {:?}", + ctx.identity_keys.public_key.to_string() + ); - send_message_sync( - client, - Some(identity_keys), - trade_keys, - mostro_key, + send_dm( + &ctx.client, + Some(&ctx.identity_keys), + &ctx.trade_keys, + &ctx.mostro_pubkey, take_dispute_message, - true, + None, false, ) .await?; diff --git a/src/cli/take_order.rs b/src/cli/take_order.rs new file mode 100644 index 0000000..62fe4f5 --- /dev/null +++ b/src/cli/take_order.rs @@ -0,0 +1,148 @@ +use anyhow::Result; +use lnurl::lightning_address::LightningAddress; +use mostro_core::prelude::*; +use nostr_sdk::prelude::*; +use std::str::FromStr; +use uuid::Uuid; + +use crate::cli::Context; +use crate::lightning::is_valid_invoice; +use crate::util::{send_dm, wait_for_dm}; + +/// Create payload based on action type and parameters +fn create_take_order_payload( + action: Action, + invoice: &Option, + amount: Option, +) -> Result> { + match action { + Action::TakeBuy => Ok(amount.map(|amt: u32| Payload::Amount(amt as i64))), + Action::TakeSell => Ok(Some(match invoice { + Some(inv) => { + let initial_payload = match LightningAddress::from_str(inv) { + Ok(_) => Payload::PaymentRequest(None, inv.to_string(), None), + Err(_) => match is_valid_invoice(inv) { + Ok(i) => Payload::PaymentRequest(None, i.to_string(), None), + Err(e) => { + println!("{}", e); + Payload::PaymentRequest(None, inv.to_string(), None) + } + }, + }; + + match amount { + Some(amt) => match initial_payload { + Payload::PaymentRequest(a, b, _) => { + Payload::PaymentRequest(a, b, Some(amt as i64)) + } + payload => payload, + }, + None => initial_payload, + } + } + None => amount + .map(|amt| Payload::Amount(amt.into())) + .unwrap_or(Payload::Amount(0)), + })), + _ => Err(anyhow::anyhow!("Invalid action for take order")), + } +} + +/// Unified function to handle both take buy and take sell orders +#[allow(clippy::too_many_arguments)] +pub async fn execute_take_order( + order_id: &Uuid, + action: Action, + invoice: &Option, + amount: Option, + ctx: &Context, +) -> Result<()> { + let action_name = match action { + Action::TakeBuy => "take buy", + Action::TakeSell => "take sell", + _ => return Err(anyhow::anyhow!("Invalid action for take order")), + }; + + println!( + "Request of {} order {} from mostro pubId {}", + action_name, order_id, ctx.mostro_pubkey + ); + + // Create payload based on action type + let payload = create_take_order_payload(action.clone(), invoice, amount)?; + + let request_id = Uuid::new_v4().as_u128() as u64; + + // Create message + let take_order_message = Message::new_order( + Some(*order_id), + Some(request_id), + Some(ctx.trade_index), + action.clone(), + payload, + ); + + // Send dm to receiver pubkey + println!( + "SENDING DM with trade keys: {:?}", + ctx.trade_keys.public_key().to_hex() + ); + + let message_json = take_order_message + .as_json() + .map_err(|_| anyhow::anyhow!("Failed to serialize message"))?; + + // Clone the keys and client for the async call + let identity_keys_clone = ctx.identity_keys.clone(); + let trade_keys_clone = ctx.trade_keys.clone(); + let client_clone = ctx.client.clone(); + let mostro_pubkey_clone = ctx.mostro_pubkey; + + // Subscribe to gift wrap events - ONLY NEW ONES WITH LIMIT 0 + let subscription = Filter::new() + .pubkey(ctx.trade_keys.public_key()) + .kind(nostr_sdk::Kind::GiftWrap) + .limit(0); + + let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::WaitForEvents(1)); + ctx.client.subscribe(subscription, Some(opts)).await?; + + // Spawn a new task to send the DM + // This is so we can wait for the gift wrap event in the main thread + tokio::spawn(async move { + let _ = send_dm( + &client_clone, + Some(&identity_keys_clone), + &trade_keys_clone, + &mostro_pubkey_clone, + message_json, + None, + false, + ) + .await; + }); + + // For take_sell, add an additional subscription with timestamp filtering + if action == Action::TakeSell { + let subscription = Filter::new() + .pubkey(ctx.trade_keys.public_key()) + .kind(nostr_sdk::Kind::GiftWrap) + .since(Timestamp::from(chrono::Utc::now().timestamp() as u64)) + .limit(0); + + ctx.client.subscribe(subscription, None).await?; + } + + // Wait for the DM to be sent from mostro + wait_for_dm( + &ctx.client, + &ctx.trade_keys, + request_id, + Some(ctx.trade_index), + None, + &ctx.pool, + ) + .await?; + + Ok(()) +} diff --git a/src/cli/take_sell.rs b/src/cli/take_sell.rs deleted file mode 100644 index b1a50b7..0000000 --- a/src/cli/take_sell.rs +++ /dev/null @@ -1,137 +0,0 @@ -use anyhow::Result; -use lnurl::lightning_address::LightningAddress; -use mostro_core::prelude::*; - -use nostr_sdk::prelude::*; -use std::str::FromStr; -use uuid::Uuid; - -use crate::db::{connect, Order, User}; -use crate::lightning::is_valid_invoice; -use crate::util::send_message_sync; - -#[allow(clippy::too_many_arguments)] -pub async fn execute_take_sell( - order_id: &Uuid, - invoice: &Option, - amount: Option, - identity_keys: &Keys, - trade_keys: &Keys, - trade_index: i64, - mostro_key: PublicKey, - client: &Client, -) -> Result<()> { - println!( - "Request of take sell order {} from mostro pubId {}", - order_id, - mostro_key.clone() - ); - - let payload = match invoice { - Some(inv) => { - let initial_payload = match LightningAddress::from_str(inv) { - Ok(_) => Payload::PaymentRequest(None, inv.to_string(), None), - Err(_) => match is_valid_invoice(inv) { - Ok(i) => Payload::PaymentRequest(None, i.to_string(), None), - Err(e) => { - println!("{}", e); - Payload::PaymentRequest(None, inv.to_string(), None) // or handle error differently - } - }, - }; - - match amount { - Some(amt) => match initial_payload { - Payload::PaymentRequest(a, b, _) => { - Payload::PaymentRequest(a, b, Some(amt as i64)) - } - payload => payload, - }, - None => initial_payload, - } - } - None => amount - .map(|amt| Payload::Amount(amt.into())) - .unwrap_or(Payload::Amount(0)), - }; - - let request_id = Uuid::new_v4().as_u128() as u64; - // Create takesell message - let take_sell_message = Message::new_order( - Some(*order_id), - Some(request_id), - Some(trade_index), - Action::TakeSell, - Some(payload), - ); - - let dm = send_message_sync( - client, - Some(identity_keys), - trade_keys, - mostro_key, - take_sell_message, - true, - false, - ) - .await?; - let pool = connect().await?; - - let order = dm.iter().find_map(|el| { - let message = el.0.get_inner_message_kind(); - if message.request_id == Some(request_id) { - match message.action { - Action::AddInvoice => { - if let Some(Payload::Order(order)) = message.payload.as_ref() { - println!( - "Please add a lightning invoice with amount of {}", - order.amount - ); - return Some(order.clone()); - } - } - Action::CantDo => { - if let Some(Payload::CantDo(Some(cant_do_reason))) = &message.payload { - match cant_do_reason { - CantDoReason::OutOfRangeFiatAmount | CantDoReason::OutOfRangeSatsAmount => { - println!("Error: Amount is outside the allowed range. Please check the order's min/max limits."); - } - _ => { - println!("Unknown reason: {:?}", message.payload); - } - } - } else { - println!("Unknown reason: {:?}", message.payload); - return None; - } - } - _ => { - println!("Unknown action: {:?}", message.action); - return None; - } - } - } - None - }); - if let Some(o) = order { - if let Ok(order) = Order::new(&pool, o, trade_keys, Some(request_id as i64)).await { - if let Some(order_id) = order.id { - println!("Order {} created", order_id); - } else { - println!("Warning: The newly created order has no ID."); - } - // Update last trade index to be used in next trade - match User::get(&pool).await { - Ok(mut user) => { - user.set_last_trade_index(trade_index); - if let Err(e) = user.save(&pool).await { - println!("Failed to update user: {}", e); - } - } - Err(e) => println!("Failed to get user: {}", e), - } - } - } - - Ok(()) -} diff --git a/src/db.rs b/src/db.rs index e5f0a8f..f5d9e04 100644 --- a/src/db.rs +++ b/src/db.rs @@ -476,13 +476,6 @@ impl Order { Ok(order) } - pub async fn get_all(pool: &SqlitePool) -> Result> { - let orders = sqlx::query_as::<_, Order>(r#"SELECT * FROM orders"#) - .fetch_all(pool) - .await?; - Ok(orders) - } - pub async fn get_all_trade_keys(pool: &SqlitePool) -> Result> { let trade_keys: Vec = sqlx::query_scalar::<_, Option>( "SELECT DISTINCT trade_keys FROM orders WHERE trade_keys IS NOT NULL", diff --git a/src/fiat.rs b/src/fiat.rs index a7d01be..238907b 100644 --- a/src/fiat.rs +++ b/src/fiat.rs @@ -1453,14 +1453,12 @@ pub fn load_fiat_values() -> FiatList { } }"#; - let fiat_json: FiatNames = serde_json::from_str(fiat_names).unwrap(); - + // Parse fiat names + let fiat_json = serde_json::from_str(fiat_names).map_err(|e| anyhow::anyhow!("Failed to parse fiat names: {}", e))?; let mut fiatlist = FiatList::new(); - for elem in fiat_json.iter() { fiatlist.push((elem.0.to_string(), elem.1.name.clone())); - } - + //Return list fiatlist.sort_by(|a, b| a.0.cmp(&b.0)); diff --git a/src/lib.rs b/src/lib.rs index ce39579..14986a5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,5 +3,5 @@ pub mod db; pub mod error; pub mod lightning; pub mod nip33; -pub mod pretty_table; +pub mod parser; pub mod util; diff --git a/src/nip33.rs b/src/nip33.rs index e566888..3909132 100644 --- a/src/nip33.rs +++ b/src/nip33.rs @@ -62,23 +62,26 @@ pub fn dispute_from_tags(tags: Tags) -> Result { let mut dispute = Dispute::default(); for tag in tags { let t = tag.to_vec(); - let v = t.get(1).unwrap().as_str(); - match t.first().unwrap().as_str() { + + // Check if tag has at least 2 elements + if t.len() < 2 { + continue; + } + + let key = t.first().map(|s| s.as_str()).unwrap_or(""); + let value = t.get(1).map(|s| s.as_str()).unwrap_or(""); + + match key { "d" => { - let id = t.get(1).unwrap().as_str().parse::(); - let id = match id { - core::result::Result::Ok(id) => id, - Err(_) => return Err(anyhow::anyhow!("Invalid dispute id")), - }; + let id = value + .parse::() + .map_err(|_| anyhow::anyhow!("Invalid dispute id"))?; dispute.id = id; } "s" => { - let status = match DisputeStatus::from_str(v) { - core::result::Result::Ok(status) => status, - Err(_) => return Err(anyhow::anyhow!("Invalid dispute status")), - }; - + let status = DisputeStatus::from_str(value) + .map_err(|_| anyhow::anyhow!("Invalid dispute status"))?; dispute.status = status.to_string(); } diff --git a/src/parser/disputes.rs b/src/parser/disputes.rs new file mode 100644 index 0000000..3e409f1 --- /dev/null +++ b/src/parser/disputes.rs @@ -0,0 +1,122 @@ +use anyhow::Result; +use chrono::DateTime; +use comfy_table::presets::UTF8_FULL; +use comfy_table::*; +use log::info; +use mostro_core::prelude::*; +use nostr_sdk::prelude::*; + +use crate::util::Event; + +use crate::nip33::dispute_from_tags; + +pub fn parse_dispute_events(events: Events) -> Vec { + // Extracted Disputes List + let mut disputes_list = Vec::::new(); + + // Scan events to extract all disputes + for event in events.into_iter() { + if let Ok(mut dispute) = dispute_from_tags(event.tags) { + info!("Found Dispute id : {:?}", dispute.id); + // Get created at field from Nostr event + dispute.created_at = event.created_at.as_u64() as i64; + disputes_list.push(dispute.clone()); + } + } + + let buffer_dispute_list = disputes_list.clone(); + // Order all element ( orders ) received to filter - discard disaligned messages + // if an order has an older message with the state we received is discarded for the latest one + disputes_list.retain(|keep| { + !buffer_dispute_list + .iter() + .any(|x| x.id == keep.id && x.created_at > keep.created_at) + }); + + // Sort by id to remove duplicates + disputes_list.sort_by(|a, b| b.id.cmp(&a.id)); + disputes_list.dedup_by(|a, b| a.id == b.id); + + // Finally sort list by creation time + disputes_list.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + disputes_list +} + +pub fn print_disputes_table(disputes_table: Vec) -> Result { + // Convert Event to Dispute + let disputes_table: Vec = disputes_table + .into_iter() + .filter_map(|event| { + if let Event::Dispute(dispute) = event { + Some(dispute) + } else { + None + } + }) + .collect(); + + // Create table + let mut table = Table::new(); + //Table rows + let mut rows: Vec = Vec::new(); + + if disputes_table.is_empty() { + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_width(160) + .set_header(vec![Cell::new("Sorry...") + .add_attribute(Attribute::Bold) + .set_alignment(CellAlignment::Center)]); + + // Single row for error + let mut r = Row::new(); + + r.add_cell( + Cell::new("No disputes found with requested parameters...") + .fg(Color::Red) + .set_alignment(CellAlignment::Center), + ); + + //Push single error row + rows.push(r); + } else { + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_width(160) + .set_header(vec![ + Cell::new("Dispute Id") + .add_attribute(Attribute::Bold) + .set_alignment(CellAlignment::Center), + Cell::new("Status") + .add_attribute(Attribute::Bold) + .set_alignment(CellAlignment::Center), + Cell::new("Created") + .add_attribute(Attribute::Bold) + .set_alignment(CellAlignment::Center), + ]); + + //Iterate to create table of orders + for single_dispute in disputes_table.into_iter() { + let date = DateTime::from_timestamp(single_dispute.created_at, 0); + + let r = Row::from(vec![ + Cell::new(single_dispute.id).set_alignment(CellAlignment::Center), + Cell::new(single_dispute.status.to_string()).set_alignment(CellAlignment::Center), + Cell::new( + date.map(|d| d.to_string()) + .unwrap_or_else(|| "Invalid date".to_string()), + ), + ]); + rows.push(r); + } + } + + table.add_rows(rows); + + Ok(table.to_string()) +} + +#[cfg(test)] +mod tests {} diff --git a/src/parser/dms.rs b/src/parser/dms.rs new file mode 100644 index 0000000..f4a1466 --- /dev/null +++ b/src/parser/dms.rs @@ -0,0 +1,183 @@ +use std::collections::HashSet; + +use anyhow::Result; +use base64::engine::general_purpose; +use base64::Engine; +use chrono::DateTime; +use mostro_core::prelude::*; +use nip44::v2::{decrypt_to_bytes, ConversationKey}; +use nostr_sdk::prelude::*; + +use crate::db::{Order, User}; +use sqlx::SqlitePool; + +pub async fn parse_dm_events(events: Events, pubkey: &Keys) -> Vec<(Message, u64, PublicKey)> { + let mut id_set = HashSet::::new(); + let mut direct_messages: Vec<(Message, u64, PublicKey)> = Vec::new(); + + for dm in events.iter() { + // Skip if already processed + if !id_set.insert(dm.id) { + continue; + } + + let (created_at, message) = match dm.kind { + nostr_sdk::Kind::GiftWrap => { + let unwrapped_gift = match nip59::extract_rumor(pubkey, dm).await { + Ok(u) => u, + Err(_) => { + println!("Error unwrapping gift"); + continue; + } + }; + let (message, _): (Message, Option) = + match serde_json::from_str(&unwrapped_gift.rumor.content) { + Ok(msg) => msg, + Err(_) => { + println!("Error parsing gift wrap content"); + continue; + } + }; + (unwrapped_gift.rumor.created_at, message) + } + nostr_sdk::Kind::PrivateDirectMessage => { + let ck = if let Ok(ck) = ConversationKey::derive(pubkey.secret_key(), &dm.pubkey) { + ck + } else { + continue; + }; + let b64decoded_content = + match general_purpose::STANDARD.decode(dm.content.as_bytes()) { + Ok(b64decoded_content) => b64decoded_content, + Err(_) => { + continue; + } + }; + let unencrypted_content = match decrypt_to_bytes(&ck, &b64decoded_content) { + Ok(bytes) => bytes, + Err(_) => { + continue; + } + }; + let message_str = match String::from_utf8(unencrypted_content) { + Ok(s) => s, + Err(_) => { + continue; + } + }; + let message = match Message::from_json(&message_str) { + Ok(m) => m, + Err(_) => { + continue; + } + }; + (dm.created_at, message) + } + _ => continue, + }; + + let since_time = match chrono::Utc::now().checked_sub_signed(chrono::Duration::minutes(30)) + { + Some(dt) => dt.timestamp() as u64, + None => { + println!("Error: Unable to calculate time 30 minutes ago"); + continue; + } + }; + if created_at.as_u64() < since_time { + continue; + } + direct_messages.push((message, created_at.as_u64(), dm.pubkey)); + } + direct_messages.sort_by(|a, b| a.1.cmp(&b.1)); + direct_messages +} + +pub async fn print_direct_messages(dm: &[(Message, u64)], pool: &SqlitePool) -> Result<()> { + if dm.is_empty() { + println!(); + println!("No new messages"); + println!(); + } else { + for m in dm.iter() { + let message = m.0.get_inner_message_kind(); + let date = match DateTime::from_timestamp(m.1 as i64, 0) { + Some(dt) => dt, + None => { + println!("Error: Invalid timestamp {}", m.1); + continue; + } + }; + if let Some(order_id) = message.id { + println!( + "Mostro sent you this message for order id: {} at {}", + order_id, date + ); + } + if let Some(payload) = &message.payload { + match payload { + Payload::PaymentRequest(_, inv, _) => { + println!(); + println!("Pay this invoice to continue --> {}", inv); + println!(); + } + Payload::TextMessage(text) => { + println!(); + println!("{text}"); + println!(); + } + Payload::Dispute(id, info) => { + println!("Action: {}", message.action); + println!("Dispute id: {}", id); + if let Some(info) = info { + println!(); + println!("Dispute info: {:#?}", info); + println!(); + } + } + Payload::CantDo(Some(cant_do_reason)) => { + println!(); + println!("Error: {:?}", cant_do_reason); + println!(); + } + Payload::Order(new_order) if message.action == Action::NewOrder => { + if let Some(order_id) = new_order.id { + let db_order = Order::get_by_id(pool, &order_id.to_string()).await; + if db_order.is_err() { + if let Some(trade_index) = message.trade_index { + let trade_keys = + User::get_trade_keys(pool, trade_index).await?; + let _ = Order::new(pool, new_order.clone(), &trade_keys, None) + .await + .map_err(|e| { + anyhow::anyhow!("Failed to create DB order: {:?}", e) + })?; + } else { + println!("Warning: No trade_index found for new order"); + } + } + } + println!(); + println!("Order: {:#?}", new_order); + println!(); + } + _ => { + println!(); + println!("Action: {}", message.action); + println!("Payload: {:#?}", message.payload); + println!(); + } + } + } else { + println!(); + println!("Action: {}", message.action); + println!("Payload: {:#?}", message.payload); + println!(); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests {} diff --git a/src/parser/mod.rs b/src/parser/mod.rs new file mode 100644 index 0000000..3bc1427 --- /dev/null +++ b/src/parser/mod.rs @@ -0,0 +1,7 @@ +pub mod disputes; +pub mod dms; +pub mod orders; + +pub use disputes::parse_dispute_events; +pub use dms::parse_dm_events; +pub use orders::parse_orders_events; diff --git a/src/pretty_table.rs b/src/parser/orders.rs similarity index 61% rename from src/pretty_table.rs rename to src/parser/orders.rs index 83b88a5..ff6134e 100644 --- a/src/pretty_table.rs +++ b/src/parser/orders.rs @@ -1,8 +1,77 @@ +use std::collections::HashMap; + +use crate::util::Event; use anyhow::Result; use chrono::DateTime; use comfy_table::presets::UTF8_FULL; use comfy_table::*; +use log::{error, info}; use mostro_core::prelude::*; +use nostr_sdk::prelude::*; +use uuid::Uuid; + +use crate::nip33::order_from_tags; + +pub fn parse_orders_events( + events: Events, + currency: Option, + status: Option, + kind: Option, +) -> Vec { + // HashMap to store the latest order by id + let mut latest_by_id: HashMap = HashMap::new(); + + for event in events.iter() { + // Get order from tags + let mut order = match order_from_tags(event.tags.clone()) { + Ok(o) => o, + Err(e) => { + error!("{e:?}"); + continue; + } + }; + // Get order id + let order_id = match order.id { + Some(id) => id, + None => { + info!("Order ID is none"); + continue; + } + }; + // Check if order kind is none + if order.kind.is_none() { + info!("Order kind is none"); + continue; + } + // Set created at + order.created_at = Some(event.created_at.as_u64() as i64); + // Update latest order by id + latest_by_id + .entry(order_id) + .and_modify(|existing| { + let new_ts = order.created_at.unwrap_or(0); + let old_ts = existing.created_at.unwrap_or(0); + if new_ts > old_ts { + *existing = order.clone(); + } + }) + .or_insert(order); + } + + let mut requested: Vec = latest_by_id + .into_values() + .filter(|o| status.map(|s| o.status == Some(s)).unwrap_or(true)) + .filter(|o| currency.as_ref().map(|c| o.fiat_code == *c).unwrap_or(true)) + .filter(|o| { + kind.as_ref() + .map(|k| o.kind.as_ref() == Some(k)) + .unwrap_or(true) + }) + .collect(); + + requested.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + requested +} pub fn print_order_preview(ord: Payload) -> Result { let single_order = match ord { @@ -41,10 +110,10 @@ pub fn print_order_preview(ord: Payload) -> Result { let r = Row::from(vec![ if let Some(k) = single_order.kind { match k { - Kind::Buy => Cell::new(k.to_string()) + mostro_core::order::Kind::Buy => Cell::new(k.to_string()) .fg(Color::Green) .set_alignment(CellAlignment::Center), - Kind::Sell => Cell::new(k.to_string()) + mostro_core::order::Kind::Sell => Cell::new(k.to_string()) .fg(Color::Red) .set_alignment(CellAlignment::Center), } @@ -61,11 +130,12 @@ pub fn print_order_preview(ord: Payload) -> Result { if single_order.min_amount.is_none() && single_order.max_amount.is_none() { Cell::new(single_order.fiat_amount.to_string()).set_alignment(CellAlignment::Center) } else { - let range_str = format!( - "{}-{}", - single_order.min_amount.unwrap(), - single_order.max_amount.unwrap() - ); + let range_str = match (single_order.min_amount, single_order.max_amount) { + (Some(min), Some(max)) => format!("{}-{}", min, max), + (Some(min), None) => format!("{}-?", min), + (None, Some(max)) => format!("?-{}", max), + (None, None) => "?".to_string(), + }; Cell::new(range_str).set_alignment(CellAlignment::Center) }, Cell::new(single_order.payment_method.to_string()).set_alignment(CellAlignment::Center), @@ -77,8 +147,19 @@ pub fn print_order_preview(ord: Payload) -> Result { Ok(table.to_string()) } -pub fn print_orders_table(orders_table: Vec) -> Result { +pub fn print_orders_table(orders_table: Vec) -> Result { let mut table = Table::new(); + // Convert Event to SmallOrder + let orders_table: Vec = orders_table + .into_iter() + .filter_map(|event| { + if let Event::SmallOrder(order) = event { + Some(order) + } else { + None + } + }) + .collect(); //Table rows let mut rows: Vec = Vec::new(); @@ -142,19 +223,30 @@ pub fn print_orders_table(orders_table: Vec) -> Result { let r = Row::from(vec![ if let Some(k) = single_order.kind { match k { - Kind::Buy => Cell::new(k.to_string()) + mostro_core::order::Kind::Buy => Cell::new(k.to_string()) .fg(Color::Green) .set_alignment(CellAlignment::Center), - Kind::Sell => Cell::new(k.to_string()) + mostro_core::order::Kind::Sell => Cell::new(k.to_string()) .fg(Color::Red) .set_alignment(CellAlignment::Center), } } else { Cell::new("BUY/SELL").set_alignment(CellAlignment::Center) }, - Cell::new(single_order.id.unwrap()).set_alignment(CellAlignment::Center), - Cell::new(single_order.status.unwrap().to_string()) - .set_alignment(CellAlignment::Center), + Cell::new( + single_order + .id + .map(|id| id.to_string()) + .unwrap_or_else(|| "N/A".to_string()), + ) + .set_alignment(CellAlignment::Center), + Cell::new( + single_order + .status + .unwrap_or(mostro_core::order::Status::Active) + .to_string(), + ) + .set_alignment(CellAlignment::Center), if single_order.amount == 0 { Cell::new("market price").set_alignment(CellAlignment::Center) } else { @@ -166,16 +258,20 @@ pub fn print_orders_table(orders_table: Vec) -> Result { Cell::new(single_order.fiat_amount.to_string()) .set_alignment(CellAlignment::Center) } else { - let range_str = format!( - "{}-{}", - single_order.min_amount.unwrap(), - single_order.max_amount.unwrap() - ); + let range_str = match (single_order.min_amount, single_order.max_amount) { + (Some(min), Some(max)) => format!("{}-{}", min, max), + (Some(min), None) => format!("{}-?", min), + (None, Some(max)) => format!("?-{}", max), + (None, None) => "?".to_string(), + }; Cell::new(range_str).set_alignment(CellAlignment::Center) }, Cell::new(single_order.payment_method.to_string()) .set_alignment(CellAlignment::Center), - Cell::new(date.unwrap()), + Cell::new( + date.map(|d| d.to_string()) + .unwrap_or_else(|| "Invalid date".to_string()), + ), ]); rows.push(r); } @@ -186,63 +282,5 @@ pub fn print_orders_table(orders_table: Vec) -> Result { Ok(table.to_string()) } -pub fn print_disputes_table(disputes_table: Vec) -> Result { - let mut table = Table::new(); - - //Table rows - let mut rows: Vec = Vec::new(); - - if disputes_table.is_empty() { - table - .load_preset(UTF8_FULL) - .set_content_arrangement(ContentArrangement::Dynamic) - .set_width(160) - .set_header(vec![Cell::new("Sorry...") - .add_attribute(Attribute::Bold) - .set_alignment(CellAlignment::Center)]); - - // Single row for error - let mut r = Row::new(); - - r.add_cell( - Cell::new("No disputes found with requested parameters...") - .fg(Color::Red) - .set_alignment(CellAlignment::Center), - ); - - //Push single error row - rows.push(r); - } else { - table - .load_preset(UTF8_FULL) - .set_content_arrangement(ContentArrangement::Dynamic) - .set_width(160) - .set_header(vec![ - Cell::new("Dispute Id") - .add_attribute(Attribute::Bold) - .set_alignment(CellAlignment::Center), - Cell::new("Status") - .add_attribute(Attribute::Bold) - .set_alignment(CellAlignment::Center), - Cell::new("Created") - .add_attribute(Attribute::Bold) - .set_alignment(CellAlignment::Center), - ]); - - //Iterate to create table of orders - for single_dispute in disputes_table.into_iter() { - let date = DateTime::from_timestamp(single_dispute.created_at, 0); - - let r = Row::from(vec![ - Cell::new(single_dispute.id).set_alignment(CellAlignment::Center), - Cell::new(single_dispute.status.to_string()).set_alignment(CellAlignment::Center), - Cell::new(date.unwrap()), - ]); - rows.push(r); - } - } - - table.add_rows(rows); - - Ok(table.to_string()) -} +#[cfg(test)] +mod tests {} diff --git a/src/util.rs b/src/util.rs index 4b388d3..0d50db1 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,16 +1,38 @@ -use crate::nip33::{dispute_from_tags, order_from_tags}; - +use crate::cli::send_msg::execute_send_msg; +use crate::cli::{Commands, Context}; +use crate::db::{Order, User}; +use crate::parser::{parse_dispute_events, parse_dm_events, parse_orders_events}; use anyhow::{Error, Result}; use base64::engine::general_purpose; use base64::Engine; use dotenvy::var; -use log::{error, info}; +use log::info; use mostro_core::prelude::*; -use nip44::v2::{decrypt_to_bytes, encrypt_to_bytes, ConversationKey}; +use nip44::v2::{encrypt_to_bytes, ConversationKey}; use nostr_sdk::prelude::*; -use std::thread::sleep; +use sqlx::SqlitePool; use std::time::Duration; use std::{fs, path::Path}; +use uuid::Uuid; + +const FAKE_SINCE: i64 = 2880; +const FETCH_EVENTS_TIMEOUT: Duration = Duration::from_secs(15); + +#[derive(Clone, Debug)] +pub enum Event { + SmallOrder(SmallOrder), + Dispute(Dispute), // Assuming you have a Dispute struct + MessageTuple(Box<(Message, u64)>), +} + +#[derive(Clone, Debug)] +pub enum ListKind { + Orders, + Disputes, + DirectMessagesUser, + DirectMessagesAdmin, + PrivateDirectMessagesUser, +} async fn send_gift_wrap_dm_internal( client: &Client, @@ -23,7 +45,7 @@ async fn send_gift_wrap_dm_internal( .unwrap_or_else(|_| "0".to_string()) .parse() .unwrap_or(0); - + // Create Message struct for consistency with Mostro protocol let dm_message = Message::new_dm( None, @@ -31,22 +53,25 @@ async fn send_gift_wrap_dm_internal( Action::SendDm, Some(Payload::TextMessage(message.to_string())), ); - + // Serialize as JSON with the expected format (Message, Option) let content = serde_json::to_string(&(dm_message, None::))?; - + // Create the rumor with JSON content let rumor = EventBuilder::text_note(content) .pow(pow) .build(sender_keys.public_key()); - + // Create gift wrap using sender_keys as the signing key let event = EventBuilder::gift_wrap(sender_keys, receiver_pubkey, rumor, Tags::new()).await?; - + let sender_type = if is_admin { "admin" } else { "user" }; - info!("Sending {} gift wrap event to {}", sender_type, receiver_pubkey); + info!( + "Sending {} gift wrap event to {}", + sender_type, receiver_pubkey + ); client.send_event(&event).await?; - + Ok(()) } @@ -68,6 +93,281 @@ pub async fn send_gift_wrap_dm( send_gift_wrap_dm_internal(client, trade_keys, receiver_pubkey, message, false).await } +pub async fn save_order( + order: SmallOrder, + trade_keys: &Keys, + request_id: u64, + trade_index: Option, + pool: &SqlitePool, +) -> Result<()> { + if let Ok(order) = Order::new(pool, order, trade_keys, Some(request_id as i64)).await { + if let Some(order_id) = order.id { + println!("Order {} created", order_id); + } else { + println!("Warning: The newly created order has no ID."); + } + // Get trade index - we must have it + let trade_index = if let Some(trade_index) = trade_index { + trade_index + } else { + return Err(anyhow::anyhow!( + "No trade index found for new order, this should never happen" + )); + }; + + // Update last trade index to be used in next trade + match User::get(pool).await { + Ok(mut user) => { + user.set_last_trade_index(trade_index); + if let Err(e) = user.save(pool).await { + println!("Failed to update user: {}", e); + } + } + Err(e) => println!("Failed to get user: {}", e), + } + } + Ok(()) +} + +/// Wait for incoming gift wraps or events coming in +pub async fn wait_for_dm( + client: &Client, + trade_keys: &Keys, + request_id: u64, + trade_index: Option, + mut order: Option, + pool: &SqlitePool, +) -> anyhow::Result<()> { + let mut notifications = client.notifications(); + + match tokio::time::timeout(FETCH_EVENTS_TIMEOUT, async move { + while let Ok(notification) = notifications.recv().await { + if let RelayPoolNotification::Event { event, .. } = notification { + if event.kind == nostr_sdk::Kind::GiftWrap { + let gift = match nip59::extract_rumor(trade_keys, &event).await { + Ok(gift) => gift, + Err(e) => { + println!("Failed to extract rumor: {}", e); + continue; + } + }; + let (message, _): (Message, Option) = match serde_json::from_str(&gift.rumor.content) { + Ok(msg) => msg, + Err(e) => { + println!("Failed to deserialize message: {}", e); + continue; + } + }; + let message = message.get_inner_message_kind(); + if message.request_id == Some(request_id) { + match message.action { + Action::NewOrder => { + if let Some(Payload::Order(order)) = message.payload.as_ref() { + if let Err(e) = save_order(order.clone(), trade_keys, request_id, trade_index, pool).await { + println!("Failed to save order: {}", e); + return Err(()); + } + return Ok(()); + } + } + // this is the case where the buyer adds an invoice to a takesell order + Action::WaitingSellerToPay => { + println!("Now we should wait for the seller to pay the invoice"); + if let Some(mut order) = order.take() { + match order + .set_status(Status::WaitingPayment.to_string()) + .save(pool) + .await + { + Ok(_) => println!("Order status updated"), + Err(e) => println!("Failed to update order status: {}", e), + } + return Ok(()); + } + } + // this is the case where the buyer adds an invoice to a takesell order + Action::AddInvoice => { + if let Some(Payload::Order(order)) = &message.payload { + println!( + "Please add a lightning invoice with amount of {}", + order.amount + ); + // Save the order + if let Err(e) = save_order(order.clone(), trade_keys, request_id, trade_index, pool).await { + println!("Failed to save order: {}", e); + return Err(()); + } + return Ok(()); + } + } + // this is the case where the buyer pays the invoice coming from a takebuy + Action::PayInvoice => { + if let Some(Payload::PaymentRequest(order, invoice, _)) = &message.payload { + println!( + "Mostro sent you this hold invoice for order id: {}", + order + .as_ref() + .and_then(|o| o.id) + .map_or("unknown".to_string(), |id| id.to_string()) + ); + println!(); + println!("Pay this invoice to continue --> {}", invoice); + println!(); + if let Some(order) = order { + let store_order = order.clone(); + // Save the order + if let Err(e) = save_order(store_order, trade_keys, request_id, trade_index, pool).await { + println!("Failed to save order: {}", e); + return Err(()); + } + } + return Ok(()); + } + } + Action::CantDo => { + match message.payload { + Some(Payload::CantDo(Some(CantDoReason::OutOfRangeFiatAmount | CantDoReason::OutOfRangeSatsAmount))) => { + println!("Error: Amount is outside the allowed range. Please check the order's min/max limits."); + return Err(()); + } + Some(Payload::CantDo(Some(CantDoReason::PendingOrderExists))) => { + println!("Error: A pending order already exists. Please wait for it to be filled or canceled."); + return Err(()); + } + Some(Payload::CantDo(Some(CantDoReason::InvalidTradeIndex))) => { + println!("Error: Invalid trade index. Please synchronize the trade index with mostro"); + return Err(()); + } + _ => { + println!("Unknown reason: {:?}", message.payload); + return Err(()); + } + } + } + // this is the case where the user cancels the order + Action::Canceled => { + if let Some(order_id) = &message.id { + // Acquire database connection + // Verify order exists before deletion + if Order::get_by_id(pool, &order_id.to_string()).await.is_ok() { + if let Err(e) = Order::delete_by_id(pool, &order_id.to_string()).await { + println!("Failed to delete order: {}", e); + return Err(()); + } + // Release database connection + println!("Order {} canceled!", order_id); + return Ok(()); + } else { + println!("Order not found: {}", order_id); + return Err(()); + } + } + } + _ => {} + } + } + } + } + } + Ok(()) + }) + .await { + Ok(result) => match result { + Ok(()) => Ok(()), + Err(()) => Err(anyhow::anyhow!("Error in timeout closure")), + }, + Err(_) => Err(anyhow::anyhow!("Timeout waiting for DM or gift wrap event")) + } +} + +#[derive(Debug, Clone, Copy)] +enum MessageType { + PrivateDirectMessage, + PrivateGiftWrap, + SignedGiftWrap, +} + +fn determine_message_type(to_user: bool, private: bool) -> MessageType { + match (to_user, private) { + (true, _) => MessageType::PrivateDirectMessage, + (false, true) => MessageType::PrivateGiftWrap, + (false, false) => MessageType::SignedGiftWrap, + } +} + +fn create_expiration_tags(expiration: Option) -> Tags { + let mut tags: Vec = Vec::with_capacity(1 + usize::from(expiration.is_some())); + + if let Some(timestamp) = expiration { + tags.push(Tag::expiration(timestamp)); + } + + Tags::from_list(tags) +} + +async fn create_private_dm_event( + trade_keys: &Keys, + receiver_pubkey: &PublicKey, + payload: String, + pow: u8, +) -> Result { + // Derive conversation key + let ck = ConversationKey::derive(trade_keys.secret_key(), receiver_pubkey)?; + // Encrypt payload + let encrypted_content = encrypt_to_bytes(&ck, payload.as_bytes())?; + // Encode with base64 + let b64decoded_content = general_purpose::STANDARD.encode(encrypted_content); + // Compose builder + Ok( + EventBuilder::new(nostr_sdk::Kind::PrivateDirectMessage, b64decoded_content) + .pow(pow) + .tag(Tag::public_key(*receiver_pubkey)) + .sign_with_keys(trade_keys)?, + ) +} + +async fn create_gift_wrap_event( + trade_keys: &Keys, + identity_keys: Option<&Keys>, + receiver_pubkey: &PublicKey, + payload: String, + pow: u8, + expiration: Option, + signed: bool, +) -> Result { + let message = Message::from_json(&payload) + .map_err(|e| anyhow::anyhow!("Failed to deserialize message: {e}"))?; + + let content = if signed { + let _identity_keys = identity_keys + .ok_or_else(|| Error::msg("identity_keys required for signed messages"))?; + // We sign the message + let sig = Message::sign(payload, trade_keys); + serde_json::to_string(&(message, sig)) + .map_err(|e| anyhow::anyhow!("Failed to serialize message: {e}"))? + } else { + // We compose the content, when private we don't sign the payload + let content: (Message, Option) = (message, None); + serde_json::to_string(&content) + .map_err(|e| anyhow::anyhow!("Failed to serialize message: {e}"))? + }; + + // We create the rumor + let rumor = EventBuilder::text_note(content) + .pow(pow) + .build(trade_keys.public_key()); + + let tags = create_expiration_tags(expiration); + + let signer_keys = if signed { + identity_keys.ok_or_else(|| Error::msg("identity_keys required for signed messages"))? + } else { + trade_keys + }; + + Ok(EventBuilder::gift_wrap(signer_keys, receiver_pubkey, rumor, tags).await?) +} + pub async fn send_dm( client: &Client, identity_keys: Option<&Keys>, @@ -77,65 +377,48 @@ pub async fn send_dm( expiration: Option, to_user: bool, ) -> Result<()> { - let pow: u8 = var("POW").unwrap_or('0'.to_string()).parse().unwrap(); + let pow: u8 = var("POW") + .unwrap_or('0'.to_string()) + .parse() + .map_err(|e| anyhow::anyhow!("Failed to parse POW: {}", e))?; let private = var("SECRET") .unwrap_or("false".to_string()) .parse::() - .unwrap(); - let event = if to_user { - // Derive conversation key - let ck = ConversationKey::derive(trade_keys.secret_key(), receiver_pubkey)?; - // Encrypt payload - let encrypted_content = encrypt_to_bytes(&ck, payload.as_bytes())?; - // Encode with base64 - let b64decoded_content = general_purpose::STANDARD.encode(encrypted_content); - // Compose builder - EventBuilder::new(nostr_sdk::Kind::PrivateDirectMessage, b64decoded_content) - .pow(pow) - .tag(Tag::public_key(*receiver_pubkey)) - .sign_with_keys(trade_keys)? - } else if private { - let message = Message::from_json(&payload).unwrap(); - // We compose the content, when private we don't sign the payload - let content: (Message, Option) = (message, None); - let content = serde_json::to_string(&content).unwrap(); - // We create the rumor - let rumor = EventBuilder::text_note(content) - .pow(pow) - .build(trade_keys.public_key()); - let mut tags: Vec = Vec::with_capacity(1 + usize::from(expiration.is_some())); + .map_err(|e| anyhow::anyhow!("Failed to parse SECRET: {}", e))?; - if let Some(timestamp) = expiration { - tags.push(Tag::expiration(timestamp)); - } - let tags = Tags::from_list(tags); + let message_type = determine_message_type(to_user, private); - EventBuilder::gift_wrap(trade_keys, receiver_pubkey, rumor, tags).await? - } else { - let identity_keys = identity_keys - .ok_or_else(|| Error::msg("identity_keys required when to_user is false"))?; - // We sign the message - let message = Message::from_json(&payload).unwrap(); - let sig = Message::sign(payload.clone(), trade_keys); - // We compose the content - let content = serde_json::to_string(&(message, sig)).unwrap(); - // We create the rumor - let rumor = EventBuilder::text_note(content) - .pow(pow) - .build(trade_keys.public_key()); - let mut tags: Vec = Vec::with_capacity(1 + usize::from(expiration.is_some())); - - if let Some(timestamp) = expiration { - tags.push(Tag::expiration(timestamp)); + let event = match message_type { + MessageType::PrivateDirectMessage => { + create_private_dm_event(trade_keys, receiver_pubkey, payload, pow).await? + } + MessageType::PrivateGiftWrap => { + create_gift_wrap_event( + trade_keys, + identity_keys, + receiver_pubkey, + payload, + pow, + expiration, + false, + ) + .await? + } + MessageType::SignedGiftWrap => { + create_gift_wrap_event( + trade_keys, + identity_keys, + receiver_pubkey, + payload, + pow, + expiration, + true, + ) + .await? } - let tags = Tags::from_list(tags); - - EventBuilder::gift_wrap(identity_keys, receiver_pubkey, rumor, tags).await? }; - info!("Sending event: {event:#?}"); client.send_event(&event).await?; - Ok(()) } @@ -150,396 +433,210 @@ pub async fn connect_nostr() -> Result { for r in relays.into_iter() { client.add_relay(r).await?; } + // Connect to relays and keep connection alive client.connect().await; Ok(client) } -pub async fn send_message_sync( - client: &Client, - identity_keys: Option<&Keys>, - trade_keys: &Keys, - receiver_pubkey: PublicKey, - message: Message, - wait_for_dm: bool, - to_user: bool, -) -> Result> { - let message_json = message - .as_json() - .map_err(|_| Error::msg("Failed to serialize message"))?; - // Send dm to receiver pubkey - println!( - "SENDING DM with trade keys: {:?}", - trade_keys.public_key().to_hex() - ); - send_dm( - client, - identity_keys, - trade_keys, - &receiver_pubkey, - message_json, - None, - to_user, - ) - .await?; - // FIXME: This is a hack to wait for the DM to be sent - sleep(Duration::from_secs(2)); - - let dm: Vec<(Message, u64)> = if wait_for_dm { - get_direct_messages(client, trade_keys, 15, to_user, None).await - } else { - Vec::new() - }; - - Ok(dm) -} - pub async fn get_direct_messages_from_trade_keys( client: &Client, trade_keys_hex: Vec, since: i64, - mostro_pubkey: &PublicKey, -) -> Vec<(Message, u64, PublicKey)> { + _mostro_pubkey: &PublicKey, +) -> Result> { if trade_keys_hex.is_empty() { - return Vec::new(); + return Ok(Vec::new()); } - let fake_since = 2880; - let fake_since_time = chrono::Utc::now() - .checked_sub_signed(chrono::Duration::minutes(fake_since)) - .unwrap() - .timestamp() as u64; - let fake_timestamp = Timestamp::from(fake_since_time); - let since_time = chrono::Utc::now() .checked_sub_signed(chrono::Duration::minutes(since)) - .unwrap() - .timestamp() as u64; + .ok_or(anyhow::anyhow!("Failed to get since time"))? + .timestamp(); - let mut all_direct_messages: Vec<(Message, u64, PublicKey)> = Vec::new(); - let mut id_set = std::collections::HashSet::::new(); + // Get the triple of message, timestamp and public key + let mut all_messages: Vec<(Message, u64, PublicKey)> = Vec::new(); + // Fetch direct messages from trade keys and in case of since, we filter by since + // as bonus we also fetch the events from the admin pubkey in case is specified for trade_key_hex in trade_keys_hex { - if let Ok(trade_keys) = Keys::parse(&trade_key_hex) { - let filters = Filter::new() - .kind(nostr_sdk::Kind::GiftWrap) - .pubkey(trade_keys.public_key()) - .since(fake_timestamp); - - info!("Request events with event kind : {:?} for trade key: {}", - filters.kinds, trade_keys.public_key()); - - if let Ok(events) = client.fetch_events(filters, Duration::from_secs(15)).await { - for dm in events.iter() { - if !id_set.insert(dm.id) { - continue; // Already processed - } - - let unwrapped_gift = match nip59::extract_rumor(&trade_keys, dm).await { - Ok(u) => u, - Err(_) => { - error!("Error unwrapping gift for trade key: {}", trade_keys.public_key()); - continue; - } - }; - - // Filter: only process messages NOT from Mostro (user-to-user messages) - if unwrapped_gift.rumor.pubkey == *mostro_pubkey { - continue; // Skip Mostro messages - } - - if unwrapped_gift.rumor.created_at.as_u64() < since_time { + if let Ok(public_key) = PublicKey::from_hex(&trade_key_hex) { + // Create filter for fetching direct messages + let filter = + create_filter(ListKind::DirectMessagesUser, public_key, Some(&since_time))?; + let events = client.fetch_events(filter, FETCH_EVENTS_TIMEOUT).await?; + // Parse events without keys since we only have the public key + // We'll need to handle this differently - let's just collect the events for now + for event in events { + if let Ok(message) = Message::from_json(&event.content) { + if event.created_at.as_u64() < since as u64 { continue; } - - // Parse JSON content (all messages should be JSON now) - let (message, _): (Message, Option) = match serde_json::from_str(&unwrapped_gift.rumor.content) { - Ok(parsed) => parsed, - Err(_) => { - error!("Error parsing JSON content from: {}", unwrapped_gift.rumor.pubkey); - continue; - } - }; - - all_direct_messages.push(( - message, - unwrapped_gift.rumor.created_at.as_u64(), - unwrapped_gift.rumor.pubkey - )); + all_messages.push((message, event.created_at.as_u64(), event.pubkey)); } } - } else { - error!("Failed to parse trade key: {}", trade_key_hex); } } - - all_direct_messages.sort_by(|a, b| a.1.cmp(&b.1)); - all_direct_messages + Ok(all_messages) } -pub async fn get_direct_messages( - client: &Client, - my_key: &Keys, - since: i64, - from_user: bool, - mostro_pubkey: Option<&PublicKey>, -) -> Vec<(Message, u64)> { - // We use a fake timestamp to thwart time-analysis attacks - let fake_since = 2880; +/// Create a fake timestamp to thwart time-analysis attacks +fn create_fake_timestamp() -> Result { let fake_since_time = chrono::Utc::now() - .checked_sub_signed(chrono::Duration::minutes(fake_since)) - .unwrap() + .checked_sub_signed(chrono::Duration::minutes(FAKE_SINCE)) + .ok_or(anyhow::anyhow!("Failed to get fake since time"))? .timestamp() as u64; - - let fake_timestamp = Timestamp::from(fake_since_time); - let filters = if from_user { - let since_time = chrono::Utc::now() - .checked_sub_signed(chrono::Duration::minutes(since)) - .unwrap() - .timestamp() as u64; - let timestamp = Timestamp::from(since_time); - Filter::new() - .kind(nostr_sdk::Kind::PrivateDirectMessage) - .pubkey(my_key.public_key()) - .since(timestamp) - } else { - Filter::new() - .kind(nostr_sdk::Kind::GiftWrap) - .pubkey(my_key.public_key()) - .since(fake_timestamp) - }; - - info!("Request events with event kind : {:?} ", filters.kinds); - - let mut direct_messages: Vec<(Message, u64)> = Vec::new(); - - if let Ok(mostro_req) = client.fetch_events(filters, Duration::from_secs(15)).await { - // Buffer vector for direct messages - // Vector for single order id check - maybe multiple relay could send the same order id? Check unique one... - let mut id_list = Vec::::new(); - - for dm in mostro_req.iter() { - if !id_list.contains(&dm.id) { - id_list.push(dm.id); - let (created_at, message) = if from_user { - let ck = - if let Ok(ck) = ConversationKey::derive(my_key.secret_key(), &dm.pubkey) { - ck - } else { - continue; - }; - let b64decoded_content = - match general_purpose::STANDARD.decode(dm.content.as_bytes()) { - Ok(b64decoded_content) => b64decoded_content, - Err(_) => { - continue; - } - }; - - let unencrypted_content = decrypt_to_bytes(&ck, &b64decoded_content) - .expect("Failed to decrypt message"); - - let message = - String::from_utf8(unencrypted_content).expect("Found invalid UTF-8"); - let message = Message::from_json(&message).expect("Failed on deserializing"); - - (dm.created_at, message) - } else { - let unwrapped_gift = match nip59::extract_rumor(my_key, dm).await { - Ok(u) => u, - Err(_) => { - println!("Error unwrapping gift"); - continue; - } - }; - - // Filter: only process messages from Mostro - if let Some(mostro_pk) = mostro_pubkey { - if unwrapped_gift.rumor.pubkey != *mostro_pk { - continue; // Skip non-Mostro messages - } - } - - let (message, _): (Message, Option) = - serde_json::from_str(&unwrapped_gift.rumor.content).unwrap(); - - (unwrapped_gift.rumor.created_at, message) - }; - - // Here we discard messages older than the real since parameter - let since_time = chrono::Utc::now() - .checked_sub_signed(chrono::Duration::minutes(30)) - .unwrap() - .timestamp() as u64; - if created_at.as_u64() < since_time { - continue; - } - direct_messages.push((message, created_at.as_u64())); - } - } - // Return element sorted by second tuple element ( Timestamp ) - direct_messages.sort_by(|a, b| a.1.cmp(&b.1)); - } - - direct_messages + Ok(Timestamp::from(fake_since_time)) } -pub async fn get_orders_list( - pubkey: PublicKey, - status: Status, - currency: Option, - kind: Option, - client: &Client, -) -> Result> { +// Create a filter for fetching events in the last 7 days +fn create_seven_days_filter(letter: Alphabet, value: String, pubkey: PublicKey) -> Result { let since_time = chrono::Utc::now() .checked_sub_signed(chrono::Duration::days(7)) - .unwrap() + .ok_or(anyhow::anyhow!("Failed to get since days ago"))? .timestamp() as u64; let timestamp = Timestamp::from(since_time); - let filters = Filter::new() + Ok(Filter::new() .author(pubkey) .limit(50) .since(timestamp) - .custom_tag(SingleLetterTag::lowercase(Alphabet::Z), "order".to_string()) - .kind(nostr_sdk::Kind::Custom(NOSTR_REPLACEABLE_EVENT_KIND)); - - info!( - "Request to mostro id : {:?} with event kind : {:?} ", - filters.authors, filters.kinds - ); - - // Extracted Orders List - let mut complete_events_list = Vec::::new(); - let mut requested_orders_list = Vec::::new(); - - // Send all requests to relays - if let Ok(mostro_req) = client.fetch_events(filters, Duration::from_secs(15)).await { - // Scan events to extract all orders - for el in mostro_req.iter() { - let order = order_from_tags(el.tags.clone()); - - if order.is_err() { - error!("{order:?}"); - continue; - } - let mut order = order?; - - info!("Found Order id : {:?}", order.id.unwrap()); - - if order.id.is_none() { - info!("Order ID is none"); - continue; - } - - if order.kind.is_none() { - info!("Order kind is none"); - continue; - } - - if order.status.is_none() { - info!("Order status is none"); - continue; - } - - // Get created at field from Nostr event - order.created_at = Some(el.created_at.as_u64() as i64); - - complete_events_list.push(order.clone()); - - if order.status.ne(&Some(status)) { - continue; - } - - if currency.is_some() && order.fiat_code.ne(¤cy.clone().unwrap()) { - continue; - } + .custom_tag(SingleLetterTag::lowercase(letter), value) + .kind(nostr_sdk::Kind::Custom(NOSTR_REPLACEABLE_EVENT_KIND))) +} - if kind.is_some() && order.kind.ne(&kind) { - continue; - } - // Add just requested orders requested by filtering - requested_orders_list.push(order); +// Create a filter for fetching events +pub fn create_filter( + list_kind: ListKind, + pubkey: PublicKey, + since: Option<&i64>, +) -> Result { + match list_kind { + ListKind::Orders => create_seven_days_filter(Alphabet::Z, "order".to_string(), pubkey), + ListKind::Disputes => create_seven_days_filter(Alphabet::Z, "dispute".to_string(), pubkey), + ListKind::DirectMessagesAdmin | ListKind::DirectMessagesUser => { + // We use a fake timestamp to thwart time-analysis attacks + let fake_timestamp = create_fake_timestamp()?; + + Ok(Filter::new() + .kind(nostr_sdk::Kind::GiftWrap) + .pubkey(pubkey) + .since(fake_timestamp)) + } + ListKind::PrivateDirectMessagesUser => { + // Get since from cli or use 30 minutes default + let since = if let Some(mins) = since { + chrono::Utc::now() + .checked_sub_signed(chrono::Duration::minutes(*mins)) + .unwrap() + .timestamp() + } else { + chrono::Utc::now() + .checked_sub_signed(chrono::Duration::minutes(30)) + .unwrap() + .timestamp() + } as u64; + // Create filter for fetching privatedirect messages + Ok(Filter::new() + .kind(nostr_sdk::Kind::PrivateDirectMessage) + .pubkey(pubkey) + .since(Timestamp::from(since))) } } - - // Order all element ( orders ) received to filter - discard disaligned messages - // if an order has an older message with the state we received is discarded for the latest one - requested_orders_list.retain(|keep| { - !complete_events_list - .iter() - .any(|x| x.id == keep.id && x.created_at > keep.created_at) - }); - // Sort by id to remove duplicates - requested_orders_list.sort_by(|a, b| b.id.cmp(&a.id)); - requested_orders_list.dedup_by(|a, b| a.id == b.id); - - // Finally sort list by creation time - requested_orders_list.sort_by(|a, b| b.created_at.cmp(&a.created_at)); - - Ok(requested_orders_list) } -pub async fn get_disputes_list(pubkey: PublicKey, client: &Client) -> Result> { - let since_time = chrono::Utc::now() - .checked_sub_signed(chrono::Duration::days(7)) - .unwrap() - .timestamp() as u64; - - let timestamp = Timestamp::from(since_time); - - let filter = Filter::new() - .author(pubkey) - .limit(50) - .since(timestamp) - .custom_tag( - SingleLetterTag::lowercase(Alphabet::Z), - "dispute".to_string(), - ) - .kind(nostr_sdk::Kind::Custom(NOSTR_REPLACEABLE_EVENT_KIND)); - - // Extracted Orders List - let mut disputes_list = Vec::::new(); - - // Send all requests to relays - if let Ok(mostro_req) = client.fetch_events(filter, Duration::from_secs(15)).await { - // Scan events to extract all disputes - for d in mostro_req.iter() { - let dispute = dispute_from_tags(d.tags.clone()); - - if dispute.is_err() { - error!("{dispute:?}"); - continue; +#[allow(clippy::too_many_arguments)] +pub async fn fetch_events_list( + list_kind: ListKind, + status: Option, + currency: Option, + kind: Option, + ctx: &Context, + _since: Option<&i64>, +) -> Result> { + match list_kind { + ListKind::Orders => { + let filters = create_filter(list_kind, ctx.mostro_pubkey, None)?; + let fetched_events = ctx + .client + .fetch_events(filters, FETCH_EVENTS_TIMEOUT) + .await?; + let orders = parse_orders_events(fetched_events, currency, status, kind); + Ok(orders.into_iter().map(Event::SmallOrder).collect()) + } + ListKind::DirectMessagesAdmin => { + let filters = create_filter(list_kind, ctx.mostro_pubkey, None)?; + let fetched_events = ctx + .client + .fetch_events(filters, FETCH_EVENTS_TIMEOUT) + .await?; + let direct_messages_mostro = parse_dm_events(fetched_events, &ctx.context_keys).await; + Ok(direct_messages_mostro + .into_iter() + .map(|(message, timestamp, _)| Event::MessageTuple(Box::new((message, timestamp)))) + .collect()) + } + ListKind::PrivateDirectMessagesUser => { + let mut direct_messages: Vec<(Message, u64)> = Vec::new(); + for index in 1..=ctx.trade_index { + let trade_key = User::get_trade_keys(&ctx.pool, index).await?; + let filter = create_filter( + ListKind::PrivateDirectMessagesUser, + trade_key.public_key(), + None, + )?; + let fetched_user_messages = ctx + .client + .fetch_events(filter, FETCH_EVENTS_TIMEOUT) + .await?; + let direct_messages_for_trade_key = + parse_dm_events(fetched_user_messages, &trade_key).await; + direct_messages.extend( + direct_messages_for_trade_key + .into_iter() + .map(|(message, timestamp, _)| (message, timestamp)), + ); } - let mut dispute = dispute?; - - info!("Found Dispute id : {:?}", dispute.id); - - // Get created at field from Nostr event - dispute.created_at = d.created_at.as_u64() as i64; - disputes_list.push(dispute); + Ok(direct_messages + .into_iter() + .map(|t| Event::MessageTuple(Box::new(t))) + .collect()) + } + ListKind::DirectMessagesUser => { + let mut direct_messages: Vec<(Message, u64)> = Vec::new(); + for index in 1..=ctx.trade_index { + let trade_key = User::get_trade_keys(&ctx.pool, index).await?; + let filter = + create_filter(ListKind::DirectMessagesUser, trade_key.public_key(), None)?; + let fetched_user_messages = ctx + .client + .fetch_events(filter, FETCH_EVENTS_TIMEOUT) + .await?; + let direct_messages_for_trade_key = + parse_dm_events(fetched_user_messages, &trade_key).await; + direct_messages.extend( + direct_messages_for_trade_key + .into_iter() + .map(|(message, timestamp, _)| (message, timestamp)), + ); + } + Ok(direct_messages + .into_iter() + .map(|t| Event::MessageTuple(Box::new(t))) + .collect()) + } + ListKind::Disputes => { + let filters = create_filter(list_kind, ctx.mostro_pubkey, None)?; + let fetched_events = ctx + .client + .fetch_events(filters, FETCH_EVENTS_TIMEOUT) + .await?; + let disputes = parse_dispute_events(fetched_events); + Ok(disputes.into_iter().map(Event::Dispute).collect()) } } - - let buffer_dispute_list = disputes_list.clone(); - // Order all element ( orders ) received to filter - discard disaligned messages - // if an order has an older message with the state we received is discarded for the latest one - disputes_list.retain(|keep| { - !buffer_dispute_list - .iter() - .any(|x| x.id == keep.id && x.created_at > keep.created_at) - }); - - // Sort by id to remove duplicates - disputes_list.sort_by(|a, b| b.id.cmp(&a.id)); - disputes_list.dedup_by(|a, b| a.id == b.id); - - // Finally sort list by creation time - disputes_list.sort_by(|a, b| b.created_at.cmp(&a.created_at)); - - Ok(disputes_list) } /// Uppercase first letter of a string. @@ -558,6 +655,12 @@ pub fn get_mcli_path() -> String { fs::create_dir(&mcli_path).expect("Couldn't create mostro-cli directory in HOME"); println!("Directory {} created.", mcli_path); } - mcli_path } + +pub async fn run_simple_order_msg(command: Commands, order_id: &Uuid, ctx: &Context) -> Result<()> { + execute_send_msg(command, Some(*order_id), ctx, None).await +} + +#[cfg(test)] +mod tests {} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs new file mode 100644 index 0000000..c312fb6 --- /dev/null +++ b/tests/integration_tests.rs @@ -0,0 +1,71 @@ +use mostro_client::cli::Context; +use nostr_sdk::prelude::*; +use sqlx::SqlitePool; + +// Helper to create a test context for integration tests +async fn create_test_context() -> anyhow::Result { + let pool = SqlitePool::connect("sqlite::memory:").await?; + + // Generate test keys + let identity_keys = Keys::generate(); + let trade_keys = Keys::generate(); + let context_keys = Keys::generate(); + + // Create a test client + let client = Client::new(identity_keys.clone()); + + // Mock mostro pubkey + let mostro_pubkey = PublicKey::from_hex(&format!("02{}", "1".repeat(62)))?; + + Ok(Context { + client, + identity_keys, + trade_keys, + trade_index: 0, + pool, + context_keys, + mostro_pubkey, + }) +} + +#[tokio::test] +async fn test_context_creation() { + let result = create_test_context().await; + assert!(result.is_ok()); + + let ctx = result.unwrap(); + assert_eq!(ctx.trade_index, 0); +} + +#[tokio::test] +async fn test_context_fields_are_valid() { + let ctx = create_test_context().await.unwrap(); + + // Verify all required fields are present and valid + assert!(!ctx.identity_keys.public_key().to_hex().is_empty()); + assert!(!ctx.identity_keys.public_key().to_hex().is_empty()); + assert!(!ctx.trade_keys.public_key().to_hex().is_empty()); + assert!(!ctx.context_keys.public_key().to_hex().is_empty()); + assert!(!ctx.mostro_pubkey.to_hex().is_empty()); + assert!(!ctx.pool.is_closed()); +} + +#[tokio::test] +async fn test_filter_creation_integration() { + let ctx = create_test_context().await.unwrap(); + + let filter = mostro_client::util::create_filter( + mostro_client::util::ListKind::Orders, + ctx.mostro_pubkey, + None, + ) + .unwrap(); + + assert!(filter.kinds.is_some()); + assert!(filter.authors.is_some()); + assert!(filter + .authors + .as_ref() + .unwrap() + .contains(&ctx.mostro_pubkey)); +} diff --git a/tests/parser_disputes.rs b/tests/parser_disputes.rs new file mode 100644 index 0000000..0f4c30c --- /dev/null +++ b/tests/parser_disputes.rs @@ -0,0 +1,50 @@ +use mostro_client::parser::disputes::{parse_dispute_events, print_disputes_table}; +use mostro_core::prelude::*; +use nostr_sdk::prelude::*; + +fn build_dispute_event(id: uuid::Uuid, status: DisputeStatus) -> nostr_sdk::Event { + let keys = Keys::generate(); + let mut tags = Tags::new(); + tags.push(Tag::custom( + TagKind::Custom("d".into()), + vec![id.to_string()], + )); + tags.push(Tag::custom( + TagKind::Custom("y".into()), + vec!["dispute".to_string()], + )); + tags.push(Tag::custom( + TagKind::Custom("s".into()), + vec![status.to_string()], + )); + EventBuilder::new(nostr_sdk::Kind::TextNote, "") + .tags(tags) + .sign_with_keys(&keys) + .unwrap() +} + +#[test] +fn parse_disputes_empty() { + let filter = Filter::new(); + let events = Events::new(&filter); + let out = parse_dispute_events(events); + assert!(out.is_empty()); +} + +#[test] +fn parse_disputes_basic_and_print() { + let filter = Filter::new(); + let id = uuid::Uuid::new_v4(); + let e = build_dispute_event(id, DisputeStatus::Initiated); + let mut events = Events::new(&filter); + events.insert(e); + let out = parse_dispute_events(events); + assert_eq!(out.len(), 1); + + let printable = out + .into_iter() + .map(mostro_client::util::Event::Dispute) + .collect::>(); + let table = print_disputes_table(printable).expect("table should render"); + assert!(table.contains(&id.to_string())); +} diff --git a/tests/parser_dms.rs b/tests/parser_dms.rs new file mode 100644 index 0000000..ebd590c --- /dev/null +++ b/tests/parser_dms.rs @@ -0,0 +1,19 @@ +use mostro_client::parser::dms::{parse_dm_events, print_direct_messages}; +use mostro_core::prelude::*; +use nostr_sdk::prelude::*; + +#[tokio::test] +async fn parse_dm_empty() { + let keys = Keys::generate(); + let events = Events::new(&Filter::new()); + let out = parse_dm_events(events, &keys).await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn print_dms_empty() { + let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); + let msgs: Vec<(Message, u64)> = Vec::new(); + let res = print_direct_messages(&msgs, &pool).await; + assert!(res.is_ok()); +} diff --git a/tests/parser_orders.rs b/tests/parser_orders.rs new file mode 100644 index 0000000..26d61c9 --- /dev/null +++ b/tests/parser_orders.rs @@ -0,0 +1,81 @@ +use mostro_client::parser::orders::{parse_orders_events, print_orders_table}; +use mostro_core::prelude::*; +use nostr_sdk::prelude::*; + +fn build_order_event( + kind: mostro_core::order::Kind, + status: Status, + fiat: &str, + amount: i64, + fiat_amount: i64, +) -> nostr_sdk::Event { + let keys = Keys::generate(); + let id = uuid::Uuid::new_v4(); + + let mut tags = Tags::new(); + tags.push(Tag::custom( + TagKind::Custom("d".into()), + vec![id.to_string()], + )); + tags.push(Tag::custom( + TagKind::Custom("k".into()), + vec![kind.to_string()], + )); + tags.push(Tag::custom( + TagKind::Custom("f".into()), + vec![fiat.to_string()], + )); + tags.push(Tag::custom( + TagKind::Custom("s".into()), + vec![status.to_string()], + )); + tags.push(Tag::custom( + TagKind::Custom("amt".into()), + vec![amount.to_string()], + )); + tags.push(Tag::custom( + TagKind::Custom("fa".into()), + vec![fiat_amount.to_string()], + )); + + EventBuilder::new(nostr_sdk::Kind::TextNote, "") + .tags(tags) + .sign_with_keys(&keys) + .unwrap() +} + +#[test] +fn parse_orders_empty() { + let filter = Filter::new(); + let events = Events::new(&filter); + let out = parse_orders_events(events, None, None, None); + assert!(out.is_empty()); +} + +#[test] +fn parse_orders_basic_and_print() { + let filter = Filter::new(); + let e = build_order_event( + mostro_core::order::Kind::Sell, + Status::Pending, + "USD", + 100, + 1000, + ); + let mut events = Events::new(&filter); + events.insert(e); + let out = parse_orders_events( + events, + Some("USD".into()), + Some(Status::Pending), + Some(mostro_core::order::Kind::Sell), + ); + assert_eq!(out.len(), 1); + + let printable = out + .into_iter() + .map(mostro_client::util::Event::SmallOrder) + .collect::>(); + let table = print_orders_table(printable).expect("table should render"); + assert!(table.contains("USD")); +}