From 9b633b408e245ff63fe5aa65a4eec3c941cd63ed Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 8 Oct 2026 12:50:54 -0400 Subject: [PATCH] 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 , sent to the relay as #t and #P. List, get and search output now include labels and created_by. Signed-off-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- crates/buzz-cli/README.md | 3 + crates/buzz-cli/src/commands/channels.rs | 207 ++++++++++++++++++++--- crates/buzz-cli/src/lib.rs | 24 ++- crates/buzz-core/src/channel.rs | 65 ++++++- crates/buzz-sdk/src/builders.rs | 141 +++++++++++++-- crates/buzz-sdk/src/lib.rs | 5 + 6 files changed, 408 insertions(+), 37 deletions(-) diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index 29ec19a04c..99768bfc37 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -46,6 +46,9 @@ buzz messages send-diff --channel --diff - --repo https://github.com/org/ # Channels buzz channels list buzz channels create --name "my-channel" --type stream --visibility open +buzz channels create --name "notes" --type stream --visibility private --label agent-attention +buzz channels list --label agent-attention --creator +buzz channels update --channel --label workspace # replace labels; --no-labels clears them buzz channels join --channel buzz channels topic --channel --topic "New topic" diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 31de6035e2..fe2aa96f76 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -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 { + 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, + labels: &[String], + creator: Option<&str>, limit: Option, 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, + labels: Vec, + created_by: Option, visibility: Option, archived: bool, about: Option, @@ -175,7 +231,7 @@ impl ChannelSummary { let tags = event.get("tags")?.as_array()?; let mut channel_id: Option = None; let mut name: Option = None; - let mut channel_type: Option = None; + let identity = channel_identity(event); let mut visibility: Option = None; let mut archived = false; let mut about: Option = 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, + 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, + 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::>(), ) .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>, + 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, 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> = + 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"); } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index c2f6b7e089..b9c797a961 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -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 " )] 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, + /// 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, /// Maximum number of channels to return [default: 500] #[arg(long)] limit: Option, @@ -632,6 +640,11 @@ pub enum ChannelsCmd { /// many seconds pass without a new message. #[arg(long, value_name = "SECONDS")] ttl: Option, + /// 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, /// 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, }, - /// 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 --name general\n buzz channels update --channel --visibility open\n buzz channels update --channel --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, + /// Remove all labels from the channel. + #[arg(long)] + no_labels: bool, }, /// Set the channel topic Topic { diff --git a/crates/buzz-core/src/channel.rs b/crates/buzz-core/src/channel.rs index 1a3f4e60aa..68b3a4408c 100644 --- a/crates/buzz-core/src/channel.rs +++ b/crates/buzz-core/src/channel.rs @@ -132,6 +132,48 @@ pub fn channel_created_at_tag(unix_seconds: i64) -> Result, + /// Every later `t` value, in tag order: the channel labels. + pub labels: Vec, + /// The first `P` value: the hex key that created the channel. Trust it + /// only when the relay key signed the event. + pub created_by: Option, +} + +impl GroupStateIdentity { + /// Read the channel type, labels and creator from `(name, value)` tag + /// pairs. Tags without a value are skipped. + pub fn from_tag_pairs<'a>(pairs: impl IntoIterator) -> Self { + let mut identity = Self::default(); + for (name, value) in pairs { + match name { + "t" if identity.channel_type.is_none() => { + identity.channel_type = Some(value.to_owned()); + } + "t" => identity.labels.push(value.to_owned()), + "P" if identity.created_by.is_none() => { + identity.created_by = Some(value.to_owned()); + } + _ => {} + } + } + identity + } + + /// Read the channel type, labels and creator from Nostr tags. + pub fn from_tags<'a>(tags: impl IntoIterator) -> Self { + Self::from_tag_pairs(tags.into_iter().filter_map(|tag| { + let slice = tag.as_slice(); + Some((slice.first()?.as_str(), slice.get(1)?.as_str())) + })) + } +} + /// Whether a channel is publicly visible or invite-only. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ChannelVisibility { @@ -297,7 +339,7 @@ impl FromStr for MemberRole { mod tests { use super::{ canonical_channel_name, channel_type_from_group_state_tags, group_state_identity_tags, - parse_channel_labels, ChannelLabelError, MAX_CHANNEL_LABELS, + parse_channel_labels, ChannelLabelError, GroupStateIdentity, MAX_CHANNEL_LABELS, }; #[test] @@ -318,6 +360,27 @@ mod tests { ); } + #[test] + fn group_state_identity_reads_back_what_the_relay_writes() { + let creator = [0xab; 32]; + let labels = vec!["workspace".to_string(), "team:core".to_string()]; + let mut tags = vec![nostr::Tag::parse(["d", "channel"]).unwrap()]; + tags.extend(group_state_identity_tags("forum", &labels, &creator).unwrap()); + tags.push(nostr::Tag::parse(["p", &"cd".repeat(32), "", "member"]).unwrap()); + assert_eq!( + GroupStateIdentity::from_tags(&tags), + GroupStateIdentity { + channel_type: Some("forum".to_string()), + labels, + created_by: Some("ab".repeat(32)), + } + ); + assert_eq!( + GroupStateIdentity::from_tag_pairs([("name", "x")]), + GroupStateIdentity::default() + ); + } + #[test] fn channel_labels_accept_the_allowed_alphabet_and_drop_duplicates() { assert_eq!( diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index f698598b93..301588524d 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -803,22 +803,33 @@ pub fn build_leave(channel_id: Uuid) -> Result { Ok(EventBuilder::new(Kind::Custom(9022), "").tags(tags)) } -/// Build a NIP-29 edit-metadata event for name/about/visibility/ttl (kind 9002). +/// Build a NIP-29 edit-metadata event for name/about/visibility/ttl/labels +/// (kind 9002). /// /// `ttl`: outer `None` leaves it unchanged; `Some(Some(secs))` sets the /// ephemeral timeout; `Some(None)` clears it (emits `["ttl", ""]`). +/// +/// `labels`: `None` leaves them unchanged; `Some(labels)` replaces the +/// channel's label set; `Some(&[])` clears it (emits `["t", ""]`). pub fn build_update_channel( channel_id: Uuid, name: Option<&str>, about: Option<&str>, visibility: Option<&str>, ttl: Option>, + labels: Option<&[&str]>, ) -> Result { - if name.is_none() && about.is_none() && visibility.is_none() && ttl.is_none() { + if name.is_none() + && about.is_none() + && visibility.is_none() + && ttl.is_none() + && labels.is_none() + { return Err(SdkError::InvalidTag( - "at least one of name, about, visibility, or ttl must be provided".into(), + "at least one of name, about, visibility, ttl, or labels must be provided".into(), )); } + let labels = labels.map(validated_channel_labels).transpose()?; if let Some(v) = visibility { if v != "open" && v != "private" { return Err(SdkError::InvalidTag( @@ -851,9 +862,34 @@ pub fn build_update_channel( None => tags.push(tag(&["ttl", ""])?), } } + match labels.as_deref() { + None => {} + Some([]) => tags.push(tag(&["t", ""])?), + Some(labels) => push_label_tags(&mut tags, labels)?, + } Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags)) } +/// Validate channel labels with the relay's rules (see +/// [`buzz_core::channel::parse_channel_labels`]). An empty string is +/// refused here: callers clear labels by passing an empty slice. +fn validated_channel_labels(labels: &[&str]) -> Result, SdkError> { + if labels.contains(&"") { + return Err(SdkError::InvalidTag( + "channel label cannot be empty; pass no labels to clear them".into(), + )); + } + buzz_core::channel::parse_channel_labels(labels.iter().copied()) + .map_err(|e| SdkError::InvalidTag(e.to_string())) +} + +fn push_label_tags(tags: &mut Vec, labels: &[String]) -> Result<(), SdkError> { + for label in labels { + tags.push(tag(&["t", label])?); + } + Ok(()) +} + /// Build a NIP-29 edit-metadata event for topic (kind 9002). pub fn build_set_topic(channel_id: Uuid, topic: &str) -> Result { let tags = vec![ @@ -877,6 +913,10 @@ pub fn build_set_purpose(channel_id: Uuid, purpose: &str) -> Result]`. The relay +/// publishes them on the channel's group-state events, so clients can find +/// the channel with a `#t` filter. pub fn build_create_channel( channel_id: Uuid, name: &str, @@ -884,11 +924,13 @@ pub fn build_create_channel( channel_type: Option, about: Option<&str>, ttl: Option, + labels: &[&str], ) -> Result { let name = buzz_core::channel::canonical_channel_name(name); if name.trim().is_empty() { return Err(SdkError::InvalidTag("channel name is required".into())); } + let labels = validated_channel_labels(labels)?; let mut tags = vec![tag(&["h", &channel_id.to_string()])?, tag(&["name", name])?]; if let Some(v) = visibility { tags.push(tag(&["visibility", v.as_str()])?); @@ -902,6 +944,7 @@ pub fn build_create_channel( if let Some(secs) = ttl { tags.push(tag(&["ttl", &secs.to_string()])?); } + push_label_tags(&mut tags, &labels)?; Ok(EventBuilder::new(Kind::Custom(9007), "").tags(tags)) } @@ -3480,7 +3523,8 @@ mod tests { fn update_channel_name_and_about() { let cid = uuid(); let ev = sign( - build_update_channel(cid, Some("new-name"), Some("new about"), None, None).unwrap(), + build_update_channel(cid, Some("new-name"), Some("new about"), None, None, None) + .unwrap(), ); assert_eq!(ev.kind.as_u16(), 9002); assert!(has_tag(&ev, "name", "new-name")); @@ -3489,15 +3533,16 @@ mod tests { #[test] fn update_channel_strips_all_leading_hashes_from_name() { - let ev = - sign(build_update_channel(uuid(), Some(" ###new-name "), None, None, None).unwrap()); + let ev = sign( + build_update_channel(uuid(), Some(" ###new-name "), None, None, None, None).unwrap(), + ); assert!(has_tag(&ev, "name", "new-name")); } #[test] fn update_channel_rejects_hash_only_name() { assert!(matches!( - build_update_channel(uuid(), Some(" ### "), None, None, None), + build_update_channel(uuid(), Some(" ### "), None, None, None, None), Err(SdkError::InvalidTag(_)) )); } @@ -3505,8 +3550,9 @@ mod tests { #[test] fn update_channel_visibility_and_ttl() { let cid = uuid(); - let ev = - sign(build_update_channel(cid, None, None, Some("private"), Some(Some(3600))).unwrap()); + let ev = sign( + build_update_channel(cid, None, None, Some("private"), Some(Some(3600)), None).unwrap(), + ); assert_eq!(ev.kind.as_u16(), 9002); assert!(has_tag(&ev, "visibility", "private")); assert!(has_tag(&ev, "ttl", "3600")); @@ -3515,7 +3561,7 @@ mod tests { #[test] fn update_channel_clears_ttl() { let cid = uuid(); - let ev = sign(build_update_channel(cid, None, None, None, Some(None)).unwrap()); + let ev = sign(build_update_channel(cid, None, None, None, Some(None), None).unwrap()); assert!(has_tag(&ev, "ttl", "")); } @@ -3523,7 +3569,7 @@ mod tests { fn update_channel_invalid_visibility_rejected() { let cid = uuid(); assert!(matches!( - build_update_channel(cid, None, None, Some("secret"), None), + build_update_channel(cid, None, None, Some("secret"), None, None), Err(SdkError::InvalidTag(_)) )); } @@ -3532,11 +3578,77 @@ mod tests { fn update_channel_no_fields_rejected() { let cid = uuid(); assert!(matches!( - build_update_channel(cid, None, None, None, None), + build_update_channel(cid, None, None, None, None, None), Err(SdkError::InvalidTag(_)) )); } + #[test] + fn update_channel_replaces_and_clears_labels() { + let cid = uuid(); + let ev = sign( + build_update_channel( + cid, + None, + None, + None, + None, + Some(&["workspace", "team:core"]), + ) + .unwrap(), + ); + assert_eq!(tag_values(&ev, "t"), vec!["workspace", "team:core"]); + + let ev = sign(build_update_channel(cid, None, None, None, None, Some(&[])).unwrap()); + assert_eq!(tag_values(&ev, "t"), vec![""]); + + let ev = sign(build_update_channel(cid, Some("x"), None, None, None, None).unwrap()); + assert!(tag_values(&ev, "t").is_empty()); + } + + #[test] + fn channel_labels_use_the_relay_rules() { + for bad in [ + &["Upper"][..], + &["stream"], + &[""], + &["a", "b", "c", "d", "e", "f", "g", "h", "i"], + ] { + assert!( + matches!( + build_create_channel(uuid(), "dev", None, None, None, None, bad), + Err(SdkError::InvalidTag(_)) + ), + "create must refuse {bad:?}" + ); + assert!( + matches!( + build_update_channel(uuid(), None, None, None, None, Some(bad)), + Err(SdkError::InvalidTag(_)) + ), + "update must refuse {bad:?}" + ); + } + } + + #[test] + fn create_channel_carries_labels_as_t_tags() { + let ev = sign( + build_create_channel( + uuid(), + "dev", + None, + Some(ChannelKind::Stream), + None, + None, + &["agent-attention", "agent-attention", "v1"], + ) + .unwrap(), + ); + assert_eq!(tag_values(&ev, "t"), vec!["agent-attention", "v1"]); + assert!(has_tag(&ev, "channel_type", "stream")); + } + #[test] fn set_topic_happy_path() { let cid = uuid(); @@ -3564,6 +3676,7 @@ mod tests { Some(ChannelKind::Stream), Some("General chat"), None, + &[], ) .unwrap(), ); @@ -3585,6 +3698,7 @@ mod tests { None::, None, None, + &[], ) .unwrap(), ); @@ -3602,6 +3716,7 @@ mod tests { None::, None, None, + &[], ) .unwrap(), ); @@ -3618,6 +3733,7 @@ mod tests { None::, None, None, + &[], ), Err(SdkError::InvalidTag(_)) )); @@ -3634,6 +3750,7 @@ mod tests { Some(ChannelKind::Stream), None, Some(3600), + &[], ) .unwrap(), ); diff --git a/crates/buzz-sdk/src/lib.rs b/crates/buzz-sdk/src/lib.rs index 845505c56d..7a61d01e22 100644 --- a/crates/buzz-sdk/src/lib.rs +++ b/crates/buzz-sdk/src/lib.rs @@ -81,8 +81,13 @@ pub use buzz_core::channel::canonical_channel_name; pub use buzz_core::channel::ChannelType as ChannelKind; /// Channel visibility. pub use buzz_core::channel::ChannelVisibility as Visibility; +/// Channel type, labels and creator read from a relay-signed group-state +/// event (kinds 39000–39003). See [`ChannelIdentity::from_tags`]. +pub use buzz_core::channel::GroupStateIdentity as ChannelIdentity; /// Member role. pub use buzz_core::channel::MemberRole; +/// Validate channel labels with the relay's rules. +pub use buzz_core::channel::{parse_channel_labels, ChannelLabelError}; /// Errors returned by SDK builder functions. #[derive(Debug, thiserror::Error)]