// 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, TagKind, TagStandard, Tags, UnsignedEvent}, filter::Filter, key::PublicKey, nips::{ nip01::{Coordinate, Metadata}, nip19::ToBech32, nip22, nip34::GitRepositoryAnnouncement, }, parser::NostrParser, types::RelayUrl, }; use nostr_sdk::{Client, ClientOptions}; use traits::TokenUtils; use crate::{ cli::{CliOptions, issue::IssueStatus, patch::PatchStatus}, error::{N34Error, N34Result}, }; /// Timeout duration for the client. const CLIENT_TIMEOUT: Duration = Duration::from_millis(1500); /// Length of a Nostr npub (public key) in characters. const NPUB_LEN: usize = 63; /// Parsed content details #[derive(Clone)] 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 #[derive(Clone)] pub struct NostrClient { /// The underlying Nostr client implementation pub 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)| { // TODO: Add the author public key if we know it 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, relays: &[RelayUrl]) -> Self { let mut client_builder = Client::builder().opts(ClientOptions::new().verify_subscriptions(true)); if let Ok(Some(signer)) = options.signer().await { client_builder = client_builder.signer(signer); } let client = Self::new(client_builder.build()); client.add_relays(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> { self.add_relays(relays).await; let event_id = event.id(); let (result, ..) = futures::join!( async { N34Result::Ok( self.client .send_event_to(relays, &event.sign(&self.client.signer().await?).await?) .await?, ) }, async { if let Some(event) = relays_list { let _ = self.client.send_event_to(relays, event).await; } } ); let result = result?; 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.limit(1), CLIENT_TIMEOUT) .await? .first_owned()) } /// Fetches the events matching the given filter pub async fn fetch_events(&self, filter: Filter) -> N34Result> { // Multiply timeout by 5 to account for multiple events being fetched Ok(self .client .fetch_events(filter, CLIENT_TIMEOUT * 5) .await? .to_vec()) } /// Try to fetch the repositories and returns them pub async fn fetch_repos( &self, repo_naddrs: &[Coordinate], ) -> N34Result> { future::join_all(repo_naddrs.iter().map(|c| { async { self.fetch_event( Filter::new() .author(c.public_key) .identifier(&c.identifier) .kind(Kind::GitRepoAnnouncement), ) .await? .map(|e| utils::event_into_repo(e, &c.identifier)) .ok_or(N34Error::NotFoundRepo) } })) .await .into_iter() .collect() } /// Fetch the patch by the given id. None if not found pub async fn fetch_patch(&self, patch_id: EventId) -> N34Result { self.fetch_event(Filter::new().id(patch_id).kind(Kind::GitPatch)) .await? .ok_or(N34Error::CanNotFoundPatch) } /// Returns the username for a given public key. If no username is found, /// falls back to a shortened version of the public key. pub async fn get_username(&self, user: PublicKey) -> String { self.fetch_event(Filter::new().kind(Kind::Metadata).author(user)) .await .ok() .flatten() .and_then(|e| Metadata::try_from(&e).ok()) .and_then(|m| m.display_name.or(m.name)) .unwrap_or_else(|| { let pubkey = user.to_bech32().expect("The error is `Infallible`"); format!("{}...{}", &pubkey[..8], &pubkey[NPUB_LEN - 8..]) }) } /// Get the latest status of an issue by its ID, only considering status /// events from authorized_pubkeys. If no valid status event is found, /// defaults to Open. pub async fn fetch_issue_status( &self, issue_id: EventId, authorized_pubkeys: Vec, ) -> N34Result { self.fetch_events( Filter::new() .event(issue_id) .kinds([ Kind::GitStatusOpen, Kind::GitStatusApplied, Kind::GitStatusClosed, ]) .authors(utils::dedup(authorized_pubkeys.into_iter())), ) .await? .into_iter() .max_by_key(|e| e.created_at) .map(|status| IssueStatus::try_from(status.kind)) .unwrap_or_else(|| Ok(IssueStatus::Open)) } /// Gets the status of a patch. If it's a revision patch, checks if it's /// closed when the root patch is already merged/applied but doesn't /// reference this revision. Defaults to Open status if no status event /// is found. pub async fn fetch_patch_status( &self, root_patch: EventId, root_revision: Option, authorized_pubkeys: Vec, ) -> N34Result { let (root_status, event_tags) = self .fetch_events( Filter::new() .event(root_patch) .kinds([ Kind::GitStatusOpen, Kind::GitStatusApplied, Kind::GitStatusClosed, Kind::GitStatusDraft, ]) .authors(utils::dedup(authorized_pubkeys.into_iter())), ) .await? .into_iter() .max_by_key(|e| e.created_at) .map(|status| N34Result::Ok((PatchStatus::try_from(status.kind)?, status.tags))) .unwrap_or_else(|| Ok((PatchStatus::Open, Tags::new())))?; if let Some(revision_id) = root_revision && root_status.is_merged_or_applied() && !event_tags .filter(TagKind::e()) .any(|t| t.is_reply() && t.content().is_some_and(|c| c == revision_id.to_hex())) { return Ok(PatchStatus::Closed); } Ok(root_status) } pub async fn fetch_patch_series( &self, root_patch_id: EventId, root_patch_author: PublicKey, ) -> N34Result> { Ok(self .fetch_events( Filter::new() .kind(Kind::GitPatch) .author(root_patch_author) .event(root_patch_id), ) .await? .into_iter() .filter(|e| { e.tags.iter().any(|t| { t.is_root() && t.content().is_some_and(|c| c == root_patch_id.to_hex()) }) }) .collect()) } /// 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 && !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)) } /// Returns the read relays of the given user if found, otherwise empty /// vector pub async fn read_relays_from_user(&self, user: PublicKey) -> Vec { utils::add_read_relays(self.user_relays_list(user).await.ok().flatten().as_ref()) } /// Returns the read relays of the given users if found, otherwise empty /// vector pub async fn read_relays_from_users(&self, users: &[PublicKey]) -> Vec { self.fetch_events( Filter::new() .kind(nostr::event::Kind::RelayList) .authors(utils::dedup(users.iter().copied())), ) .await .unwrap_or_default() .into_iter() .flat_map(|e| utils::add_read_relays(Some(&e))) .collect() } /// 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.extend(self.read_relays_from_user(*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.extend(self.read_relays_from_user(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, ) } }