add encrypted Nostr session sharing (#8922)
Signed-off-by: callebtc <93376500+callebtc@users.noreply.github.com> Signed-off-by: Douwe Osinga <douwe@squareup.com> Signed-off-by: Michael Neale <michael.neale@gmail.com> Co-authored-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
@@ -543,6 +543,28 @@ enum SessionCommand {
|
||||
default_value = "markdown"
|
||||
)]
|
||||
format: String,
|
||||
|
||||
#[arg(
|
||||
long = "nostr",
|
||||
help = "Publish the JSON session export as an encrypted Nostr event and print a Goose share link"
|
||||
)]
|
||||
nostr: bool,
|
||||
|
||||
#[arg(
|
||||
long = "relay",
|
||||
value_name = "RELAY",
|
||||
help = "Nostr relay URL to publish to (can be specified multiple times)",
|
||||
action = clap::ArgAction::Append
|
||||
)]
|
||||
relays: Vec<String>,
|
||||
},
|
||||
#[command(about = "Import a session from JSON or an encrypted Nostr share link")]
|
||||
Import {
|
||||
#[arg(help = "Path to a JSON session export, or a goose://sessions/nostr share link")]
|
||||
input: String,
|
||||
|
||||
#[arg(long = "nostr", help = "Treat input as an encrypted Nostr share link")]
|
||||
nostr: bool,
|
||||
},
|
||||
#[command(name = "diagnostics")]
|
||||
Diagnostics {
|
||||
@@ -1227,6 +1249,8 @@ async fn handle_session_subcommand(command: SessionCommand) -> Result<()> {
|
||||
identifier,
|
||||
output,
|
||||
format,
|
||||
nostr,
|
||||
relays,
|
||||
} => {
|
||||
let session_manager = SessionManager::instance();
|
||||
let session_identifier = if let Some(id) = identifier {
|
||||
@@ -1244,8 +1268,17 @@ async fn handle_session_subcommand(command: SessionCommand) -> Result<()> {
|
||||
}
|
||||
}
|
||||
};
|
||||
crate::commands::session::handle_session_export(session_identifier, output, format)
|
||||
.await?;
|
||||
crate::commands::session::handle_session_export(
|
||||
session_identifier,
|
||||
output,
|
||||
format,
|
||||
nostr,
|
||||
relays,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
SessionCommand::Import { input, nostr } => {
|
||||
crate::commands::session::handle_session_import(input, nostr).await?;
|
||||
}
|
||||
SessionCommand::Diagnostics { identifier, output } => {
|
||||
let session_manager = SessionManager::instance();
|
||||
|
||||
@@ -3,7 +3,8 @@ use anyhow::{Context, Result};
|
||||
|
||||
use cliclack::{confirm, multiselect, select};
|
||||
use etcetera::home_dir;
|
||||
use goose::session::{generate_diagnostics, Session, SessionManager};
|
||||
use goose::config::Config;
|
||||
use goose::session::{generate_diagnostics, nostr_share, Session, SessionManager, SessionType};
|
||||
use goose::utils::safe_truncate;
|
||||
use regex::Regex;
|
||||
use std::fs;
|
||||
@@ -216,6 +217,8 @@ pub async fn handle_session_export(
|
||||
session_id: String,
|
||||
output_path: Option<PathBuf>,
|
||||
format: String,
|
||||
nostr: bool,
|
||||
relays: Vec<String>,
|
||||
) -> Result<()> {
|
||||
let session_manager = SessionManager::instance();
|
||||
let session = match session_manager.get_session(&session_id, true).await {
|
||||
@@ -241,6 +244,29 @@ pub async fn handle_session_export(
|
||||
_ => return Err(anyhow::anyhow!("Unsupported format: {}", format)),
|
||||
};
|
||||
|
||||
if nostr {
|
||||
if format != "json" {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Nostr session sharing only supports --format json"
|
||||
));
|
||||
}
|
||||
if output_path.is_some() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Nostr session sharing cannot be combined with --output"
|
||||
));
|
||||
}
|
||||
|
||||
let relays = nostr_share::resolve_relays(relays, Config::global());
|
||||
let share = nostr_share::publish_session_json(&output, relays).await?;
|
||||
println!("Session published to Nostr relays:");
|
||||
for relay in &share.relays {
|
||||
println!("- {}", relay);
|
||||
}
|
||||
println!("\nShare link:");
|
||||
println!("{}", share.deeplink);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(output_path) = output_path {
|
||||
fs::write(&output_path, output).with_context(|| {
|
||||
format!("Failed to write to output file: {}", output_path.display())
|
||||
@@ -253,6 +279,25 @@ pub async fn handle_session_export(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn handle_session_import(input: String, nostr: bool) -> Result<()> {
|
||||
let json = if nostr || input.starts_with("goose://sessions/nostr") {
|
||||
nostr_share::import_session_json_from_deeplink(&input).await?
|
||||
} else {
|
||||
fs::read_to_string(&input)
|
||||
.with_context(|| format!("Failed to read session import file: {input}"))?
|
||||
};
|
||||
|
||||
let session_manager = SessionManager::instance();
|
||||
let session = session_manager
|
||||
.import_session(&json, Some(SessionType::User))
|
||||
.await?;
|
||||
|
||||
println!("Session imported:");
|
||||
println!("{} - {}", session.id, session.name);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn handle_diagnostics(session_id: &str, output_path: Option<PathBuf>) -> Result<()> {
|
||||
println!(
|
||||
"Generating diagnostics bundle for session '{}'...",
|
||||
|
||||
@@ -443,6 +443,8 @@ derive_utoipa!(IconTheme as IconThemeSchema);
|
||||
super::routes::session::delete_session,
|
||||
super::routes::session::export_session,
|
||||
super::routes::session::import_session,
|
||||
super::routes::session::share_session_nostr,
|
||||
super::routes::session::import_session_nostr,
|
||||
super::routes::session::update_session_user_recipe_values,
|
||||
super::routes::session::fork_session,
|
||||
super::routes::session::get_session_extensions,
|
||||
@@ -512,6 +514,9 @@ derive_utoipa!(IconTheme as IconThemeSchema);
|
||||
super::routes::session_events::SessionReplyResponse,
|
||||
super::routes::session_events::CancelRequest,
|
||||
super::routes::session::ImportSessionRequest,
|
||||
super::routes::session::ShareSessionNostrRequest,
|
||||
super::routes::session::ShareSessionNostrResponse,
|
||||
super::routes::session::ImportSessionNostrRequest,
|
||||
super::routes::session::SessionListResponse,
|
||||
super::routes::session::UpdateSessionNameRequest,
|
||||
super::routes::session::UpdateSessionUserRecipeValuesRequest,
|
||||
|
||||
@@ -11,6 +11,7 @@ use axum::{
|
||||
};
|
||||
use goose::agents::ExtensionConfig;
|
||||
use goose::recipe::Recipe;
|
||||
use goose::session::nostr_share;
|
||||
use goose::session::session_manager::{SessionInsights, SessionType};
|
||||
use goose::session::{EnabledExtensionsState, Session};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -50,6 +51,28 @@ pub struct ImportSessionRequest {
|
||||
json: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ShareSessionNostrRequest {
|
||||
#[serde(default)]
|
||||
relays: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ShareSessionNostrResponse {
|
||||
deeplink: String,
|
||||
nevent: String,
|
||||
event_id: String,
|
||||
relays: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportSessionNostrRequest {
|
||||
deeplink: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ForkRequest {
|
||||
@@ -364,6 +387,79 @@ async fn import_session(
|
||||
Ok(Json(session))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/sessions/{session_id}/share/nostr",
|
||||
request_body = ShareSessionNostrRequest,
|
||||
params(
|
||||
("session_id" = String, Path, description = "Unique identifier for the session")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Session shared to Nostr successfully", body = ShareSessionNostrResponse),
|
||||
(status = 401, description = "Unauthorized - Invalid or missing API key"),
|
||||
(status = 404, description = "Session not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("api_key" = [])
|
||||
),
|
||||
tag = "Session Management"
|
||||
)]
|
||||
async fn share_session_nostr(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(session_id): Path<String>,
|
||||
Json(request): Json<ShareSessionNostrRequest>,
|
||||
) -> Result<Json<ShareSessionNostrResponse>, StatusCode> {
|
||||
let exported = state
|
||||
.session_manager()
|
||||
.export_session(&session_id)
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
let relays = nostr_share::resolve_relays(request.relays, goose::config::Config::global());
|
||||
let share = nostr_share::publish_session_json(&exported, relays)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(ShareSessionNostrResponse {
|
||||
deeplink: share.deeplink,
|
||||
nevent: share.nevent,
|
||||
event_id: share.event_id,
|
||||
relays: share.relays,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/sessions/import/nostr",
|
||||
request_body = ImportSessionNostrRequest,
|
||||
responses(
|
||||
(status = 200, description = "Nostr shared session imported successfully", body = Session),
|
||||
(status = 401, description = "Unauthorized - Invalid or missing API key"),
|
||||
(status = 400, description = "Bad request - Invalid Nostr share link"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("api_key" = [])
|
||||
),
|
||||
tag = "Session Management"
|
||||
)]
|
||||
async fn import_session_nostr(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(request): Json<ImportSessionNostrRequest>,
|
||||
) -> Result<Json<Session>, StatusCode> {
|
||||
let json = nostr_share::import_session_json_from_deeplink(&request.deeplink)
|
||||
.await
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
let session = state
|
||||
.session_manager()
|
||||
.import_session(&json, Some(SessionType::User))
|
||||
.await
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
Ok(Json(session))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/sessions/{session_id}/fork",
|
||||
@@ -505,10 +601,18 @@ pub fn routes(state: Arc<AppState>) -> Router {
|
||||
.route("/sessions/{session_id}", get(get_session))
|
||||
.route("/sessions/{session_id}", delete(delete_session))
|
||||
.route("/sessions/{session_id}/export", get(export_session))
|
||||
.route(
|
||||
"/sessions/{session_id}/share/nostr",
|
||||
post(share_session_nostr).layer(DefaultBodyLimit::max(25 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/sessions/import",
|
||||
post(import_session).layer(DefaultBodyLimit::max(25 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/sessions/import/nostr",
|
||||
post(import_session_nostr).layer(DefaultBodyLimit::max(25 * 1024 * 1024)),
|
||||
)
|
||||
.route("/sessions/insights", get(get_session_insights))
|
||||
.route("/sessions/{session_id}/name", put(update_session_name))
|
||||
.route(
|
||||
|
||||
@@ -39,6 +39,7 @@ aws-providers = [
|
||||
cuda = ["local-inference", "candle-core/cuda", "candle-nn/cuda", "llama-cpp-2/cuda"]
|
||||
vulkan = ["local-inference", "llama-cpp-2/vulkan"]
|
||||
rustls-tls = [
|
||||
"dep:rustls",
|
||||
"reqwest/rustls",
|
||||
"rmcp/reqwest",
|
||||
"sqlx/runtime-tokio-rustls",
|
||||
@@ -59,6 +60,7 @@ native-tls = [
|
||||
"oauth2/native-tls",
|
||||
]
|
||||
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -194,6 +196,9 @@ goose-acp-macros = { path = "../goose-acp-macros" }
|
||||
tower-http = { workspace = true, features = ["cors"] }
|
||||
http-body-util = "0.1.3"
|
||||
process-wrap = { version = "9.1.0", features = ["std"] }
|
||||
nostr = { version = "0.44.2", features = ["nip44"] }
|
||||
nostr-sdk = { version = "0.44.1", features = ["nip44"] }
|
||||
rustls = { version = "0.23", features = ["aws_lc_rs"], optional = true }
|
||||
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
|
||||
@@ -2,6 +2,7 @@ mod chat_history_search;
|
||||
mod diagnostics;
|
||||
pub mod extension_data;
|
||||
mod legacy;
|
||||
pub mod nostr_share;
|
||||
pub mod session_manager;
|
||||
|
||||
pub use diagnostics::{
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use nostr::nips::nip19::{FromBech32, Nip19Event, ToBech32};
|
||||
use nostr::nips::nip44;
|
||||
use nostr::prelude::*;
|
||||
use nostr_sdk::Client;
|
||||
|
||||
use crate::config::{Config, ConfigError};
|
||||
|
||||
pub const EVENT_KIND: u16 = 30278;
|
||||
pub const CONFIG_RELAYS_KEY: &str = "GOOSE_NOSTR_RELAYS";
|
||||
|
||||
const DEFAULT_RELAYS: &[&str] = &[
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.nostr.band",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NostrShare {
|
||||
pub deeplink: String,
|
||||
pub nevent: String,
|
||||
pub event_id: String,
|
||||
pub relays: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParsedShareLink {
|
||||
pub nevent: String,
|
||||
pub decryption_key: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait NostrPublisher {
|
||||
async fn publish(&self, event: Event, relays: &[String]) -> Result<()>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait NostrFetcher {
|
||||
async fn fetch(&self, event_id: EventId, relays: &[String]) -> Result<Event>;
|
||||
}
|
||||
|
||||
pub struct LiveNostrClient;
|
||||
|
||||
#[async_trait]
|
||||
impl NostrPublisher for LiveNostrClient {
|
||||
async fn publish(&self, event: Event, relays: &[String]) -> Result<()> {
|
||||
install_rustls_crypto_provider();
|
||||
let client = Client::default();
|
||||
for relay in relays {
|
||||
client
|
||||
.add_relay(relay)
|
||||
.await
|
||||
.with_context(|| format!("Failed to add relay {relay}"))?;
|
||||
}
|
||||
|
||||
client.try_connect(Duration::from_secs(8)).await;
|
||||
let output = client
|
||||
.send_event_to(relays.iter().map(String::as_str), &event)
|
||||
.await
|
||||
.context("Failed to publish session to Nostr relays")?;
|
||||
client.shutdown().await;
|
||||
|
||||
if output.success.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"Failed to publish session to any Nostr relay: {:?}",
|
||||
output.failed
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NostrFetcher for LiveNostrClient {
|
||||
async fn fetch(&self, event_id: EventId, relays: &[String]) -> Result<Event> {
|
||||
install_rustls_crypto_provider();
|
||||
let client = Client::default();
|
||||
for relay in relays {
|
||||
client
|
||||
.add_relay(relay)
|
||||
.await
|
||||
.with_context(|| format!("Failed to add relay {relay}"))?;
|
||||
}
|
||||
|
||||
client.try_connect(Duration::from_secs(8)).await;
|
||||
let filter = Filter::new()
|
||||
.id(event_id)
|
||||
.kind(Kind::Custom(EVENT_KIND))
|
||||
.limit(1);
|
||||
let events = client
|
||||
.fetch_events_from(
|
||||
relays.iter().map(String::as_str),
|
||||
filter,
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await
|
||||
.context("Failed to fetch shared session from Nostr relays")?;
|
||||
client.shutdown().await;
|
||||
|
||||
events
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("Shared session event not found"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "rustls-tls")]
|
||||
fn install_rustls_crypto_provider() {
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "rustls-tls"))]
|
||||
fn install_rustls_crypto_provider() {}
|
||||
|
||||
pub fn default_relays() -> Vec<String> {
|
||||
DEFAULT_RELAYS
|
||||
.iter()
|
||||
.map(|relay| relay.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn relays_from_config(config: &Config) -> Vec<String> {
|
||||
match config.get_param::<Vec<String>>(CONFIG_RELAYS_KEY) {
|
||||
Ok(relays) if !relays.is_empty() => normalize_relays(relays),
|
||||
Err(ConfigError::NotFound(_)) => default_relays(),
|
||||
_ => default_relays(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_relays(cli_relays: Vec<String>, config: &Config) -> Vec<String> {
|
||||
if cli_relays.is_empty() {
|
||||
relays_from_config(config)
|
||||
} else {
|
||||
normalize_relays(cli_relays)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn publish_session_json(session_json: &str, relays: Vec<String>) -> Result<NostrShare> {
|
||||
publish_session_json_with(session_json, relays, &LiveNostrClient).await
|
||||
}
|
||||
|
||||
pub async fn publish_session_json_with<P>(
|
||||
session_json: &str,
|
||||
relays: Vec<String>,
|
||||
publisher: &P,
|
||||
) -> Result<NostrShare>
|
||||
where
|
||||
P: NostrPublisher + Sync,
|
||||
{
|
||||
let relays = normalize_relays(relays);
|
||||
if relays.is_empty() {
|
||||
return Err(anyhow!("At least one Nostr relay is required"));
|
||||
}
|
||||
let relay_urls = relays
|
||||
.iter()
|
||||
.map(|relay| RelayUrl::parse(relay))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let publish_keys = Keys::generate();
|
||||
let encryption_key = SecretKey::generate();
|
||||
let encryption_keys = Keys::new(encryption_key.clone());
|
||||
let encrypted = nip44::encrypt(
|
||||
&encryption_key,
|
||||
&encryption_keys.public_key(),
|
||||
session_json,
|
||||
nip44::Version::V2,
|
||||
)?;
|
||||
|
||||
let event = EventBuilder::new(Kind::Custom(EVENT_KIND), encrypted)
|
||||
.tag(Tag::identifier(format!(
|
||||
"goose-session-{}",
|
||||
uuid::Uuid::now_v7()
|
||||
)))
|
||||
.tag(Tag::parse(["client", "goose"])?)
|
||||
.sign_with_keys(&publish_keys)?;
|
||||
|
||||
publisher.publish(event.clone(), &relays).await?;
|
||||
|
||||
let nevent = Nip19Event::new(event.id)
|
||||
.author(event.pubkey)
|
||||
.kind(Kind::Custom(EVENT_KIND))
|
||||
.relays(relay_urls)
|
||||
.to_bech32()?;
|
||||
let decryption_key = encryption_key.to_secret_hex();
|
||||
let deeplink = build_deeplink(&nevent, &decryption_key);
|
||||
|
||||
Ok(NostrShare {
|
||||
deeplink,
|
||||
nevent,
|
||||
event_id: event.id.to_hex(),
|
||||
relays,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn import_session_json_from_deeplink(deeplink: &str) -> Result<String> {
|
||||
import_session_json_from_deeplink_with(deeplink, &LiveNostrClient).await
|
||||
}
|
||||
|
||||
pub async fn import_session_json_from_deeplink_with<F>(
|
||||
deeplink: &str,
|
||||
fetcher: &F,
|
||||
) -> Result<String>
|
||||
where
|
||||
F: NostrFetcher + Sync,
|
||||
{
|
||||
let ParsedShareLink {
|
||||
nevent,
|
||||
decryption_key,
|
||||
} = parse_deeplink(deeplink)?;
|
||||
let event_ref = Nip19Event::from_bech32(&nevent)?;
|
||||
let relays = event_ref
|
||||
.relays
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if relays.is_empty() {
|
||||
return Err(anyhow!("Shared session link does not include any relays"));
|
||||
}
|
||||
|
||||
let event = fetcher.fetch(event_ref.event_id, &relays).await?;
|
||||
if event.kind != Kind::Custom(EVENT_KIND) {
|
||||
return Err(anyhow!(
|
||||
"Unexpected Nostr event kind: {}",
|
||||
u16::from(event.kind)
|
||||
));
|
||||
}
|
||||
|
||||
let secret_key = SecretKey::parse(&decryption_key)?;
|
||||
let encryption_keys = Keys::new(secret_key.clone());
|
||||
nip44::decrypt(&secret_key, &encryption_keys.public_key(), event.content).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn build_deeplink(nevent: &str, decryption_key: &str) -> String {
|
||||
format!(
|
||||
"goose://sessions/nostr?nevent={}&key={}",
|
||||
urlencoding::encode(nevent),
|
||||
urlencoding::encode(decryption_key)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn parse_deeplink(deeplink: &str) -> Result<ParsedShareLink> {
|
||||
let parsed = url::Url::parse(deeplink).context("Invalid Goose session share link")?;
|
||||
if parsed.scheme() != "goose"
|
||||
|| parsed.host_str() != Some("sessions")
|
||||
|| parsed.path() != "/nostr"
|
||||
{
|
||||
return Err(anyhow!("Invalid Goose Nostr session share link"));
|
||||
}
|
||||
|
||||
let nevent = parsed
|
||||
.query_pairs()
|
||||
.find_map(|(key, value)| (key == "nevent").then(|| value.into_owned()))
|
||||
.ok_or_else(|| anyhow!("Missing nevent parameter"))?;
|
||||
let decryption_key = parsed
|
||||
.query_pairs()
|
||||
.find_map(|(key, value)| (key == "key").then(|| value.into_owned()))
|
||||
.ok_or_else(|| anyhow!("Missing decryption key parameter"))?;
|
||||
|
||||
Ok(ParsedShareLink {
|
||||
nevent,
|
||||
decryption_key,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_relays(relays: Vec<String>) -> Vec<String> {
|
||||
let mut normalized = Vec::new();
|
||||
for relay in relays {
|
||||
let relay = relay.trim();
|
||||
if relay.is_empty() || normalized.iter().any(|existing| existing == relay) {
|
||||
continue;
|
||||
}
|
||||
normalized.push(relay.to_string());
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
struct RecordingPublisher {
|
||||
event: Arc<Mutex<Option<Event>>>,
|
||||
relays: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NostrPublisher for RecordingPublisher {
|
||||
async fn publish(&self, event: Event, relays: &[String]) -> Result<()> {
|
||||
*self.event.lock().unwrap() = Some(event);
|
||||
*self.relays.lock().unwrap() = relays.to_vec();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct StaticFetcher(Event);
|
||||
|
||||
#[async_trait]
|
||||
impl NostrFetcher for StaticFetcher {
|
||||
async fn fetch(&self, _event_id: EventId, _relays: &[String]) -> Result<Event> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_builds_deeplink_and_encrypted_kind_30278_event() {
|
||||
let event = Arc::new(Mutex::new(None));
|
||||
let relays = Arc::new(Mutex::new(Vec::new()));
|
||||
let publisher = RecordingPublisher {
|
||||
event: event.clone(),
|
||||
relays: relays.clone(),
|
||||
};
|
||||
|
||||
let share = publish_session_json_with(
|
||||
r#"{"id":"session-id","conversation":{"messages":[]}}"#,
|
||||
vec!["wss://relay.example".to_string()],
|
||||
&publisher,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(share.deeplink.starts_with("goose://sessions/nostr?"));
|
||||
assert!(share.nevent.starts_with("nevent1"));
|
||||
assert_eq!(share.relays, vec!["wss://relay.example"]);
|
||||
assert_eq!(*relays.lock().unwrap(), vec!["wss://relay.example"]);
|
||||
|
||||
let event = event.lock().unwrap().clone().unwrap();
|
||||
assert_eq!(event.kind, Kind::Custom(EVENT_KIND));
|
||||
assert_ne!(
|
||||
event.content,
|
||||
r#"{"id":"session-id","conversation":{"messages":[]}}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_and_import_round_trips_session_json() {
|
||||
let event = Arc::new(Mutex::new(None));
|
||||
let publisher = RecordingPublisher {
|
||||
event: event.clone(),
|
||||
relays: Arc::new(Mutex::new(Vec::new())),
|
||||
};
|
||||
let json = r#"{"id":"session-id","name":"shared"}"#;
|
||||
|
||||
let share =
|
||||
publish_session_json_with(json, vec!["wss://relay.example".to_string()], &publisher)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let fetched_event = event.lock().unwrap().clone().unwrap();
|
||||
let imported =
|
||||
import_session_json_from_deeplink_with(&share.deeplink, &StaticFetcher(fetched_event))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(imported, json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_deeplink() {
|
||||
let parsed = parse_deeplink("goose://sessions/nostr?nevent=abc&key=def").unwrap();
|
||||
assert_eq!(parsed.nevent, "abc");
|
||||
assert_eq!(parsed.decryption_key, "def");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user