feat(sdk,cli): set channel labels and find channels by label or creator

build_create_channel takes labels and build_update_channel can replace or
clear them, validated with the relay's rules. buzz_sdk::ChannelIdentity
reads the channel type, labels and creator from group-state tags.

buzz channels create/update take --label (update also --no-labels), and
buzz channels list takes --label and --creator <npub|hex>, sent to the
relay as #t and #P. List, get and search output now include labels and
created_by.

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
Logan Johnson
2026-10-08 12:50:54 -04:00
parent b2819a5013
commit 9b633b408e
6 changed files with 408 additions and 37 deletions

View File

@@ -18,21 +18,73 @@ use crate::error::CliError;
use crate::validate::{parse_uuid, read_or_stdin, validate_hex64, validate_uuid};
fn extract_channel_metadata(e: &serde_json::Value) -> serde_json::Value {
let identity = channel_identity(e);
serde_json::json!({
"channel_id": extract_d_tag(e),
"name": extract_tag_value(e, "name"),
"description": extract_tag_value(e, "about"),
"labels": identity.labels,
"created_by": identity.created_by,
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
})
}
/// Channel type, labels and creator from a group-state event's JSON tags.
fn channel_identity(e: &serde_json::Value) -> buzz_sdk::ChannelIdentity {
let tags = e
.get("tags")
.and_then(|tags| tags.as_array())
.map(Vec::as_slice)
.unwrap_or_default();
buzz_sdk::ChannelIdentity::from_tag_pairs(tags.iter().filter_map(|tag| {
let tag = tag.as_array()?;
Some((tag.first()?.as_str()?, tag.get(1)?.as_str()?))
}))
}
/// Server-side `#t` (labels) and `#P` (creator) constraints for a kind:39000
/// query. Several labels match a channel with any one of them.
fn apply_identity_filters(
filter: &mut serde_json::Value,
labels: &[String],
creator: Option<&str>,
) {
if !labels.is_empty() {
filter["#t"] = serde_json::json!(labels);
}
if let Some(creator) = creator {
filter["#P"] = serde_json::json!([creator]);
}
}
/// Parse `--creator` (npub or hex) into lowercase hex.
fn parse_creator(creator: &str) -> Result<String, CliError> {
nostr::PublicKey::parse(creator.trim())
.map(|pubkey| pubkey.to_hex())
.map_err(|e| CliError::Usage(format!("--creator must be an npub or hex pubkey: {e}")))
}
/// Validate `--label` values with the relay's rules before signing.
fn validate_labels(labels: &[String]) -> Result<(), CliError> {
if labels.iter().any(String::is_empty) {
return Err(CliError::Usage("--label cannot be empty".into()));
}
buzz_sdk::parse_channel_labels(labels.iter().map(String::as_str))
.map(|_| ())
.map_err(|e| CliError::Usage(format!("--label: {e}")))
}
pub async fn cmd_list_channels(
client: &BuzzClient,
visibility: Option<&str>,
member: Option<bool>,
labels: &[String],
creator: Option<&str>,
limit: Option<u32>,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
validate_labels(labels)?;
let creator = creator.map(parse_creator).transpose()?;
let effective_limit = limit.unwrap_or(500);
let events = if member == Some(true) {
// Step 1: find channel IDs where we're a member (kind:39002)
@@ -54,17 +106,19 @@ pub async fn cmd_list_channels(
return Ok(());
}
// Step 2: fetch kind:39000 metadata for those channels.
let metadata_filter = serde_json::json!({
let mut metadata_filter = serde_json::json!({
"kinds": [39000],
"#d": channel_ids,
});
apply_identity_filters(&mut metadata_filter, labels, creator.as_deref());
client
.query_paginated(metadata_filter, effective_limit)
.await?
} else {
let filter = serde_json::json!({
let mut filter = serde_json::json!({
"kinds": [39000],
});
apply_identity_filters(&mut filter, labels, creator.as_deref());
client.query_paginated(filter, effective_limit).await?
};
@@ -160,6 +214,8 @@ struct ChannelSummary {
channel_id: String,
name: String,
channel_type: Option<String>,
labels: Vec<String>,
created_by: Option<String>,
visibility: Option<String>,
archived: bool,
about: Option<String>,
@@ -175,7 +231,7 @@ impl ChannelSummary {
let tags = event.get("tags")?.as_array()?;
let mut channel_id: Option<String> = None;
let mut name: Option<String> = None;
let mut channel_type: Option<String> = None;
let identity = channel_identity(event);
let mut visibility: Option<String> = None;
let mut archived = false;
let mut about: Option<String> = None;
@@ -192,8 +248,6 @@ impl ChannelSummary {
match key {
"d" => channel_id = val.map(str::to_string),
"name" => name = val.map(str::to_string),
// The first `t` is the channel type; later `t` tags are labels.
"t" if channel_type.is_none() => channel_type = val.map(str::to_string),
// NIP-29 emits both `private` and `public` (Buzz adds the latter).
// The presence of either tag is the source of truth; tag value is unused.
"private" => visibility = Some("private".to_string()),
@@ -210,7 +264,9 @@ impl ChannelSummary {
Some(ChannelSummary {
channel_id: channel_id?,
name: name?,
channel_type,
channel_type: identity.channel_type,
labels: identity.labels,
created_by: identity.created_by,
visibility,
archived,
about,
@@ -660,6 +716,7 @@ pub async fn cmd_create_channel(
visibility: &str,
description: Option<&str>,
ttl: Option<i64>,
labels: &[String],
) -> Result<(), CliError> {
match channel_type {
"stream" | "forum" => {}
@@ -679,6 +736,8 @@ pub async fn cmd_create_channel(
}
let ttl = ttl.map(validate_ttl_seconds).transpose()?;
validate_labels(labels)?;
let labels: Vec<&str> = labels.iter().map(String::as_str).collect();
let channel_uuid = Uuid::new_v4();
@@ -692,9 +751,16 @@ pub async fn cmd_create_channel(
"forum" => buzz_sdk::ChannelKind::Forum,
_ => unreachable!(),
};
let builder =
buzz_sdk::build_create_channel(channel_uuid, name, Some(vis), Some(ct), description, ttl)
.map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?;
let builder = buzz_sdk::build_create_channel(
channel_uuid,
name,
Some(vis),
Some(ct),
description,
ttl,
&labels,
)
.map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
@@ -1421,7 +1487,9 @@ pub async fn cmd_create_channel_from_template(
visibility_override: Option<&str>,
description: Option<&str>,
ttl: Option<i64>,
labels: &[String],
) -> Result<(), CliError> {
validate_labels(labels)?;
let templates_path = channel_templates::resolve_templates_path(templates_file)?;
let template: ChannelTemplateRecord =
channel_templates::find_template(&templates_path, template_name)?;
@@ -1475,6 +1543,7 @@ pub async fn cmd_create_channel_from_template(
Some(ct),
effective_description,
ttl,
&labels.iter().map(String::as_str).collect::<Vec<_>>(),
)
.map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?;
let event = client.sign_event(builder)?;
@@ -1596,16 +1665,26 @@ fn validate_update_channel_fields(
description: Option<&str>,
visibility: Option<&str>,
ttl_change: Option<Option<i32>>,
labels_change: Option<&[String]>,
) -> Result<(), CliError> {
if name.is_none() && description.is_none() && visibility.is_none() && ttl_change.is_none() {
if name.is_none()
&& description.is_none()
&& visibility.is_none()
&& ttl_change.is_none()
&& labels_change.is_none()
{
return Err(CliError::Usage(
"at least one field required (--name, --description, --visibility, --ttl, --no-ttl)"
"at least one field required (--name, --description, --visibility, --ttl, --no-ttl, --label, --no-labels)"
.into(),
));
}
if let Some(labels) = labels_change {
validate_labels(labels)?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn cmd_update_channel(
client: &BuzzClient,
channel_id: &str,
@@ -1614,6 +1693,8 @@ pub async fn cmd_update_channel(
visibility: Option<&str>,
ttl: Option<i64>,
no_ttl: bool,
labels: &[String],
no_labels: bool,
) -> Result<(), CliError> {
// Outer Option: None leaves TTL unchanged. Inner: Some(secs) sets it,
// None (from --no-ttl) clears it, making the channel permanent.
@@ -1623,12 +1704,29 @@ pub async fn cmd_update_channel(
(None, false) => None,
};
validate_update_channel_fields(name, description, visibility, ttl_change)?;
let channel_uuid = parse_uuid(channel_id)?;
// None leaves labels unchanged; an empty set (from --no-labels) clears them.
let labels_change: Option<&[String]> = if no_labels {
Some(&[])
} else if labels.is_empty() {
None
} else {
Some(labels)
};
let builder =
buzz_sdk::build_update_channel(channel_uuid, name, description, visibility, ttl_change)
.map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?;
validate_update_channel_fields(name, description, visibility, ttl_change, labels_change)?;
let channel_uuid = parse_uuid(channel_id)?;
let label_refs: Option<Vec<&str>> =
labels_change.map(|labels| labels.iter().map(String::as_str).collect());
let builder = buzz_sdk::build_update_channel(
channel_uuid,
name,
description,
visibility,
ttl_change,
label_refs.as_deref(),
)
.map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
@@ -1871,10 +1969,21 @@ pub async fn dispatch(
ChannelsCmd::List {
visibility,
member,
label,
creator,
limit,
} => {
let vis_str = visibility.as_ref().map(|v| v.to_string());
cmd_list_channels(client, vis_str.as_deref(), Some(member), limit, format).await
cmd_list_channels(
client,
vis_str.as_deref(),
Some(member),
&label,
creator.as_deref(),
limit,
format,
)
.await
}
ChannelsCmd::Get { channel } => cmd_get_channel(client, &channel).await,
ChannelsCmd::Search {
@@ -1889,6 +1998,7 @@ pub async fn dispatch(
visibility,
description,
ttl,
label,
template,
templates_file,
} => {
@@ -1902,6 +2012,7 @@ pub async fn dispatch(
visibility.as_ref().map(|v| v.to_string()).as_deref(),
description.as_deref(),
ttl,
&label,
)
.await
} else {
@@ -1918,6 +2029,7 @@ pub async fn dispatch(
&visibility.to_string(),
description.as_deref(),
ttl,
&label,
)
.await
}
@@ -1929,6 +2041,8 @@ pub async fn dispatch(
visibility,
ttl,
no_ttl,
label,
no_labels,
} => {
let visibility = visibility.as_ref().map(|v| v.to_string());
cmd_update_channel(
@@ -1939,6 +2053,8 @@ pub async fn dispatch(
visibility.as_deref(),
ttl,
no_ttl,
&label,
no_labels,
)
.await
}
@@ -1983,9 +2099,10 @@ pub async fn dispatch_canvas(cmd: crate::CanvasCmd, client: &BuzzClient) -> Resu
#[cfg(test)]
mod tests {
use super::{
apply_cardinality_rule, assemble_roster_resolution, build_hint_map, build_template_report,
cmd_set_add_policy, fetch_candidate_hints, finalize_roster_resolution, format_candidate,
hints_from_results, join_bounded_queries, name_matches, resolve_roster_with_archive_filter,
apply_cardinality_rule, apply_identity_filters, assemble_roster_resolution, build_hint_map,
build_template_report, cmd_set_add_policy, extract_channel_metadata, fetch_candidate_hints,
finalize_roster_resolution, format_candidate, hints_from_results, join_bounded_queries,
name_matches, parse_creator, resolve_roster_with_archive_filter, validate_labels,
validate_ttl_seconds, validate_update_channel_fields, ArchivedExclusion, CandidateHint,
ChannelSummary, ResolvedAgent, RosterResolution, SkippedSlug,
};
@@ -2020,6 +2137,52 @@ mod tests {
]));
let s = ChannelSummary::from_event(&ev).expect("parse");
assert_eq!(s.channel_type.as_deref(), Some("forum"));
assert_eq!(s.labels, vec!["workspace".to_string()]);
assert_eq!(s.created_by, Some("ab".repeat(32)));
let listed = extract_channel_metadata(&ev);
assert_eq!(listed["labels"], json!(["workspace"]));
assert_eq!(listed["created_by"], json!("ab".repeat(32)));
}
#[test]
fn list_filters_push_labels_and_creator_to_the_relay() {
let mut filter = json!({"kinds": [39000]});
apply_identity_filters(&mut filter, &[], None);
assert_eq!(filter, json!({"kinds": [39000]}));
let creator = "ab".repeat(32);
apply_identity_filters(
&mut filter,
&["workspace".to_string(), "team:core".to_string()],
Some(&creator),
);
assert_eq!(
filter,
json!({"kinds": [39000], "#t": ["workspace", "team:core"], "#P": [creator]})
);
}
#[test]
fn creator_accepts_npub_and_hex_and_refuses_junk() {
let keys = nostr::Keys::generate();
let hex = keys.public_key().to_hex();
let npub = nostr::ToBech32::to_bech32(&keys.public_key()).unwrap();
assert_eq!(parse_creator(&npub).unwrap(), hex);
assert_eq!(parse_creator(&hex.to_uppercase()).unwrap(), hex);
assert!(matches!(parse_creator("nope"), Err(CliError::Usage(_))));
}
#[test]
fn labels_are_checked_before_signing() {
assert!(validate_labels(&["agent-attention".to_string()]).is_ok());
for bad in ["", "Upper", "forum"] {
assert!(
matches!(validate_labels(&[bad.to_string()]), Err(CliError::Usage(_))),
"{bad:?} must be refused"
);
}
let clear: &[String] = &[];
assert!(validate_update_channel_fields(None, None, None, None, Some(clear)).is_ok());
}
#[test]
@@ -2128,7 +2291,7 @@ mod tests {
#[test]
fn update_channel_fields_rejects_empty_update() {
let result = validate_update_channel_fields(None, None, None, None);
let result = validate_update_channel_fields(None, None, None, None, None);
assert!(matches!(result, Err(CliError::Usage(_))));
let msg = result.unwrap_err().to_string();
assert!(msg.contains("at least one field required"));
@@ -2137,7 +2300,7 @@ mod tests {
#[test]
fn update_channel_fields_accepts_visibility_only_update() {
let result = validate_update_channel_fields(None, None, Some("open"), None);
let result = validate_update_channel_fields(None, None, Some("open"), None, None);
assert!(result.is_ok(), "visibility-only update should be accepted");
}

View File

@@ -573,7 +573,7 @@ pub enum MessagesCmd {
pub enum ChannelsCmd {
/// List channels visible to the current identity
#[command(
after_help = "Examples:\n buzz channels list\n buzz channels list --visibility open"
after_help = "Examples:\n buzz channels list\n buzz channels list --visibility open\n buzz channels list --label workspace --creator <npub>"
)]
List {
/// Filter by visibility
@@ -582,6 +582,14 @@ pub enum ChannelsCmd {
/// Only show channels where the current identity is a member
#[arg(long, default_value_t = false)]
member: bool,
/// Only show channels with this label (repeatable; a channel with
/// any of the labels matches)
#[arg(long, value_name = "LABEL")]
label: Vec<String>,
/// Only show channels created by this key (npub or hex). The relay
/// writes the creator; clients cannot set it.
#[arg(long, value_name = "NPUB_OR_HEX")]
creator: Option<String>,
/// Maximum number of channels to return [default: 500]
#[arg(long)]
limit: Option<u32>,
@@ -632,6 +640,11 @@ pub enum ChannelsCmd {
/// many seconds pass without a new message.
#[arg(long, value_name = "SECONDS")]
ttl: Option<i64>,
/// Label the channel (repeatable, up to 8). A label uses 1-64
/// characters from a-z, 0-9, '.', ':' and '-', and cannot be a
/// channel type name.
#[arg(long, value_name = "LABEL")]
label: Vec<String>,
/// Apply a desktop-local channel template by name (case-insensitive):
/// supplies default type/visibility/description/canvas, and resolves
/// its agent roster against the relay to add as members.
@@ -642,7 +655,7 @@ pub enum ChannelsCmd {
#[arg(long, value_name = "PATH")]
templates_file: Option<String>,
},
/// Update channel name, description, visibility, or ephemeral TTL
/// Update channel name, description, visibility, ephemeral TTL, or labels
#[command(
after_help = "Examples:\n buzz channels update --channel <uuid> --name general\n buzz channels update --channel <uuid> --visibility open\n buzz channels update --channel <uuid> --visibility private"
)]
@@ -666,6 +679,13 @@ pub enum ChannelsCmd {
/// Clear an existing TTL, making the channel permanent.
#[arg(long)]
no_ttl: bool,
/// Replace the channel's labels (repeatable, up to 8). Conflicts
/// with --no-labels.
#[arg(long, value_name = "LABEL", conflicts_with = "no_labels")]
label: Vec<String>,
/// Remove all labels from the channel.
#[arg(long)]
no_labels: bool,
},
/// Set the channel topic
Topic {