249 lines
8.8 KiB
Rust
249 lines
8.8 KiB
Rust
//! Shared helpers for creating, signing, encrypting, storing and publishing
|
||
//! the user's own Nostr events (agent conversations, skills, deletions).
|
||
//!
|
||
//! Port of the C `nostr_create_and_sign_event_with_signer` +
|
||
//! `synchronous_publish_event_with_progress` usage in agent_conversations.c
|
||
//! and agent_skills.c. All functions are blocking: call them from a worker
|
||
//! thread (never the GTK main thread) since remote signers and relay
|
||
//! publishing can take seconds.
|
||
|
||
use nostr_core::types::{Event, Kind, PublicKey, Tag};
|
||
|
||
/// Maximum number of bootstrap relays to publish to.
|
||
const MAX_RELAYS: usize = 32;
|
||
/// Per-publish connect timeout.
|
||
const PUBLISH_CONNECT_TIMEOUT_MS: u64 = 10_000;
|
||
|
||
fn now_secs() -> u64 {
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_secs()
|
||
}
|
||
|
||
fn parse_pubkey(hex_str: &str) -> Result<PublicKey, String> {
|
||
let bytes = hex::decode(hex_str).map_err(|_| "Invalid pubkey hex".to_string())?;
|
||
let arr: [u8; 32] = bytes.try_into().map_err(|_| "Invalid pubkey length".to_string())?;
|
||
Ok(PublicKey::from_bytes(arr))
|
||
}
|
||
|
||
/// The signed-in identity, or an error if there is no signer (read-only
|
||
/// / no-login mode).
|
||
fn identity() -> Result<(std::sync::Arc<dyn nostr_signer::traits::NostrSigner>, String), String> {
|
||
let signer = crate::menu::app_get_signer().ok_or("No signer available (read-only or not logged in)")?;
|
||
let pk = crate::menu::app_get_pubkey_hex();
|
||
if pk.is_empty() {
|
||
return Err("Not logged in".to_string());
|
||
}
|
||
Ok((signer, pk))
|
||
}
|
||
|
||
/// Whether a signer is available for publishing / encryption.
|
||
pub fn have_signer() -> bool {
|
||
identity().is_ok()
|
||
}
|
||
|
||
/// The signed-in user's pubkey hex (empty when not logged in).
|
||
pub fn my_pubkey() -> String {
|
||
crate::menu::app_get_pubkey_hex()
|
||
}
|
||
|
||
/// NIP-44 encrypt `plaintext` to self; returns base64 ciphertext.
|
||
pub fn nip44_encrypt_self(plaintext: &str) -> Result<String, String> {
|
||
let (signer, pk_hex) = identity()?;
|
||
let pk = parse_pubkey(&pk_hex)?;
|
||
let ct = signer
|
||
.nip44_encrypt(&pk, plaintext)
|
||
.map_err(|e| format!("NIP-44 encrypt failed: {}", e))?;
|
||
Ok(nostr_core::util::base64_encode(&ct))
|
||
}
|
||
|
||
/// NIP-44 decrypt a base64 ciphertext encrypted to self.
|
||
pub fn nip44_decrypt_self(ciphertext_b64: &str) -> Result<String, String> {
|
||
let (signer, pk_hex) = identity()?;
|
||
let pk = parse_pubkey(&pk_hex)?;
|
||
let raw = nostr_core::util::base64_decode(ciphertext_b64)
|
||
.map_err(|e| format!("Invalid base64 ciphertext: {}", e))?;
|
||
let pt = signer
|
||
.nip44_decrypt(&pk, &raw)
|
||
.map_err(|e| format!("NIP-44 decrypt failed: {}", e))?;
|
||
String::from_utf8(pt).map_err(|_| "Decrypted content is not UTF-8".to_string())
|
||
}
|
||
|
||
/// Build and sign an event of `kind` with the given tags and content.
|
||
pub fn sign_event(kind: u64, tags: Vec<Vec<String>>, content: &str) -> Result<Event, String> {
|
||
let (signer, pk_hex) = identity()?;
|
||
let pk = parse_pubkey(&pk_hex)?;
|
||
let event = Event::new(
|
||
pk,
|
||
now_secs(),
|
||
Kind::from_u64(kind),
|
||
tags.into_iter().map(Tag).collect(),
|
||
content,
|
||
);
|
||
signer.sign_event(&event).map_err(|e| format!("Signing failed: {}", e))
|
||
}
|
||
|
||
/// Store a signed event in the local SQLite cache. Returns the event id.
|
||
pub fn store_event(event: &Event) -> Result<String, String> {
|
||
let id = event.id.as_ref().map(|i| i.to_hex()).ok_or("Signed event has no id")?;
|
||
let tags_json = serde_json::to_string(&event.tags).unwrap_or_else(|_| "[]".into());
|
||
crate::db::db_store_event(
|
||
&id,
|
||
&event.pubkey.to_hex(),
|
||
event.kind.as_u64(),
|
||
event.created_at,
|
||
&event.content,
|
||
&tags_json,
|
||
event.sig.as_ref().map(|s| s.to_hex()).as_deref(),
|
||
None,
|
||
)
|
||
.map_err(|e| format!("Failed to store event: {}", e))?;
|
||
Ok(id)
|
||
}
|
||
|
||
/// Delete a locally cached event by id (used after publishing a kind 5).
|
||
pub fn delete_local_event(id: &str) {
|
||
let _ = crate::db::db_delete_event(id);
|
||
}
|
||
|
||
/// Publish a signed event to the bootstrap relays. Returns the number of
|
||
/// relays the event was sent to. Never fails hard: offline use still
|
||
/// works because events are stored locally first.
|
||
pub fn publish_event(event: &Event) -> usize {
|
||
let relays: Vec<String> = crate::settings::settings_get_bootstrap_relays()
|
||
.into_iter()
|
||
.filter(|u| {
|
||
// Skip .onion relays unless Tor is reachable.
|
||
!u.contains(".onion")
|
||
|| crate::net_services::net_services_get_tor_socks_endpoint().is_some()
|
||
})
|
||
.take(MAX_RELAYS)
|
||
.collect();
|
||
if relays.is_empty() {
|
||
println!("[nostr-publish] No relays configured; event stored locally only");
|
||
return 0;
|
||
}
|
||
|
||
let rt = match tokio::runtime::Runtime::new() {
|
||
Ok(rt) => rt,
|
||
Err(e) => {
|
||
eprintln!("[nostr-publish] Failed to create runtime: {}", e);
|
||
return 0;
|
||
}
|
||
};
|
||
let sent = rt.block_on(async {
|
||
use nostr_relay::pool::{ReconnectConfig, RelayPool};
|
||
let pool = RelayPool::new(Some(ReconnectConfig::default()));
|
||
for url in &relays {
|
||
let _ = pool.add_relay(url).await;
|
||
}
|
||
pool.connect_all_with_timeout(PUBLISH_CONNECT_TIMEOUT_MS).await;
|
||
let connected = pool.connected_relay_urls().await;
|
||
if connected.is_empty() {
|
||
pool.disconnect_all().await;
|
||
return 0;
|
||
}
|
||
if let Err(e) = pool.publish_async(&connected, event).await {
|
||
eprintln!("[nostr-publish] Publish failed: {:?}", e);
|
||
}
|
||
// Give the relays a moment to receive the frame before closing.
|
||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||
pool.disconnect_all().await;
|
||
connected.len()
|
||
});
|
||
println!(
|
||
"[nostr-publish] kind {} published to {}/{} relays",
|
||
event.kind.as_u64(),
|
||
sent,
|
||
relays.len()
|
||
);
|
||
sent
|
||
}
|
||
|
||
/// Sign, store locally and publish. Returns the event id.
|
||
pub fn sign_store_publish(kind: u64, tags: Vec<Vec<String>>, content: &str) -> Result<String, String> {
|
||
let event = sign_event(kind, tags, content)?;
|
||
let id = store_event(&event)?;
|
||
publish_event(&event);
|
||
Ok(id)
|
||
}
|
||
|
||
/// Return the first value of tag `name` in a cached event JSON.
|
||
pub fn tag_value<'a>(event: &'a serde_json::Value, name: &str) -> Option<&'a str> {
|
||
event["tags"].as_array()?.iter().find_map(|t| {
|
||
let a = t.as_array()?;
|
||
if a.len() >= 2 && a[0].as_str() == Some(name) {
|
||
a[1].as_str()
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
}
|
||
|
||
/// All values of tag `name` in a cached event JSON.
|
||
pub fn tag_values(event: &serde_json::Value, name: &str) -> Vec<String> {
|
||
event["tags"]
|
||
.as_array()
|
||
.map(|tags| {
|
||
tags.iter()
|
||
.filter_map(|t| {
|
||
let a = t.as_array()?;
|
||
if a.len() >= 2 && a[0].as_str() == Some(name) {
|
||
a[1].as_str().map(String::from)
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.collect()
|
||
})
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
/// Whether a cached event has tag `[name, value]`.
|
||
pub fn has_tag(event: &serde_json::Value, name: &str, value: &str) -> bool {
|
||
tag_values(event, name).iter().any(|v| v == value)
|
||
}
|
||
|
||
/// For addressable events (30000–39999), keep only the newest event per
|
||
/// (pubkey, d-tag). Input need not be sorted.
|
||
pub fn latest_per_d_tag(events: Vec<serde_json::Value>) -> Vec<serde_json::Value> {
|
||
let mut best: std::collections::HashMap<(String, String), serde_json::Value> =
|
||
std::collections::HashMap::new();
|
||
for ev in events {
|
||
let key = (
|
||
ev["pubkey"].as_str().unwrap_or("").to_string(),
|
||
tag_value(&ev, "d").unwrap_or("").to_string(),
|
||
);
|
||
let newer = best
|
||
.get(&key)
|
||
.map(|cur| ev["created_at"].as_u64() > cur["created_at"].as_u64())
|
||
.unwrap_or(true);
|
||
if newer {
|
||
best.insert(key, ev);
|
||
}
|
||
}
|
||
let mut out: Vec<serde_json::Value> = best.into_values().collect();
|
||
out.sort_by(|a, b| b["created_at"].as_u64().cmp(&a["created_at"].as_u64()));
|
||
out
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn tag_helpers_and_dedup() {
|
||
let a = serde_json::json!({"pubkey":"p","created_at":1,"tags":[["d","x"],["t","a"],["t","b"]]});
|
||
let b = serde_json::json!({"pubkey":"p","created_at":5,"tags":[["d","x"]]});
|
||
let c = serde_json::json!({"pubkey":"p","created_at":3,"tags":[["d","y"]]});
|
||
assert_eq!(tag_value(&a, "d"), Some("x"));
|
||
assert_eq!(tag_values(&a, "t"), vec!["a", "b"]);
|
||
assert!(has_tag(&a, "t", "b"));
|
||
let out = latest_per_d_tag(vec![a, b, c]);
|
||
assert_eq!(out.len(), 2);
|
||
assert_eq!(out[0]["created_at"], 5);
|
||
assert_eq!(out[1]["created_at"], 3);
|
||
}
|
||
}
|