use std::env;
use std::fs;
use std::path::PathBuf;
use crate::RhoResult;
use crate::normalize_actor_id;
use crate::nostr::NostrIdentity;
use crate::nostr_relay::RelayClient;
use crate::providers::parse_identity_id;
const DEFAULT_RELAY_URL: &str = "wss://relay.biovault.net";
pub fn usage() -> ! {
eprintln!(
"usage:\n \
rho nostr key [--show] create/show this profile's controller key\n \
rho nostr publish-identity [--relay <ws-url>]\n \
rho nostr resolve <rho-id|handle> [--relay <ws-url>]\n \
rho nostr send <rho-id|handle> --message <text> [--relay <ws-url>]\n \
rho nostr listen [--relay <ws-url>]"
);
std::process::exit(2);
}
pub fn run(args: &[String]) -> RhoResult<()> {
let Some(command) = args.first().map(String::as_str) else {
usage();
};
match command {
"key" => key(&args[1..]),
"publish-identity" => publish_identity(&args[1..]),
"resolve" => resolve(&args[1..]),
"send" => send(&args[1..]),
"listen" => listen(&args[1..]),
_ => usage(),
}
}
fn key(args: &[String]) -> RhoResult<()> {
let handle = active_handle()?;
let path = controller_key_path(&handle)?;
if has_flag(args, "--show") && !path.is_file() {
return Err(format!("no controller key yet for {handle}; run `rho nostr key`").into());
}
let identity = load_or_create_identity(&handle)?;
println!("handle: {handle}");
println!("nostr pubkey: {}", identity.pubkey_hex());
println!("key file: {}", path.display());
Ok(())
}
fn publish_identity(args: &[String]) -> RhoResult<()> {
let rho_id = active_identity()?;
let handle = parse_identity_id(&rho_id)?.handle;
let identity = load_or_create_identity(&handle)?;
let relay = relay_url(args);
block_on(async {
let client = RelayClient::connect(&identity, &relay).await?;
let id = client
.publish_identity(&identity, &rho_id, &handle, &relay)
.await?;
println!("published identity record {rho_id} ({id}) to {relay}");
Ok(())
})
}
fn resolve(args: &[String]) -> RhoResult<()> {
let target = first_positional(args).ok_or("usage: rho nostr resolve <rho-id|handle>")?;
let rho_id = to_rho_id(&target);
let identity = ephemeral_identity()?;
let relay = relay_url(args);
block_on(async {
let client = RelayClient::connect(&identity, &relay).await?;
match client.resolve(&rho_id).await? {
Some(record) => {
println!("{}", serde_json::to_string_pretty(&record)?);
Ok(())
}
None => Err(format!("{rho_id} not found in directory at {relay}").into()),
}
})
}
fn send(args: &[String]) -> RhoResult<()> {
let target =
first_positional(args).ok_or("usage: rho nostr send <rho-id|handle> --message <text>")?;
let text = arg_value(args, "--message").ok_or("missing required argument: --message")?;
let me = active_identity()?;
let handle = parse_identity_id(&me)?.handle;
let identity = load_or_create_identity(&handle)?;
let to_id = to_rho_id(&target);
let relay = relay_url(args);
block_on(async {
let client = RelayClient::connect(&identity, &relay).await?;
let record = client.resolve(&to_id).await?.ok_or_else(|| {
format!("{to_id} not found in directory; ask them to publish-identity")
})?;
let id = client
.send_message(&identity, &record.controller_pubkey, &me, &to_id, &text)
.await?;
println!("sent message to {to_id} ({id})");
Ok(())
})
}
fn listen(args: &[String]) -> RhoResult<()> {
let me = active_identity()?;
let handle = parse_identity_id(&me)?.handle;
let identity = load_or_create_identity(&handle)?;
let relay = relay_url(args);
println!("listening for messages to {me} on {relay} (ctrl-c to stop)");
block_on(async {
let client = RelayClient::connect(&identity, &relay).await?;
client
.listen(&identity, |message| {
println!(
"[{}] {}: {}",
message.created_at, message.from_rho_id, message.text
);
})
.await
})
}
fn block_on<F: std::future::Future>(fut: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build tokio runtime")
.block_on(fut)
}
fn relay_url(args: &[String]) -> String {
arg_value(args, "--relay")
.or_else(|| env::var("RHO_RELAY_URL").ok())
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_RELAY_URL.to_string())
}
fn to_rho_id(input: &str) -> String {
let trimmed = input.trim();
if trimmed.starts_with("rho://") {
trimmed.to_string()
} else {
format!("rho://id/github/{trimmed}")
}
}
fn rho_home() -> RhoResult<PathBuf> {
if let Ok(value) = env::var("RHO_HOME")
&& !value.is_empty()
{
return Ok(PathBuf::from(value));
}
let home = env::var("HOME").map_err(|_| "HOME is not set and RHO_HOME was not provided")?;
Ok(PathBuf::from(home).join("rho"))
}
fn active_identity() -> RhoResult<String> {
let identity = env::var("RHO_IDENTITY")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or("RHO_IDENTITY is not set (pass --identity or set the profile)")?;
normalize_actor_id(&identity)
}
fn active_handle() -> RhoResult<String> {
Ok(parse_identity_id(&active_identity()?)?.handle)
}
fn controller_key_path(handle: &str) -> RhoResult<PathBuf> {
Ok(rho_home()?
.join("keys")
.join("nostr")
.join(format!("{handle}.hex")))
}
fn load_or_create_identity(handle: &str) -> RhoResult<NostrIdentity> {
let path = controller_key_path(handle)?;
if path.is_file() {
let secret = fs::read_to_string(&path)?;
return NostrIdentity::from_secret_hex(secret.trim());
}
let identity = NostrIdentity::generate();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, identity.secret_hex())?;
restrict_permissions(&path);
Ok(identity)
}
fn ephemeral_identity() -> RhoResult<NostrIdentity> {
Ok(NostrIdentity::generate())
}
#[cfg(unix)]
fn restrict_permissions(path: &std::path::Path) {
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
}
#[cfg(not(unix))]
fn restrict_permissions(_path: &std::path::Path) {}
fn has_flag(args: &[String], flag: &str) -> bool {
args.iter().any(|arg| arg == flag)
}
fn arg_value(args: &[String], flag: &str) -> Option<String> {
args.iter()
.position(|arg| arg == flag)
.and_then(|index| args.get(index + 1))
.cloned()
}
fn first_positional(args: &[String]) -> Option<String> {
let mut iter = args.iter();
while let Some(arg) = iter.next() {
if arg.starts_with("--") {
iter.next();
continue;
}
return Some(arg.clone());
}
None
}