diff --git a/src/cli.rs b/src/cli.rs index 84eb84e..5d7b70a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,9 @@ pub mod add_invoice; +pub mod adm_send_dm; pub mod conversation_key; +pub mod dm_to_user; pub mod get_dm; +pub mod get_dm_user; pub mod list_disputes; pub mod list_orders; pub mod new_order; @@ -13,8 +16,11 @@ pub mod take_dispute; pub mod take_sell; use crate::cli::add_invoice::execute_add_invoice; +use crate::cli::adm_send_dm::execute_adm_send_dm; use crate::cli::conversation_key::execute_conversation_key; +use crate::cli::dm_to_user::execute_dm_to_user; use crate::cli::get_dm::execute_get_dm; +use crate::cli::get_dm_user::execute_get_dm_user; use crate::cli::list_disputes::execute_list_disputes; use crate::cli::list_orders::execute_list_orders; use crate::cli::new_order::execute_new_order; @@ -158,6 +164,13 @@ pub enum Commands { #[arg(short)] from_user: bool, }, + /// Get direct messages sent to any trade keys + GetDmUser { + /// Since time of the messages in minutes + #[arg(short, long)] + #[clap(default_value_t = 30)] + since: i64, + }, /// Get the latest direct messages for admin GetAdminDm { /// Since time of the messages in minutes @@ -180,6 +193,18 @@ pub enum Commands { #[arg(short, long)] message: String, }, + /// Send gift wrapped direct message to a user + DmToUser { + /// Pubkey of the recipient + #[arg(short, long)] + pubkey: String, + /// Order id to get ephemeral keys + #[arg(short, long)] + order_id: Uuid, + /// Message to send + #[arg(short, long)] + message: String, + }, /// Send fiat sent message to confirm payment to other user FiatSent { /// Order id @@ -241,6 +266,15 @@ pub enum Commands { #[arg(short, long)] dispute_id: Uuid, }, + /// Send gift wrapped direct message to a user (only admin) + AdmSendDm { + /// Pubkey of the recipient + #[arg(short, long)] + pubkey: String, + /// Message to send + #[arg(short, long)] + message: String, + }, /// Get the conversation key for direct messaging with a user ConversationKey { /// Pubkey of the counterpart @@ -373,10 +407,13 @@ pub async fn run() -> Result<()> { 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).await? + 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).await? + execute_get_dm(since, trade_index, &client, *from_user, true, &mostro_key).await? } Commands::FiatSent { order_id } | Commands::Release { order_id } @@ -396,8 +433,7 @@ pub async fn run() -> Result<()> { 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); + anyhow::bail!("NSEC_PRIVKEY not set: {e}"); } }; execute_admin_add_solver(npubkey, &id_key, &trade_keys, mostro_key, &client).await? @@ -439,8 +475,7 @@ pub async fn run() -> Result<()> { 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); + anyhow::bail!("NSEC_PRIVKEY not set: {e}"); } }; execute_admin_settle_dispute(order_id, &id_key, &trade_keys, mostro_key, &client) @@ -450,8 +485,7 @@ pub async fn run() -> Result<()> { 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); + anyhow::bail!("NSEC_PRIVKEY not set: {e}"); } }; execute_admin_cancel_dispute(order_id, &id_key, &trade_keys, mostro_key, &client) @@ -461,8 +495,7 @@ pub async fn run() -> Result<()> { 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); + anyhow::bail!("NSEC_PRIVKEY not set: {e}"); } }; @@ -477,6 +510,18 @@ pub async fn run() -> Result<()> { let pubkey = PublicKey::from_str(pubkey)?; execute_send_dm(pubkey, &client, order_id, message).await? } + Commands::DmToUser { + pubkey, + order_id, + message, + } => { + let pubkey = PublicKey::from_str(pubkey)?; + execute_dm_to_user(pubkey, &client, order_id, message).await? + } + Commands::AdmSendDm { pubkey, message } => { + let pubkey = PublicKey::from_str(pubkey)?; + execute_adm_send_dm(pubkey, &client, message).await? + } }; } diff --git a/src/cli/adm_send_dm.rs b/src/cli/adm_send_dm.rs new file mode 100644 index 0000000..f8d0566 --- /dev/null +++ b/src/cli/adm_send_dm.rs @@ -0,0 +1,24 @@ +use crate::util::send_admin_gift_wrap_dm; +use anyhow::Result; +use nostr_sdk::prelude::*; + +pub async fn execute_adm_send_dm( + receiver: PublicKey, + client: &Client, + message: &str, +) -> Result<()> { + let admin_keys = match std::env::var("NSEC_PRIVKEY") { + Ok(key) => Keys::parse(&key)?, + Err(e) => { + anyhow::bail!("NSEC_PRIVKEY not set: {e}"); + } + }; + + 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 new file mode 100644 index 0000000..9b8e835 --- /dev/null +++ b/src/cli/dm_to_user.rs @@ -0,0 +1,27 @@ +use crate::{db::Order, util::send_gift_wrap_dm}; +use anyhow::Result; +use nostr_sdk::prelude::*; +use uuid::Uuid; + +pub async fn execute_dm_to_user( + receiver: PublicKey, + client: &Client, + order_id: &Uuid, + message: &str, +) -> Result<()> { + let pool = crate::db::connect().await?; + + let order = Order::get_by_id(&pool, &order_id.to_string()) + .await + .map_err(|_| anyhow::anyhow!("order {} not found", order_id))?; + 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_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 1e05329..8432843 100644 --- a/src/cli/get_dm.rs +++ b/src/cli/get_dm.rs @@ -14,13 +14,14 @@ pub async fn execute_get_dm( client: &Client, from_user: bool, admin: bool, + mostro_pubkey: &PublicKey, ) -> 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).await; + let dm_temp = get_direct_messages(client, &keys, *since, from_user, Some(mostro_pubkey)).await; dm.extend(dm_temp); } } else { @@ -31,7 +32,7 @@ pub async fn execute_get_dm( std::process::exit(1); } }; - let dm_temp = get_direct_messages(client, &id_key, *since, from_user).await; + let dm_temp = get_direct_messages(client, &id_key, *since, from_user, Some(mostro_pubkey)).await; dm.extend(dm_temp); } diff --git a/src/cli/get_dm_user.rs b/src/cli/get_dm_user.rs new file mode 100644 index 0000000..de7a64f --- /dev/null +++ b/src/cli/get_dm_user.rs @@ -0,0 +1,62 @@ +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?; + + // 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); + } + + if trade_keys_hex.is_empty() { + println!("No trade keys found in orders and NSEC_PRIVKEY not set"); + 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; + + if direct_messages.is_empty() { + println!("You don't have any direct messages in your trade keys"); + return Ok(()); + } + + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .apply_modifier(UTF8_ROUND_CORNERS) + .set_content_arrangement(comfy_table::ContentArrangement::Dynamic) + .set_header(vec!["Time", "From", "Message"]); + + for (message, created_at, sender_pubkey) in direct_messages.iter() { + let datetime = chrono::DateTime::from_timestamp(*created_at as i64, 0); + let formatted_date = match datetime { + Some(dt) => dt.format("%Y-%m-%d %H:%M:%S").to_string(), + None => "Invalid timestamp".to_string(), + }; + + let inner = message.get_inner_message_kind(); + let message_str = match &inner.payload { + Some(Payload::TextMessage(text)) => text.clone(), + _ => format!("{:?}", message), + }; + + let sender_hex = sender_pubkey.to_hex(); + + table.add_row(vec![&formatted_date, &sender_hex, &message_str]); + } + + println!("{table}"); + println!(); + Ok(()) +} \ No newline at end of file diff --git a/src/db.rs b/src/db.rs index 5b7caaa..92d1eab 100644 --- a/src/db.rs +++ b/src/db.rs @@ -439,6 +439,26 @@ impl Order { Ok(orders) } + pub async fn get_all_trade_keys(pool: &SqlitePool) -> Result> { + #[derive(sqlx::FromRow)] + struct TradeKeyRow { + trade_keys: Option, + } + + let rows = sqlx::query_as::<_, TradeKeyRow>( + "SELECT DISTINCT trade_keys FROM orders WHERE trade_keys IS NOT NULL" + ) + .fetch_all(pool) + .await?; + + let trade_keys: Vec = rows + .into_iter() + .filter_map(|row| row.trade_keys) + .collect(); + + Ok(trade_keys) + } + pub async fn delete_by_id(pool: &SqlitePool, id: &str) -> Result { let rows_affected = sqlx::query( r#" diff --git a/src/util.rs b/src/util.rs index a30d91c..4b388d3 100644 --- a/src/util.rs +++ b/src/util.rs @@ -12,6 +12,62 @@ use std::thread::sleep; use std::time::Duration; use std::{fs, path::Path}; +async fn send_gift_wrap_dm_internal( + client: &Client, + sender_keys: &Keys, + receiver_pubkey: &PublicKey, + message: &str, + is_admin: bool, +) -> Result<()> { + let pow: u8 = var("POW") + .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, + None, + 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); + client.send_event(&event).await?; + + Ok(()) +} + +pub async fn send_admin_gift_wrap_dm( + client: &Client, + admin_keys: &Keys, + receiver_pubkey: &PublicKey, + message: &str, +) -> Result<()> { + send_gift_wrap_dm_internal(client, admin_keys, receiver_pubkey, message, true).await +} + +pub async fn send_gift_wrap_dm( + client: &Client, + trade_keys: &Keys, + receiver_pubkey: &PublicKey, + message: &str, +) -> Result<()> { + send_gift_wrap_dm_internal(client, trade_keys, receiver_pubkey, message, false).await +} + pub async fn send_dm( client: &Client, identity_keys: Option<&Keys>, @@ -131,7 +187,7 @@ pub async fn send_message_sync( sleep(Duration::from_secs(2)); let dm: Vec<(Message, u64)> = if wait_for_dm { - get_direct_messages(client, trade_keys, 15, to_user).await + get_direct_messages(client, trade_keys, 15, to_user, None).await } else { Vec::new() }; @@ -139,11 +195,95 @@ pub async fn send_message_sync( 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)> { + if trade_keys_hex.is_empty() { + return 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; + + let mut all_direct_messages: Vec<(Message, u64, PublicKey)> = Vec::new(); + let mut id_set = std::collections::HashSet::::new(); + + 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 { + 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 + )); + } + } + } 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 +} + 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; @@ -213,6 +353,14 @@ pub async fn get_direct_messages( 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();