diff --git a/crates/agent-controller/src/app_agent.rs b/crates/agent-controller/src/app_agent.rs index e07c14580..7b5e0f134 100644 --- a/crates/agent-controller/src/app_agent.rs +++ b/crates/agent-controller/src/app_agent.rs @@ -31,6 +31,11 @@ pub struct AppAgent { pub agent_type: String, #[serde(default)] pub name: String, + /// Public profile metadata, published as kind-0 picture and about. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub picture: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub about: Option, /// The last profile its community accepted. #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, @@ -43,6 +48,10 @@ pub struct AppAgent { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Published { pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub picture: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub about: Option, pub created_at: u64, } impl AppAgent { @@ -90,11 +99,23 @@ impl AppAgent { let tags = self.attested(key, tags)?; key.signed(kind, content, tags) } + /// Whether the community accepted this row's current public metadata. + pub fn profile_is_current(&self) -> bool { + self.profile.as_ref().is_some_and(|last| { + last.name == self.name && last.picture == self.picture && last.about == self.about + }) + } /// Its profile as this row describes it, replacing the last one accepted. - /// No other writer exists: only this device holds the key. pub fn profile_event(&self, key: &Secret) -> Result { valid_name(&self.name)?; - let content = serde_json::json!({ "name": self.name, "bot": true }).to_string(); + let mut content = serde_json::json!({ "name": self.name, "bot": true }); + if let Some(picture) = &self.picture { + content["picture"] = picture.as_str().into(); + } + if let Some(about) = &self.about { + content["about"] = about.as_str().into(); + } + let content = content.to_string(); let tags = self.attested(key, vec![])?; key.sign_event_after( 0, @@ -299,6 +320,8 @@ impl AppAgents { auth, agent_type: prepared.agent_type, name: prepared.name, + picture: None, + about: None, profile: None, deleted: false, }; diff --git a/crates/agent-controller/src/app_agent/tests.rs b/crates/agent-controller/src/app_agent/tests.rs index 219ee8a2d..2b4518878 100644 --- a/crates/agent-controller/src/app_agent/tests.rs +++ b/crates/agent-controller/src/app_agent/tests.rs @@ -122,6 +122,8 @@ fn renames_and_profiles_replace_the_last_accepted_profile() { let future = first["created_at"].as_u64().unwrap() + 100; let published = |created_at| Published { name: "Ada".into(), + picture: None, + about: None, created_at, }; agents.published(&agent.pubkey, published(future)).unwrap(); @@ -324,3 +326,68 @@ fn concurrent_writers_on_one_file_keep_every_identity() { saved.sort(); assert_eq!(saved, made); } + +#[test] +fn saved_public_profile_survives_updates_and_is_signed() { + let dir = tempfile::tempdir().unwrap(); + let credentials = Memory::default(); + let (agents, agent) = created(dir.path(), &credentials); + let mut saved: serde_json::Value = + serde_json::from_slice(&std::fs::read(&agents.path).unwrap()).unwrap(); + saved[0]["picture"] = "https://relay.example/media/a.png".into(); + saved[0]["about"] = "Reviews pull requests.\nKeeps changes small.".into(); + // Existing receipts predate picture/about tracking. + saved[0]["profile"] = serde_json::json!({"name": "Ada", "createdAt": 500}); + std::fs::write(&agents.path, saved.to_string()).unwrap(); + agents.rename(&agent.pubkey, "Bea").unwrap(); + let reopened = AppAgents::open(agents.path.clone()); + let agent = reopened.get(&agent.pubkey).unwrap(); + let event = agent + .profile_event(&agent.read_key(&credentials).unwrap()) + .unwrap(); + let content: serde_json::Value = + serde_json::from_str(event["content"].as_str().unwrap()).unwrap(); + assert_eq!( + content, + serde_json::json!({ + "name": "Bea", "bot": true, + "picture": "https://relay.example/media/a.png", + "about": "Reviews pull requests.\nKeeps changes small.", + }) + ); + assert!(!agent.profile_is_current()); + reopened + .published( + &agent.pubkey, + Published { + name: agent.name.clone(), + picture: agent.picture.clone(), + about: agent.about.clone(), + created_at: event["created_at"].as_u64().unwrap(), + }, + ) + .unwrap(); + assert!(reopened.get(&agent.pubkey).unwrap().profile_is_current()); + let accepted = std::fs::read(&reopened.path).unwrap(); + // Each metadata-only change/clear must publish, even without a rename. + for field in ["picture", "about"] { + for replacement in [Some("https://relay.example/changed"), None] { + let mut changed: serde_json::Value = serde_json::from_slice(&accepted).unwrap(); + changed[0][field] = serde_json::json!(replacement); + std::fs::write(&reopened.path, changed.to_string()).unwrap(); + let changed = reopened.get(&agent.pubkey).unwrap(); + assert!(!changed.profile_is_current()); + let event = changed + .profile_event(&agent.read_key(&credentials).unwrap()) + .unwrap(); + let content: serde_json::Value = + serde_json::from_str(event["content"].as_str().unwrap()).unwrap(); + assert_eq!( + content.get(field).and_then(serde_json::Value::as_str), + replacement + ); + } + } + let event: nostr::event::Event = serde_json::from_value(event).unwrap(); + event.verify().unwrap(); +} diff --git a/src-tauri/src/app_agents.rs b/src-tauri/src/app_agents.rs index 9fc68db66..049466bd1 100644 --- a/src-tauri/src/app_agents.rs +++ b/src-tauri/src/app_agents.rs @@ -336,8 +336,8 @@ pub(crate) async fn app_agent_publish( post(&agent, &key, signed).await } -/// Publishes the agent's profile if its name differs from the last one its -/// community accepted. Safe to call any time; one runs per agent at a time. +/// Publishes changed public metadata. Safe to call any time; one publication +/// runs per agent at a time and records only what its community accepted. #[tauri::command] pub(crate) async fn app_agent_publish_profile( state: tauri::State<'_, AppAgentHost>, @@ -346,7 +346,7 @@ pub(crate) async fn app_agent_publish_profile( let turn = state.profile_turn(&pubkey); let _turn = turn.lock().await; let agent = state.agent(pubkey).await?; - if agent.profile.as_ref().map(|last| &last.name) == Some(&agent.name) { + if agent.profile_is_current() { return Ok(()); } let (agent, key) = state.key(agent).await?; @@ -361,6 +361,8 @@ pub(crate) async fn app_agent_publish_profile( &agent.pubkey, Published { name: agent.name, + picture: agent.picture, + about: agent.about, created_at, }, ) @@ -590,6 +592,8 @@ mod tests { auth: "[]".into(), agent_type: "p/t".into(), name: "Ada".into(), + picture: None, + about: None, profile: None, deleted: false, };