Skip to content
Merged
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -680,9 +680,9 @@ test-integration:
./scripts/run-tests.sh integration

# Enforce-mode upload/publication/read journey (requires PostgreSQL and Redis).
# Uses the isolated PostgreSQL runner, including emoji entry replacement checks.
# Uses the isolated PostgreSQL runner, including CLI edits and the target index.
test-media-access:
./scripts/postgres-test-run.sh -p buzz-relay --lib -E 'test(=api::media_access_postgres_tests::private_media_revocation_http_journey)'
./scripts/postgres-test-run.sh -p buzz-relay -p buzz-db --lib -E 'test(/^api::media_access_postgres_tests::/) | test(effective_edit_uses_target_index) | test(historical_edit_preflight)'

# Regenerate the model-capability normative corpus from the production Rust
# resolver. The corpus is a golden snapshot, never hand-edited: this runs the
Expand Down
3 changes: 3 additions & 0 deletions crates/buzz-cli/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,9 @@ buzz messages search --query "CLI test" --limit 5 | jq .

# messages edit
buzz messages edit --event "$EVENT_ID" --content "Edited by CLI test" | jq .
# Explicit removal stays removed in a subsequent text edit.
buzz messages edit --event "$EVENT_ID" --content "Remove attached files" --clear-attachments | jq .
buzz messages edit --event "$EVENT_ID" --content "Still no attachments" | jq .

# messages delete
buzz messages delete --event "$REPLY_ID" | jq .
Expand Down
31 changes: 7 additions & 24 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
mod edit;

use buzz_sdk::{DeleteMessageOptions, DiffMeta, ThreadRef, VoteDirection};
use nostr::PublicKey;
use uuid::Uuid;
Expand Down Expand Up @@ -895,29 +897,6 @@ pub async fn cmd_delete_message(
Ok(())
}

/// Edit a message you previously sent.
pub async fn cmd_edit_message(
client: &BuzzClient,
event_id: &str,
content: &str,
) -> Result<(), CliError> {
validate_hex64(event_id)?;
validate_content_size(content)?;

// Resolve channel_id from the event's h-tag
let channel_uuid = resolve_channel_id(client, event_id).await?;
let target_eid = parse_event_id(event_id)?;

let builder = buzz_sdk::build_edit(channel_uuid, target_eid, content)
.map_err(|e| CliError::Other(format!("build_edit failed: {e}")))?;

let event = client.sign_event(builder).await?;

let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}

/// Vote on a forum post or comment.
pub async fn cmd_vote_on_post(
client: &BuzzClient,
Expand Down Expand Up @@ -1014,7 +993,11 @@ pub async fn dispatch(
)
.await
}
MessagesCmd::Edit { event, content } => cmd_edit_message(client, &event, &content).await,
MessagesCmd::Edit {
event,
content,
clear_attachments,
} => edit::edit_message(client, &event, &content, clear_attachments).await,
MessagesCmd::Delete {
event,
action_id,
Expand Down
132 changes: 132 additions & 0 deletions crates/buzz-cli/src/commands/messages/edit.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//! Resolve replacement attachment state before publishing an edit.
use super::{channel_id_from_event, fetch_event};
use crate::{
client::BuzzClient,
error::CliError,
validate::{parse_event_id, validate_content_size, validate_hex64},
};
use nostr::{Event, Timestamp};

pub(super) async fn edit_message(
client: &BuzzClient,
event_id: &str,
content: &str,
clear_attachments: bool,
) -> Result<(), CliError> {
validate_hex64(event_id)?;
validate_content_size(content)?;
let target = parse_event_id(event_id)?;
let raw = fetch_event(client, &target.to_hex()).await?;
let channel = channel_id_from_event(event_id, &raw)?;
let original: Event = serde_json::from_value(raw)
.map_err(|e| CliError::Other(format!("invalid edit target: {e}")))?;
if original.id != target {
return Err(CliError::Other(
"relay returned the wrong edit target".into(),
));
}
// Keep this boundary aligned with media_reference_query and the relay lookup.
// Other channel-tagged events retain the historical plain-edit behavior.
if !buzz_core::media::EDIT_GOVERNED_KINDS.contains(&original.kind.as_u16()) {
if clear_attachments {
return Err(CliError::Usage(format!(
"attachments on kind {} are not edit-governed",
original.kind.as_u16()
)));
}
let builder = buzz_sdk::build_edit(channel, target, content)
.map_err(|e| CliError::Other(format!("build_edit failed: {e}")))?;
let event = client.sign_event(builder).await?;
let resp = client.submit_event(event).await?;
println!("{}", crate::client::normalize_write_response(&resp));
return Ok(());
}
let raw = client
.query(&serde_json::json!({
"kinds": buzz_core::media::EDIT_GOVERNED_KINDS, "ids": [target.to_hex()], "#h": [channel.to_string()],
"effective_edit": 1, "consistency": "strong",
}))
.await?;
let snapshot: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("invalid effective edit response: {e}")))?;
if snapshot.get("effective_edit") != Some(&serde_json::json!(1))
|| snapshot.get("edit").is_none()
{
return Err(CliError::Other(
"relay does not support effective_edit v1; upgrade the relay before editing".into(),
));
}
let original: Event = serde_json::from_value(snapshot["original"].clone())
.map_err(|e| CliError::Other(format!("invalid effective original: {e}")))?;
if original.id != target
|| !buzz_core::media::EDIT_GOVERNED_KINDS.contains(&original.kind.as_u16())
|| channel_id_from_event(event_id, &snapshot["original"])? != channel
{
return Err(CliError::Other(
"effective edit response has the wrong original".into(),
));
}
let edits: Vec<Event> = if snapshot["edit"].is_null() {
vec![]
} else {
vec![serde_json::from_value(snapshot["edit"].clone())
.map_err(|e| CliError::Other(format!("invalid effective edit: {e}")))?]
};
let effective = latest_edit(&original, &edits);
if !edits.is_empty() && effective.id == original.id {
return Err(CliError::Other(
"effective edit response has the wrong replacement".into(),
));
}
let media = if clear_attachments {
Vec::new()
} else {
effective
.tags
.iter()
.filter(|t| t.as_slice().first().map(String::as_str) == Some("imeta"))
.map(|t| t.as_slice().to_vec())
.collect()
};
let now = Timestamp::now().as_secs();
// Make successive edits effective even when issued in the same second.
// Refuse an unexpectedly future timeline instead of extending it forever.
let head = effective.created_at.as_secs();
if head > now.saturating_add(60) {
return Err(CliError::Other(
"latest edit is too far in the future; retry when the clock catches up".into(),
));
}
let created_at = now.max(head + 1);
let builder = buzz_sdk::build_edit_with_media(channel, target, content, &media)
.map_err(|e| CliError::Other(format!("build_edit failed: {e}")))?
.custom_created_at(Timestamp::from(created_at));
let event = client.sign_event(builder).await?;
let resp = client.submit_event(event).await?;
println!("{}", crate::client::normalize_write_response(&resp));
Ok(())
}

fn latest_edit<'a>(original: &'a Event, edits: &'a [Event]) -> &'a Event {
let channel = |event: &Event| {
event
.tags
.iter()
.find(|t| t.as_slice().first().map(String::as_str) == Some("h"))
.and_then(|t| t.content())
.and_then(|id| uuid::Uuid::parse_str(id).ok())
};
edits
.iter()
.filter(|edit| {
edit.kind.as_u16() == 40003
&& channel(edit) == channel(original)
&& buzz_core::media::first_edit_target(edit).as_deref()
== Some(original.id.as_bytes().as_slice())
})
.max_by_key(|edit| (edit.created_at, edit.id))
.unwrap_or(original)
}

#[cfg(test)]
mod tests;
Loading
Loading