From 9444fc1a8c75dcfb9f74be5e7ea7df6759a5ce6c Mon Sep 17 00:00:00 2001 From: Awiteb Date: Mon, 19 May 2025 20:04:21 +0000 Subject: [PATCH] feat: New `reply` command Signed-off-by: Awiteb --- src/cli/mod.rs | 12 +++- src/cli/reply.rs | 178 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 src/cli/reply.rs diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 45655ed..92563de 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -18,6 +18,8 @@ mod issue; /// CLI arguments parsers pub mod parsers; +/// 'reply` command +mod reply; /// `repo` subcommands mod repo; /// CLI traits @@ -30,9 +32,10 @@ use clap_verbosity_flag::Verbosity; use nostr::key::Keys; use nostr::{PublicKey, RelayUrl, SecretKey}; -pub use self::issue::IssueSubcommands; -pub use self::repo::RepoSubcommands; -pub use self::traits::CommandRunner; +use self::issue::IssueSubcommands; +use self::reply::ReplyArgs; +use self::repo::RepoSubcommands; +use self::traits::CommandRunner; use crate::error::N34Result; /// Header message, used in the help message @@ -100,6 +103,8 @@ pub enum Commands { // #[command(subcommand)] // subcommands: PatchSubcommands, // }, + /// Reply to issues and patches. + Reply(ReplyArgs), } impl Cli { @@ -116,6 +121,7 @@ impl CommandRunner for Commands { match self { Self::Repo { subcommands } => subcommands.run(options).await, Self::Issue { subcommands } => subcommands.run(options).await, + Commands::Reply(args) => args.run(options).await, } } } diff --git a/src/cli/reply.rs b/src/cli/reply.rs new file mode 100644 index 0000000..150d227 --- /dev/null +++ b/src/cli/reply.rs @@ -0,0 +1,178 @@ +// 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 . + +use std::str::FromStr; + +use clap::{ArgGroup, Args}; +use nostr::{ + event::{EventBuilder, EventId, Kind, Tag}, + filter::Filter, + nips::nip19::{self, FromBech32, Nip19Coordinate}, + types::RelayUrl, +}; + +use super::{CliOptions, CommandRunner, parsers}; +use crate::{ + error::{N34Error, N34Result}, + nostr_utils::{NostrClient, utils}, +}; + +/// Parses and represents a Nostr `nevent1` or `note1`. +#[derive(Debug, Clone)] +struct NostrEvent { + /// Unique identifier for the event. + event_id: EventId, + /// List of relay URLs associated with the event. Empty if parsing a + /// `note1`. + relays: Vec, +} + +impl NostrEvent { + /// Create a new [`NostrEvent`] instance + fn new(event_id: EventId, relays: Vec) -> Self { + Self { event_id, relays } + } +} + +impl FromStr for NostrEvent { + type Err = String; + + fn from_str(s: &str) -> Result { + if s.trim().starts_with("nevent1") { + let event = nip19::Nip19Event::from_bech32(s).map_err(|e| e.to_string())?; + Ok(Self::new(event.event_id, event.relays)) + } else if s.trim().starts_with("note1") { + Ok(Self::new( + EventId::from_bech32(s).map_err(|e| e.to_string())?, + Vec::new(), + )) + } else { + Err("Invalid event id, must starts with `note1` or `nevent1`".to_owned()) + } + } +} + + +/// Arguments for the `reply` command +#[derive(Args, Debug)] +#[clap( + group( + ArgGroup::new("comment-content") + .args(["comment", "editor"]) + .required(true) + ) +)] +pub struct ReplyArgs { + /// The issue, patch, or comment to reply to + #[arg(long)] + to: NostrEvent, + /// Repository address + #[arg(short, long, value_parser = parsers::repo_naddr)] + naddr: Option, + /// The comment (cannot be used with --editor) + #[arg(short, long)] + comment: Option, + /// Open editor to write comment (cannot be used with --content) + #[arg(short, long)] + editor: bool, +} + +impl CommandRunner for ReplyArgs { + async fn run(self, options: CliOptions) -> N34Result<()> { + let client = NostrClient::init(&options).await; + let user_pubk = options.pubkey().await?; + let relays_list = client.user_relays_list(user_pubk).await?; + let mut write_relays = + utils::add_write_relays(options.relays.clone(), relays_list.as_ref()); + client.add_relays(&options.relays).await; + client.add_relays(&self.to.relays).await; + + if let Some(ref naddr) = self.naddr { + client.add_relays(&naddr.relays).await; + } + + let reply_to = client + .fetch_event(Filter::new().id(self.to.event_id)) + .await? + .ok_or(N34Error::EventNotFound)?; + let root = client.find_root(reply_to.clone()).await?; + + + let repo_naddr = if let Some(naddr) = self.naddr { + naddr.coordinate + } else if let Some(ref root_event) = root { + root_event + .tags + .coordinates() + .find(|c| c.kind == Kind::GitRepoAnnouncement) + .ok_or_else(|| { + N34Error::InvalidEvent( + "The Git issue/patch does not specify a target repository".to_owned(), + ) + })? + .clone() + } else { + return Err(N34Error::NotFoundRepo); + }; + + let repo = client.fetch_repo(&repo_naddr).await?; + write_relays.extend(repo.relays.clone()); + + write_relays = client + .read_relays_from_user(write_relays, reply_to.pubkey) + .await; + if let Some(root_event) = &root { + write_relays = client + .read_relays_from_user(write_relays, root_event.pubkey) + .await; + } + + let content = utils::get_content(self.comment.as_ref(), ".txt")?; + let content_details = client.parse_content(&content).await; + write_relays.extend(content_details.write_relays.clone()); + + let event = EventBuilder::comment( + content, + &reply_to, + root.as_ref(), + repo.relays.first().cloned(), + ) + .tag(Tag::public_key(repo_naddr.public_key)) + .tags(content_details.into_tags()) + .pow(options.pow) + .build(user_pubk); + + let event_id = event.id.expect("There is an id"); + let author_read_relays = utils::add_read_relays(Vec::new(), relays_list.as_ref()); + + tracing::trace!(relays = ?write_relays, "Write relays list"); + let (success, ..) = futures::join!( + client.send_event_to(event, relays_list.as_ref(), &write_relays), + client.broadcast(&reply_to, &author_read_relays), + async { + if let Some(root_event) = root { + let _ = client.broadcast(&root_event, &author_read_relays).await; + } + }, + ); + + + let nevent = utils::new_nevent(event_id, &success?)?; + println!("Comment created: {nevent}"); + + Ok(()) + } +}