mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(cli): add channels search for human-readable channel lookup (#712)
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -111,6 +111,124 @@ pub async fn cmd_list_channels(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Search channels by human-readable name (kind:39000 group metadata).
|
||||
///
|
||||
/// The relay's access control already filters out channels the caller can't see
|
||||
/// (private channels they're not a member of), so we just post-filter the
|
||||
/// returned events by name and project them into a stable JSON shape.
|
||||
pub async fn cmd_search_channels(
|
||||
client: &SproutClient,
|
||||
query: &str,
|
||||
exact: bool,
|
||||
include_archived: bool,
|
||||
limit: u32,
|
||||
) -> Result<(), CliError> {
|
||||
if query.trim().is_empty() {
|
||||
return Err(CliError::Usage("--query cannot be empty".into()));
|
||||
}
|
||||
|
||||
let filter = serde_json::json!({
|
||||
"kinds": [39000],
|
||||
"limit": limit,
|
||||
});
|
||||
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 response: {e}")))?;
|
||||
let Some(arr) = events.as_array() else {
|
||||
println!("[]");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let needle = query.to_ascii_lowercase();
|
||||
let mut matches: Vec<ChannelSummary> = arr
|
||||
.iter()
|
||||
.filter_map(ChannelSummary::from_event)
|
||||
.filter(|c| if include_archived { true } else { !c.archived })
|
||||
.filter(|c| name_matches(&c.name, &needle, exact))
|
||||
.collect();
|
||||
matches.sort_by(|a, b| {
|
||||
a.name
|
||||
.cmp(&b.name)
|
||||
.then_with(|| a.channel_id.cmp(&b.channel_id))
|
||||
});
|
||||
|
||||
let output = serde_json::to_string(&matches).expect("serializing ChannelSummary");
|
||||
println!("{output}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stable, scriptable projection of a kind:39000 channel-metadata event.
|
||||
#[derive(serde::Serialize)]
|
||||
struct ChannelSummary {
|
||||
channel_id: String,
|
||||
name: String,
|
||||
channel_type: Option<String>,
|
||||
visibility: Option<String>,
|
||||
archived: bool,
|
||||
about: Option<String>,
|
||||
topic: Option<String>,
|
||||
purpose: Option<String>,
|
||||
}
|
||||
|
||||
impl ChannelSummary {
|
||||
/// Parse a kind:39000 event JSON value into a summary. Returns `None` if the
|
||||
/// event lacks the required `d` (channel UUID) or `name` tags.
|
||||
fn from_event(event: &serde_json::Value) -> Option<Self> {
|
||||
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 mut visibility: Option<String> = None;
|
||||
let mut archived = false;
|
||||
let mut about: Option<String> = None;
|
||||
let mut topic: Option<String> = None;
|
||||
let mut purpose: Option<String> = None;
|
||||
|
||||
for tag in tags {
|
||||
let Some(tag_arr) = tag.as_array() else {
|
||||
continue;
|
||||
};
|
||||
let key = tag_arr.first().and_then(|v| v.as_str()).unwrap_or("");
|
||||
let val = tag_arr.get(1).and_then(|v| v.as_str());
|
||||
match key {
|
||||
"d" => channel_id = val.map(str::to_string),
|
||||
"name" => name = val.map(str::to_string),
|
||||
"t" => channel_type = val.map(str::to_string),
|
||||
// NIP-29 emits both `private` and `public` (Sprout adds the latter).
|
||||
// The presence of either tag is the source of truth; tag value is unused.
|
||||
"private" => visibility = Some("private".to_string()),
|
||||
"public" => visibility = Some("public".to_string()),
|
||||
"about" => about = val.map(str::to_string),
|
||||
"topic" => topic = val.map(str::to_string),
|
||||
"purpose" => purpose = val.map(str::to_string),
|
||||
"archived" => archived = val == Some("true"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Some(ChannelSummary {
|
||||
channel_id: channel_id?,
|
||||
name: name?,
|
||||
channel_type,
|
||||
visibility,
|
||||
archived,
|
||||
about,
|
||||
topic,
|
||||
purpose,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn name_matches(name: &str, needle_lower: &str, exact: bool) -> bool {
|
||||
let hay = name.to_ascii_lowercase();
|
||||
if exact {
|
||||
hay == needle_lower
|
||||
} else {
|
||||
hay.contains(needle_lower)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cmd_get_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
|
||||
validate_uuid(channel_id)?;
|
||||
let filter = serde_json::json!({
|
||||
@@ -421,6 +539,12 @@ pub async fn dispatch(
|
||||
cmd_list_channels(client, vis_str.as_deref(), Some(member), limit, format).await
|
||||
}
|
||||
ChannelsCmd::Get { channel } => cmd_get_channel(client, &channel).await,
|
||||
ChannelsCmd::Search {
|
||||
query,
|
||||
exact,
|
||||
include_archived,
|
||||
limit,
|
||||
} => cmd_search_channels(client, &query, exact, include_archived, limit).await,
|
||||
ChannelsCmd::Create {
|
||||
name,
|
||||
channel_type,
|
||||
@@ -471,3 +595,97 @@ pub async fn dispatch_canvas(cmd: crate::CanvasCmd, client: &SproutClient) -> Re
|
||||
CanvasCmd::Set { channel, content } => cmd_set_canvas(client, &channel, &content).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{name_matches, ChannelSummary};
|
||||
use serde_json::json;
|
||||
|
||||
fn event(tags: serde_json::Value) -> serde_json::Value {
|
||||
json!({ "tags": tags })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_event_extracts_known_tags() {
|
||||
let ev = event(json!([
|
||||
["d", "11111111-1111-1111-1111-111111111111"],
|
||||
["name", "sprout-chat-composer"],
|
||||
["t", "stream"],
|
||||
["public"],
|
||||
["about", "About text"],
|
||||
["topic", "Composer work"],
|
||||
["purpose", "Track UI for the composer"],
|
||||
]));
|
||||
let s = ChannelSummary::from_event(&ev).expect("parse");
|
||||
assert_eq!(s.channel_id, "11111111-1111-1111-1111-111111111111");
|
||||
assert_eq!(s.name, "sprout-chat-composer");
|
||||
assert_eq!(s.channel_type.as_deref(), Some("stream"));
|
||||
assert_eq!(s.visibility.as_deref(), Some("public"));
|
||||
assert!(!s.archived);
|
||||
assert_eq!(s.about.as_deref(), Some("About text"));
|
||||
assert_eq!(s.topic.as_deref(), Some("Composer work"));
|
||||
assert_eq!(s.purpose.as_deref(), Some("Track UI for the composer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_event_marks_archived() {
|
||||
let ev = event(json!([
|
||||
["d", "11111111-1111-1111-1111-111111111111"],
|
||||
["name", "old-channel"],
|
||||
["archived", "true"],
|
||||
]));
|
||||
let s = ChannelSummary::from_event(&ev).expect("parse");
|
||||
assert!(s.archived);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_event_marks_private() {
|
||||
let ev = event(json!([
|
||||
["d", "11111111-1111-1111-1111-111111111111"],
|
||||
["name", "secret"],
|
||||
["private"],
|
||||
]));
|
||||
let s = ChannelSummary::from_event(&ev).expect("parse");
|
||||
assert_eq!(s.visibility.as_deref(), Some("private"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_event_returns_none_without_required_tags() {
|
||||
// missing `name`
|
||||
let ev = event(json!([["d", "11111111-1111-1111-1111-111111111111"]]));
|
||||
assert!(ChannelSummary::from_event(&ev).is_none());
|
||||
// missing `d`
|
||||
let ev = event(json!([["name", "no-id"]]));
|
||||
assert!(ChannelSummary::from_event(&ev).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_event_tolerates_malformed_tags() {
|
||||
// Non-array tag entry, empty tag, single-element tag — all must be skipped, not panic.
|
||||
let ev = event(json!([
|
||||
"not-an-array",
|
||||
[],
|
||||
["name"],
|
||||
["d", "11111111-1111-1111-1111-111111111111"],
|
||||
["name", "fine"],
|
||||
]));
|
||||
let s = ChannelSummary::from_event(&ev).expect("parse");
|
||||
assert_eq!(s.name, "fine");
|
||||
}
|
||||
|
||||
// `name_matches` takes a pre-lowercased needle (caller responsibility, set in
|
||||
// cmd_search_channels). Tests follow the same contract.
|
||||
|
||||
#[test]
|
||||
fn name_matches_substring_case_insensitive() {
|
||||
assert!(name_matches("Sprout-Chat-Composer", "composer", false));
|
||||
assert!(name_matches("Sprout-Chat-Composer", "sprout", false));
|
||||
assert!(!name_matches("design", "composer", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_matches_exact_case_insensitive() {
|
||||
assert!(name_matches("Sprout", "sprout", true));
|
||||
assert!(!name_matches("Sprout-Chat", "sprout", true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,6 +378,24 @@ pub enum ChannelsCmd {
|
||||
#[arg(long)]
|
||||
channel: String,
|
||||
},
|
||||
/// Search channels by human-readable name
|
||||
#[command(
|
||||
after_help = "Examples:\n sprout channels search --query composer\n sprout channels search --query sprout-chat-composer --exact\n sprout channels search --query design --include-archived"
|
||||
)]
|
||||
Search {
|
||||
/// Search query (case-insensitive substring of channel name)
|
||||
#[arg(long)]
|
||||
query: String,
|
||||
/// Require an exact case-insensitive match instead of substring
|
||||
#[arg(long, default_value_t = false)]
|
||||
exact: bool,
|
||||
/// Include archived channels in results
|
||||
#[arg(long, default_value_t = false)]
|
||||
include_archived: bool,
|
||||
/// Maximum number of channel-metadata events to fetch from the relay
|
||||
#[arg(long, default_value_t = 1000)]
|
||||
limit: u32,
|
||||
},
|
||||
/// Create a new channel
|
||||
#[command(
|
||||
after_help = "Examples:\n sprout channels create --name general --type stream --visibility open\n sprout channels create --name design --type forum --visibility open --description \"Design discussions\""
|
||||
|
||||
Reference in New Issue
Block a user