feat(cli): accept Buzz message links for thread reads (#6359)

## Summary

- let `buzz messages thread` accept a canonical `buzz://message` link
directly
- treat the selected message ID as authoritative, verify its channel,
and derive its containing thread root from the event
- reject stale or forged `thread` hints that disagree with the selected
message
- preserve the existing `--channel` / `--event` form while allowing it
to target any message in a thread

## Why

Agent skills should be able to hand first-class Buzz links to the public
Buzz CLI instead of carrying their own URL parsing wrappers. Link
handling belongs with the CLI that owns the protocol; identity and relay
selection remain explicit through the existing Buzz CLI environment and
do not read Buzz Desktop private state.

## Verification

- `cargo test -p buzz-cli` — 360 passed
- `cargo clippy -p buzz-cli -- -D warnings`
- `cargo fmt --all -- --check`
- repository pre-push gates, including 5,070 Desktop tests
- final architecture/code review — no must-fix findings

A relay-backed manual smoke test was not run because this session did
not have `BUZZ_RELAY_URL` and `BUZZ_PRIVATE_KEY` configured; the change
has focused parser, CLI argument, channel/root binding, and mismatch
tests.

---------

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-08-20 11:17:50 -07:00
committed by GitHub
parent 1934e83bf5
commit 84c095f8be
6 changed files with 423 additions and 70 deletions

View File

@@ -202,15 +202,16 @@ or invoke with the full path.
### Deep Links
`buzz://message?channel=<uuid>&id=<hex>` links reference a specific message
thread. To read the linked thread:
thread. Pass the link directly to the CLI:
```bash
buzz --format compact messages thread --channel <uuid> --event <hex>
buzz --format compact messages thread --link '<buzz://message?...>'
```
Extract `channel` and `id` from the URL query parameters. The optional
`thread` parameter (root event ID) can be ignored — `messages thread` resolves
the full thread from the event ID alone.
The selected message ID is authoritative: `messages thread` verifies its
channel and derives its containing root. An optional `thread` parameter is
accepted only when it matches that derived root. The explicit
`--channel <uuid> --event <hex>` form remains available.
All reads return sig-stripped JSON arrays; all writes return
`{event_id, accepted, message}`; creates add the entity ID. Exit codes:

View File

@@ -34,6 +34,7 @@ buzz messages send --channel <uuid> --content "Reply" --reply-to <event-id> --br
buzz messages send --channel <uuid> --content - < message.md # read body from stdin
buzz messages get --channel <uuid> --limit 20
buzz messages thread --channel <uuid> --event <event-id>
buzz messages thread --link 'buzz://message?channel=<uuid>&id=<event-id>&thread=<root-id>'
buzz messages search --query "architecture"
buzz messages search --author <pubkey|npub|name> --since <unix-ts>
buzz messages edit --event <event-id> --content "Updated text"

View File

@@ -216,8 +216,11 @@ echo 'Body with `backticks` and $vars stays literal.' \
buzz messages get --channel "$CHANNEL_ID" | jq .
buzz messages get --channel "$CHANNEL_ID" --limit 5 | jq .
# messages thread
# messages thread from the root, a reply, and a canonical link
buzz messages thread --channel "$CHANNEL_ID" --event "$EVENT_ID" | jq .
buzz messages thread --channel "$CHANNEL_ID" --event "$REPLY_ID" | jq .
buzz messages thread \
--link "buzz://message?channel=$CHANNEL_ID&id=$REPLY_ID&thread=$EVENT_ID" | jq .
# messages search
buzz messages search --query "Hello" | jq .

View File

@@ -54,68 +54,69 @@ fn find_root_from_tags(tags: &serde_json::Value) -> Option<String> {
/// - Nested reply: `root` is the parent's own root marker; `parent` is unchanged.
///
/// Ensures CLI-sent replies thread correctly using the same NIP-10 logic.
async fn fetch_event(client: &BuzzClient, event_id: &str) -> Result<serde_json::Value, CliError> {
let filter = serde_json::json!({ "ids": [event_id], "limit": 1 });
let raw = client.query(&filter).await?;
let events: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse query response: {e}")))?;
events
.as_array()
.and_then(|events| events.first())
.cloned()
.ok_or_else(|| CliError::NotFound(format!("event {event_id} not found")))
}
async fn resolve_thread_ref(
client: &BuzzClient,
parent_event_id: &str,
) -> Result<ThreadRef, CliError> {
let parent_eid = parse_event_id(parent_event_id)?;
let filter = serde_json::json!({ "ids": [parent_event_id], "limit": 1 });
let raw = client.query(&filter).await?;
let events: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse query response: {e}")))?;
let event = events
.as_array()
.and_then(|a| a.first())
.ok_or_else(|| CliError::Other(format!("parent event {parent_event_id} not found")))?;
let event = fetch_event(client, parent_event_id).await?;
thread_ref_from_event(parent_event_id, &event)
}
fn thread_ref_from_event(event_id: &str, event: &serde_json::Value) -> Result<ThreadRef, CliError> {
let parent_eid = parse_event_id(event_id)?;
let tags = event
.get("tags")
.cloned()
.unwrap_or(serde_json::Value::Null);
let root_eid = match find_root_from_tags(&tags) {
Some(root_hex) if root_hex != parent_event_id => parse_event_id(&root_hex)?,
let root_event_id = match find_root_from_tags(&tags) {
Some(root_hex) if root_hex != event_id => parse_event_id(&root_hex)?,
_ => parent_eid,
};
Ok(ThreadRef {
root_event_id: root_eid,
root_event_id,
parent_event_id: parent_eid,
})
}
/// Resolve the channel UUID for an event by querying for it via POST /query.
/// Extracts the `h` tag value from the returned event's tags.
async fn resolve_channel_id(client: &BuzzClient, event_id: &str) -> Result<Uuid, CliError> {
let filter = serde_json::json!({
"ids": [event_id]
});
let raw = client.query(&filter).await?;
let events: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse query response: {e}")))?;
let arr = events
.as_array()
.ok_or_else(|| CliError::Other("query response is not an array".into()))?;
let event = arr
.first()
.ok_or_else(|| CliError::Other(format!("event {event_id} not found")))?;
fn channel_id_from_event(event_id: &str, event: &serde_json::Value) -> Result<Uuid, CliError> {
let tags = event
.get("tags")
.and_then(|t| t.as_array())
.and_then(|tags| tags.as_array())
.ok_or_else(|| CliError::Other("event missing 'tags' field".into()))?;
for tag in tags {
if let Some(arr) = tag.as_array() {
if arr.first().and_then(|v| v.as_str()) == Some("h") {
if let Some(uuid_str) = arr.get(1).and_then(|v| v.as_str()) {
return Uuid::parse_str(uuid_str).map_err(|_| {
CliError::Other(format!("event h-tag is not a valid UUID: {uuid_str}"))
});
}
}
}
}
Err(CliError::Other(format!(
"event {event_id} has no h-tag — cannot determine channel"
)))
tags.iter()
.filter_map(|tag| tag.as_array())
.find(|tag| tag.first().and_then(|value| value.as_str()) == Some("h"))
.and_then(|tag| tag.get(1))
.and_then(|value| value.as_str())
.ok_or_else(|| {
CliError::Other(format!(
"event {event_id} has no h-tag — cannot determine channel"
))
})
.and_then(|channel_id| {
Uuid::parse_str(channel_id).map_err(|_| {
CliError::Other(format!("event h-tag is not a valid UUID: {channel_id}"))
})
})
}
async fn resolve_channel_id(client: &BuzzClient, event_id: &str) -> Result<Uuid, CliError> {
let event = fetch_event(client, event_id).await?;
channel_id_from_event(event_id, &event)
}
fn resolve_names_to_pubkeys(
@@ -391,37 +392,71 @@ pub async fn cmd_get_messages(
Ok(())
}
pub fn resolve_thread_target(
expected_channel_id: Uuid,
event_id: &str,
expected_root_id: Option<&str>,
selected_event: &serde_json::Value,
) -> Result<String, CliError> {
let actual_channel_id = channel_id_from_event(event_id, selected_event)?;
if actual_channel_id != expected_channel_id {
return Err(CliError::Usage(format!(
"event {event_id} does not belong to channel {expected_channel_id}"
)));
}
let root_event_id = thread_ref_from_event(event_id, selected_event)?
.root_event_id
.to_hex();
if expected_root_id.is_some_and(|expected| expected != root_event_id) {
return Err(CliError::Usage(
"Buzz message link thread root does not match the selected message".into(),
));
}
Ok(root_event_id)
}
pub async fn cmd_get_thread(
client: &BuzzClient,
channel_id: &str,
event_id: &str,
expected_root_id: Option<&str>,
limit: Option<u32>,
depth_limit: Option<u32>,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let expected_channel_id = parse_uuid(channel_id)?;
validate_hex64(event_id)?;
let selected_event = fetch_event(client, event_id).await?;
let root_event_id = resolve_thread_target(
expected_channel_id,
event_id,
expected_root_id,
&selected_event,
)?;
let limit = limit.unwrap_or(100).min(500);
// Two filters ORed in a single HTTP call:
// 1. Replies referencing this event via e-tag (no kind restriction)
// 2. The root event itself by ID
let mut reply_filter = serde_json::json!({
"kinds": [9, 40002, 40003, 40008, 45003],
"#h": [channel_id],
"#e": [event_id],
"#e": [root_event_id.as_str()],
"limit": limit
});
if let Some(d) = depth_limit {
reply_filter["depth_limit"] = serde_json::json!(d);
}
let root_filter = serde_json::json!({
"ids": [event_id],
"ids": [root_event_id.as_str()],
"#h": [channel_id],
"limit": 1
});
let resp = client.query_multi(&[reply_filter, root_filter]).await?;
let mut events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
events.sort_by_key(|e| e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0));
events.sort_by_key(|event| {
event
.get("created_at")
.and_then(|value| value.as_u64())
.unwrap_or(0)
});
let normalized = normalize_events(&events);
println!("{}", format_events(&normalized, format));
Ok(())
@@ -965,9 +1000,35 @@ pub async fn dispatch(
MessagesCmd::Thread {
channel,
event,
link,
limit,
depth_limit,
} => cmd_get_thread(client, &channel, &event, limit, depth_limit, format).await,
} => {
let (channel, event, expected_root) =
match link {
Some(link) => {
let parsed = crate::links::parse_message_link(&link)?;
(parsed.channel_id, parsed.message_id, parsed.thread_root_id)
}
None => match (channel, event) {
(Some(channel), Some(event)) => (channel, event, None),
_ => return Err(CliError::Usage(
"messages thread requires either --link or both --channel and --event"
.into(),
)),
},
};
cmd_get_thread(
client,
&channel,
&event,
expected_root.as_deref(),
limit,
depth_limit,
format,
)
.await
}
MessagesCmd::Search {
query,
author,
@@ -993,13 +1054,15 @@ pub async fn dispatch(
#[cfg(test)]
mod tests {
use super::{
event_mention_pubkeys, find_root_from_tags, match_profiles_by_name, merge_message_mentions,
missing_members, normalize_explicit_mentions, parse_member_pubkeys,
resolve_names_to_pubkeys,
channel_id_from_event, cmd_get_thread, event_mention_pubkeys, find_root_from_tags,
match_profiles_by_name, merge_message_mentions, missing_members,
normalize_explicit_mentions, parse_member_pubkeys, resolve_names_to_pubkeys,
resolve_thread_target, thread_ref_from_event, BuzzClient, CliError, Uuid,
};
use buzz_sdk::mentions::{
extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile,
};
use nostr::Keys;
use serde_json::json;
const ID_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
@@ -1012,6 +1075,92 @@ mod tests {
const PK_VALID_B: &str = "c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05";
const PK_VALID_C: &str = "f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68";
#[tokio::test]
async fn malformed_channel_is_rejected_before_thread_fetch() {
let client =
BuzzClient::new("http://127.0.0.1:1".into(), Keys::generate(), None, None).unwrap();
let error = cmd_get_thread(
&client,
"not-a-uuid",
ID_A,
None,
None,
None,
&crate::OutputFormat::Json,
)
.await
.unwrap_err();
assert!(matches!(error, CliError::Usage(_)));
assert!(error.to_string().contains("invalid UUID"));
}
#[test]
fn selected_event_derives_authoritative_channel_and_root() {
let channel = "123e4567-e89b-12d3-a456-426614174000";
let event = json!({
"tags": [
["h", channel],
["e", ID_A, "", "root"],
["e", ID_B, "", "reply"],
]
});
assert_eq!(
channel_id_from_event(ID_B, &event).unwrap().to_string(),
channel
);
assert_eq!(
thread_ref_from_event(ID_B, &event)
.unwrap()
.root_event_id
.to_hex(),
ID_A
);
}
#[test]
fn selected_event_requires_a_valid_channel_tag() {
let missing = json!({"tags": []});
let malformed = json!({"tags": [["h", "not-a-uuid"]]});
assert!(channel_id_from_event(ID_A, &missing).is_err());
assert!(channel_id_from_event(ID_A, &malformed).is_err());
}
#[test]
fn thread_target_rejects_wrong_channel_or_root_hint() {
let channel = "123e4567-e89b-12d3-a456-426614174000";
let other_channel = "123e4567-e89b-12d3-a456-426614174001";
let selected = json!({
"tags": [["h", channel], ["e", ID_A, "", "root"]]
});
assert!(resolve_thread_target(
Uuid::parse_str(other_channel).unwrap(),
ID_B,
Some(ID_A),
&selected,
)
.is_err());
assert!(resolve_thread_target(
Uuid::parse_str(channel).unwrap(),
ID_B,
Some(ID_B),
&selected,
)
.is_err());
assert_eq!(
resolve_thread_target(
Uuid::parse_str(channel).unwrap(),
ID_B,
Some(ID_A),
&selected,
)
.unwrap(),
ID_A
);
}
#[test]
fn root_marker_wins_over_reply_marker() {
let tags = json!([

View File

@@ -480,14 +480,20 @@ pub enum MessagesCmd {
#[arg(long)]
kinds: Option<String>,
},
/// Get a message thread (replies to a root message)
/// Get the containing thread for a message or Buzz message link
#[command(
after_help = "Examples:\n buzz messages thread --channel <UUID> --event <EVENT_ID>\n buzz messages thread --link 'buzz://message?channel=<UUID>&id=<EVENT_ID>&thread=<ROOT_ID>'"
)]
Thread {
/// Channel UUID
#[arg(long)]
channel: String,
/// Root message event ID (64-char hex)
#[arg(long)]
event: String,
/// Channel UUID; required unless --link is supplied
#[arg(long, required_unless_present = "link", conflicts_with = "link")]
channel: Option<String>,
/// Message event ID (64-char hex); required unless --link is supplied
#[arg(long, required_unless_present = "link", conflicts_with = "link")]
event: Option<String>,
/// Canonical buzz://message deep link; uses the configured relay and identity
#[arg(long, conflicts_with_all = ["channel", "event"])]
link: Option<String>,
/// Maximum number of results to return
#[arg(long)]
limit: Option<u32>,
@@ -2120,6 +2126,47 @@ mod tests {
Cli::command().debug_assert();
}
#[test]
fn messages_thread_accepts_link_or_explicit_identifiers() {
let channel = "123e4567-e89b-12d3-a456-426614174000";
let event = "a".repeat(64);
let link = format!("buzz://message?channel={channel}&id={event}");
assert!(
Cli::try_parse_from(["buzz", "messages", "thread", "--link", link.as_str(),]).is_ok()
);
assert!(Cli::try_parse_from([
"buzz",
"messages",
"thread",
"--channel",
channel,
"--event",
event.as_str(),
])
.is_ok());
}
#[test]
fn messages_thread_rejects_partial_or_mixed_targets() {
let channel = "123e4567-e89b-12d3-a456-426614174000";
let event = "a".repeat(64);
let link = format!("buzz://message?channel={channel}&id={event}");
assert!(Cli::try_parse_from(["buzz", "messages", "thread"]).is_err());
assert!(Cli::try_parse_from(["buzz", "messages", "thread", "--channel", channel]).is_err());
assert!(Cli::try_parse_from([
"buzz",
"messages",
"thread",
"--link",
link.as_str(),
"--event",
event.as_str(),
])
.is_err());
}
#[test]
fn set_status_clear_rejects_text_and_emoji() {
for extra in [["--text", "busy"], ["--emoji", "🎶"]] {

View File

@@ -1,10 +1,10 @@
//! Canonical `buzz://` deep links for Buzz-hosted git entities.
//! Canonical `buzz://` deep links for Buzz entities.
//!
//! Buzz Desktop renders these links as rich preview cards in chat and
//! navigates in-app when they are clicked. The desktop parser lives in
//! `desktop/src/shared/lib/entityLink.ts` — the two implementations must
//! stay format-compatible (see `golden_format_matches_desktop` below and
//! the mirror test in `entityLink.test.mjs`).
//! `desktop/src/shared/lib/entityLink.ts` for git entities and
//! `desktop/src/features/messages/lib/messageLink.ts` for messages. The
//! implementations must stay format-compatible.
//!
//! Callers are expected to validate inputs first (`validate_hex64`,
//! `validate_repo_id`); the identifier charsets need no URL encoding.
@@ -15,6 +15,94 @@
//! (overview); the parameter exists for the desktop's tab-aware copy-link
//! button.
use crate::error::CliError;
/// A validated `buzz://message` deep link.
#[derive(Debug, PartialEq, Eq)]
pub struct MessageLink {
pub channel_id: String,
pub message_id: String,
pub thread_root_id: Option<String>,
}
/// Parse a `buzz://message?channel=<uuid>&id=<event>[&thread=<root>]` link.
///
/// The link chooses only the channel and event within the relay already
/// configured for this CLI process. It cannot override the relay or identity.
pub fn parse_message_link(input: &str) -> Result<MessageLink, CliError> {
let url = url::Url::parse(input.trim())
.map_err(|_| CliError::Usage("invalid Buzz message link".into()))?;
if url.scheme() != "buzz"
|| url.host_str() != Some("message")
|| !matches!(url.path(), "" | "/")
|| !url.username().is_empty()
|| url.password().is_some()
|| url.fragment().is_some()
{
return Err(CliError::Usage(
"expected a buzz://message link without credentials or a fragment".into(),
));
}
let mut channel = None;
let mut message = None;
let mut thread = None;
for (key, value) in url.query_pairs() {
let slot = match key.as_ref() {
"channel" => &mut channel,
"id" => &mut message,
"thread" => &mut thread,
_ => {
return Err(CliError::Usage(
"Buzz message link contains an unsupported query parameter".into(),
))
}
};
if slot.replace(value.into_owned()).is_some() {
return Err(CliError::Usage(format!(
"Buzz message link contains more than one {key} parameter"
)));
}
}
let channel = channel
.filter(|value| !value.is_empty())
.ok_or_else(|| CliError::Usage("Buzz message link is missing channel".into()))?;
let message = message
.filter(|value| !value.is_empty())
.ok_or_else(|| CliError::Usage("Buzz message link is missing id".into()))?;
if thread.as_deref() == Some("") {
return Err(CliError::Usage(
"Buzz message link contains an empty thread parameter".into(),
));
}
let channel_id = uuid::Uuid::parse_str(&channel)
.map_err(|_| CliError::Usage("Buzz message link contains an invalid channel UUID".into()))?
.to_string();
let message_id = canonical_event_id(&message, "id")?;
let thread_root_id = thread
.as_deref()
.map(|value| canonical_event_id(value, "thread"))
.transpose()?;
Ok(MessageLink {
channel_id,
message_id,
thread_root_id,
})
}
fn canonical_event_id(value: &str, parameter: &str) -> Result<String, CliError> {
if value.len() != 64 || !value.chars().all(|character| character.is_ascii_hexdigit()) {
return Err(CliError::Usage(format!(
"Buzz message link contains an invalid {parameter} event ID"
)));
}
Ok(value.to_ascii_lowercase())
}
/// Whether a d-tag can be expressed in a `buzz://` link.
///
/// Project slugs accept up to 1024 bytes of arbitrary UTF-8, but the link
@@ -58,6 +146,10 @@ mod tests {
use super::*;
use serde_json::Value;
const CHANNEL: &str = "123e4567-e89b-12d3-a456-426614174000";
const MESSAGE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const THREAD: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
fn golden() -> Value {
serde_json::from_str(include_str!("../../../test-fixtures/entity-links.json"))
.expect("valid entity-links golden fixture")
@@ -101,4 +193,64 @@ mod tests {
}
assert!(!is_linkable_dtag(&"a".repeat(65)));
}
#[test]
fn parses_message_link_with_thread_root() {
let parsed = parse_message_link(&format!(
"buzz://message?channel={CHANNEL}&id={MESSAGE}&thread={THREAD}"
))
.unwrap();
assert_eq!(
parsed,
MessageLink {
channel_id: CHANNEL.into(),
message_id: MESSAGE.into(),
thread_root_id: Some(THREAD.into()),
}
);
}
#[test]
fn parses_message_link_without_thread_root() {
let parsed =
parse_message_link(&format!("buzz://message?channel={CHANNEL}&id={MESSAGE}")).unwrap();
assert_eq!(parsed.thread_root_id, None);
}
#[test]
fn normalizes_message_link_identifiers() {
let parsed = parse_message_link(&format!(
"buzz://message?channel={}&id={}",
CHANNEL.to_ascii_uppercase(),
MESSAGE.to_ascii_uppercase()
))
.unwrap();
assert_eq!(parsed.channel_id, CHANNEL);
assert_eq!(parsed.message_id, MESSAGE);
}
#[test]
fn rejects_message_link_that_could_change_connection_context() {
for link in [
format!("buzz://message?channel={CHANNEL}&id={MESSAGE}&relay=other"),
format!("buzz://user:secret@message?channel={CHANNEL}&id={MESSAGE}"),
format!("buzz://message?channel={CHANNEL}&id={MESSAGE}#fragment"),
] {
assert!(parse_message_link(&link).is_err(), "accepted {link}");
}
}
#[test]
fn rejects_duplicate_or_malformed_message_link_identifiers() {
for link in [
format!("buzz://message?channel={CHANNEL}&channel={CHANNEL}&id={MESSAGE}"),
format!("buzz://message?channel=not-a-uuid&id={MESSAGE}"),
format!("buzz://message?channel={CHANNEL}&id=not-an-event"),
format!("buzz://message?channel={CHANNEL}&id={MESSAGE}&thread="),
] {
assert!(parse_message_link(&link).is_err(), "accepted {link}");
}
}
}