Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions crates/agent-controller/src/app_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub about: Option<String>,
/// The last profile its community accepted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<Published>,
Expand All @@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub about: Option<String>,
pub created_at: u64,
}
impl AppAgent {
Expand Down Expand Up @@ -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<serde_json::Value> {
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,
Expand Down Expand Up @@ -299,6 +320,8 @@ impl AppAgents {
auth,
agent_type: prepared.agent_type,
name: prepared.name,
picture: None,
about: None,
profile: None,
deleted: false,
};
Expand Down
67 changes: 67 additions & 0 deletions crates/agent-controller/src/app_agent/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
}
10 changes: 7 additions & 3 deletions src-tauri/src/app_agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
Expand All @@ -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?;
Expand All @@ -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,
},
)
Expand Down Expand Up @@ -590,6 +592,8 @@ mod tests {
auth: "[]".into(),
agent_type: "p/t".into(),
name: "Ada".into(),
picture: None,
about: None,
profile: None,
deleted: false,
};
Expand Down
Loading