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(())
+ }
+}