// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr // Copyright (C) 2025 Awiteb // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . /// Extension traits for nostr types. pub mod traits; /// Utility functions for nostr. pub mod utils; use std::{collections::HashSet, time::Duration}; use futures::future; use nostr::{ event::{Event, EventId, Kind, Tag, TagStandard, Tags, UnsignedEvent}, filter::Filter, key::{Keys, PublicKey}, nips::{nip01::Coordinate, nip22, nip34::GitRepositoryAnnouncement}, parser::NostrParser, types::RelayUrl, }; use nostr_sdk::Client; use traits::TokenUtils; use crate::{ cli::CliOptions, error::{N34Error, N34Result}, }; /// Timeout duration for the clinet. const CLIENT_TIMEOUT: Duration = Duration::from_millis(1500); /// Parsed content details pub struct ContentDetails { /// Public keys of users mentioned in the content. pub p_tagged: HashSet, /// Event IDs and optional relay URLs for quoted events. pub quotes: HashSet<(EventId, Option)>, /// Hashtags found in the content. pub hashtags: HashSet, /// Relays where mentioned users and quoted authors are read. pub write_relays: HashSet, } /// A client for interacting with the Nostr relays pub struct NostrClient { /// The underlying Nostr client implementation client: Client, } impl ContentDetails { /// Create a new [`ContentDetails`] instance pub fn new( users: impl IntoIterator, quotes: impl IntoIterator)>, hashtags: impl IntoIterator, write_relays: impl IntoIterator, ) -> Self { Self { p_tagged: HashSet::from_iter(users), quotes: HashSet::from_iter(quotes), hashtags: HashSet::from_iter(hashtags), write_relays: HashSet::from_iter(write_relays), } } /// Converts the instance into a list of tags including hashtags, p-tagged /// users, and quoted events. pub fn into_tags(self) -> Tags { let mut tags = Tags::new(); tags.extend(self.hashtags.into_iter().map(Tag::hashtag)); tags.extend(self.p_tagged.into_iter().map(Tag::public_key)); tags.extend(self.quotes.into_iter().map(|(event_id, relay_url)| { Tag::from_standardized(TagStandard::Quote { event_id, relay_url, public_key: None, }) })); tags } } impl NostrClient { /// Creates a new [`NostrClient`] with the given client and options. const fn new(client: Client) -> Self { Self { client } } /// Initializes a new [`NostrClient`] instance and connects to the specified /// relays. pub async fn init(options: &CliOptions) -> Self { let client = Self::new( Client::builder() .signer(Keys::new( options .secret_key .as_ref() .expect("This the only method for now") .clone(), )) .build(), ); client.add_relays(&options.relays).await; client } /// Add relays and connect to them pub async fn add_relays(&self, relays: &[RelayUrl]) { if relays.is_empty() { return; } let mut tasks = Vec::new(); for relay in relays { let relay = relay.clone(); let client = self.client.clone(); tasks.push(tokio::spawn(async move { client .add_relay(&relay) .await .expect("It's a valid relay url"); if let Err(err) = client.try_connect_relay(&relay, CLIENT_TIMEOUT).await { tracing::error!("Failed to connect to relay '{relay}': {err}"); } })); } future::join_all(tasks).await; } /// Add a relay hint and connect to it pub async fn add_relay_hint(&self, hint: Option) { if let Some(relay) = hint { self.add_relays(&[relay]).await } } /// broadcast an event to the given relays pub async fn broadcast(&self, event: &Event, relays: &[RelayUrl]) -> N34Result<()> { self.client.send_event_to(relays, event).await?; Ok(()) } /// Broadcasts an unsigned event to given relays, optionally broadcast the /// relays list event. Returns URLs of relays that successfully received /// the event. pub async fn send_event_to( &self, mut event: UnsignedEvent, relays_list: Option<&Event>, relays: &[RelayUrl], ) -> N34Result> { event.ensure_id(); self.add_relays(relays).await; let event_id = event.id.expect("It's there"); if let Some(event) = relays_list { let _ = self.client.send_event_to(relays, event).await; } let result = self .client .send_event_to(relays, &event.sign(&self.client.signer().await?).await?) .await?; for relay in &result.success { tracing::info!(event_id = %event_id, relay = %relay, "Event sent successfully"); } for (relay, reason) in &result.failed { tracing::warn!(event_id = %event_id, relay = %relay, reason = %reason, "Failed to send event"); } Ok(result.success.into_iter().collect()) } /// Fetches the first event matching the given filter, or None if no event /// is found. pub async fn fetch_event(&self, filter: Filter) -> N34Result> { Ok(self .client .fetch_events(filter, CLIENT_TIMEOUT) .await? .first_owned()) } /// Try to fetch a repository and returns it pub async fn fetch_repo( &self, repo_naddr: &Coordinate, ) -> N34Result { let filter = Filter::new() .author(repo_naddr.public_key) .kind(Kind::GitRepoAnnouncement) .identifier(&repo_naddr.identifier); self.fetch_event(filter) .await? .map(|e| utils::event_into_repo(e, &repo_naddr.identifier)) .ok_or(N34Error::NotFoundRepo) } /// Finds the root issue or patch for a given event. If the event is already /// a root (issue/patch), returns it directly. For comments, follows /// parent/root references until finding the root or failing. Returns /// None if no root can be found. pub async fn find_root(&self, mut event: Event) -> N34Result> { if !matches!(event.kind, Kind::GitIssue | Kind::GitPatch | Kind::Comment) { return Err(N34Error::CanNotReplyToEvent); } loop { if matches!(event.kind, Kind::GitIssue | Kind::GitPatch) { return Ok(Some(event)); } if let Some(nip22::CommentTarget::Event { id, relay_hint, .. }) = nip22::extract_root(&event) { self.add_relay_hint(relay_hint.cloned()).await; let root_event = self.fetch_event(Filter::new().id(*id)).await?; if let Some(ref root_event) = root_event { if !matches!(root_event.kind, Kind::GitIssue | Kind::GitPatch) { return Err(N34Error::CanNotReplyToEvent); } } return Ok(root_event); } else if let Some(nip22::CommentTarget::Event { id, relay_hint, .. }) = nip22::extract_parent(&event) { self.add_relay_hint(relay_hint.cloned()).await; if let Ok(Some(parent_event)) = self.fetch_event(Filter::new().id(*id)).await { event = parent_event; continue; } } // Break if: no root/parent tags found, parent/root event fetch failed break; } Ok(None) } /// Fetches the relay list (kind 10002) for the given user. Returns None if /// no relays are found. pub async fn user_relays_list(&self, user: PublicKey) -> N34Result> { self.fetch_event(Filter::new().author(user).kind(Kind::RelayList)) .await } /// Gets the author of the specified event, if found. pub async fn event_author(&self, event_id: EventId) -> N34Result> { Ok(self .fetch_event(Filter::new().id(event_id)) .await? .map(|e| e.pubkey)) } /// Adds read relays from the user to the given vector of relays. pub async fn read_relays_from_user( &self, vector: Vec, user: PublicKey, ) -> Vec { utils::add_read_relays( vector, self.user_relays_list(user).await.ok().flatten().as_ref(), ) } /// Parse the given content and returns the details that inside it pub async fn parse_content(&self, content: &str) -> ContentDetails { let mut write_relays = Vec::new(); let tokens = NostrParser::new().parse(content).collect::>(); let mut p_tagged_users = tokens .iter() .filter_map(TokenUtils::extract_public_key) .collect::>(); let quotes = tokens .iter() .filter_map(TokenUtils::extract_event_id) .collect::>(); let hashtags = tokens .iter() .filter_map(TokenUtils::extract_hashtag) .collect::>(); for (user, relays) in &p_tagged_users { self.add_relays(relays).await; write_relays = self.read_relays_from_user(write_relays, *user).await } for (event_id, relays) in "es { self.add_relays(relays).await; // Add the event author to the p-tagged users if let Ok(Some(author)) = self.event_author(*event_id).await { p_tagged_users.push((author, Vec::new())); write_relays = self.read_relays_from_user(write_relays, author).await; } } ContentDetails::new( p_tagged_users.into_iter().map(|(p, _)| p), quotes.into_iter().map(|(e, r)| (e, r.first().cloned())), hashtags, write_relays, ) } }