mirror of
https://github.com/block/buzz.git
synced 2026-11-09 10:51:42 +00:00
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
871 lines
30 KiB
Rust
871 lines
30 KiB
Rust
use uuid::Uuid;
|
|
|
|
use crate::client::{
|
|
extract_d_tag, extract_p_tags, extract_tag_value, normalize_write_response,
|
|
print_create_response, BuzzClient,
|
|
};
|
|
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 {
|
|
serde_json::json!({
|
|
"channel_id": extract_d_tag(e),
|
|
"name": extract_tag_value(e, "name"),
|
|
"description": extract_tag_value(e, "about"),
|
|
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
|
|
})
|
|
}
|
|
|
|
pub async fn cmd_list_channels(
|
|
client: &BuzzClient,
|
|
visibility: Option<&str>,
|
|
member: Option<bool>,
|
|
limit: Option<u32>,
|
|
format: &crate::OutputFormat,
|
|
) -> Result<(), CliError> {
|
|
let effective_limit = limit.unwrap_or(500);
|
|
let raw = if member == Some(true) {
|
|
// Step 1: find channel IDs where we're a member (kind:39002)
|
|
let my_pk = client.keys().public_key().to_hex();
|
|
let member_filter = serde_json::json!({
|
|
"kinds": [39002],
|
|
"#p": [my_pk],
|
|
"limit": effective_limit
|
|
});
|
|
let member_resp = client.query(&member_filter).await?;
|
|
let member_events: Vec<serde_json::Value> =
|
|
serde_json::from_str(&member_resp).unwrap_or_default();
|
|
let channel_ids: Vec<String> = member_events
|
|
.iter()
|
|
.map(extract_d_tag)
|
|
.filter(|id| !id.is_empty())
|
|
.collect();
|
|
if channel_ids.is_empty() {
|
|
println!("[]");
|
|
return Ok(());
|
|
}
|
|
// Step 2: fetch kind:39000 metadata for those channels
|
|
let metadata_filter = serde_json::json!({
|
|
"kinds": [39000],
|
|
"#d": channel_ids,
|
|
"limit": effective_limit
|
|
});
|
|
client.query(&metadata_filter).await?
|
|
} else {
|
|
let filter = serde_json::json!({
|
|
"kinds": [39000],
|
|
"limit": effective_limit
|
|
});
|
|
client.query(&filter).await?
|
|
};
|
|
|
|
let events: Vec<serde_json::Value> = serde_json::from_str(&raw).unwrap_or_default();
|
|
let channels: Vec<serde_json::Value> = events
|
|
.iter()
|
|
.filter(|e| {
|
|
if let Some(vis) = visibility {
|
|
// NIP-29: relay emits ["public"] or ["private"] single-element tags
|
|
let nip29_tag = match vis {
|
|
"open" => "public",
|
|
_ => vis,
|
|
};
|
|
e.get("tags")
|
|
.and_then(|t| t.as_array())
|
|
.map(|tags| {
|
|
tags.iter().any(|tag| {
|
|
tag.as_array()
|
|
.map(|a| {
|
|
a.len() == 1
|
|
&& a.first().and_then(|v| v.as_str()) == Some(nip29_tag)
|
|
})
|
|
.unwrap_or(false)
|
|
})
|
|
})
|
|
.unwrap_or(false)
|
|
} else {
|
|
true
|
|
}
|
|
})
|
|
.map(extract_channel_metadata)
|
|
.collect();
|
|
let output = match format {
|
|
crate::OutputFormat::Compact => {
|
|
let compact: Vec<serde_json::Value> = channels
|
|
.iter()
|
|
.map(|c| {
|
|
serde_json::json!({
|
|
"channel_id": c.get("channel_id").cloned().unwrap_or_default(),
|
|
"name": c.get("name").cloned().unwrap_or_default(),
|
|
})
|
|
})
|
|
.collect();
|
|
serde_json::to_string(&compact).unwrap_or_default()
|
|
}
|
|
crate::OutputFormat::Json => serde_json::to_string(&channels).unwrap_or_default(),
|
|
};
|
|
println!("{output}");
|
|
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: &BuzzClient,
|
|
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` (Buzz 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: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
|
|
validate_uuid(channel_id)?;
|
|
let filter = serde_json::json!({
|
|
"kinds": [39000],
|
|
"#d": [channel_id],
|
|
"limit": 1
|
|
});
|
|
let resp = client.query(&filter).await?;
|
|
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
|
|
if let Some(e) = events.first() {
|
|
let mut normalized = extract_channel_metadata(e);
|
|
normalized["pubkey"] =
|
|
serde_json::json!(e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""));
|
|
println!("{normalized}");
|
|
} else {
|
|
println!("null");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn cmd_list_channel_members(
|
|
client: &BuzzClient,
|
|
channel_id: &str,
|
|
) -> Result<(), CliError> {
|
|
validate_uuid(channel_id)?;
|
|
let filter = serde_json::json!({
|
|
"kinds": [39002],
|
|
"#d": [channel_id],
|
|
"limit": 1
|
|
});
|
|
let resp = client.query(&filter).await?;
|
|
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
|
|
let members = events.first().map(extract_p_tags).unwrap_or_default();
|
|
let output = serde_json::to_string(&members).unwrap_or_default();
|
|
println!("{output}");
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn cmd_get_canvas(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
|
|
validate_uuid(channel_id)?;
|
|
let filter = serde_json::json!({
|
|
"kinds": [40100],
|
|
"#h": [channel_id]
|
|
});
|
|
let resp = client.query(&filter).await?;
|
|
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
|
|
if let Some(content) = events
|
|
.first()
|
|
.and_then(|e| e.get("content"))
|
|
.and_then(|c| c.as_str())
|
|
{
|
|
println!("{content}");
|
|
} else {
|
|
println!("null");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn cmd_create_channel(
|
|
client: &BuzzClient,
|
|
name: &str,
|
|
channel_type: &str,
|
|
visibility: &str,
|
|
description: Option<&str>,
|
|
ttl: Option<i64>,
|
|
) -> Result<(), CliError> {
|
|
match channel_type {
|
|
"stream" | "forum" => {}
|
|
_ => {
|
|
return Err(CliError::Usage(format!(
|
|
"--type must be 'stream' or 'forum' (got: {channel_type})"
|
|
)))
|
|
}
|
|
}
|
|
match visibility {
|
|
"open" | "private" => {}
|
|
_ => {
|
|
return Err(CliError::Usage(format!(
|
|
"--visibility must be 'open' or 'private' (got: {visibility})"
|
|
)))
|
|
}
|
|
}
|
|
|
|
let ttl = ttl.map(validate_ttl_seconds).transpose()?;
|
|
|
|
let channel_uuid = Uuid::new_v4();
|
|
|
|
let vis = match visibility {
|
|
"open" => buzz_sdk::Visibility::Open,
|
|
"private" => buzz_sdk::Visibility::Private,
|
|
_ => unreachable!(),
|
|
};
|
|
let ct = match channel_type {
|
|
"stream" => buzz_sdk::ChannelKind::Stream,
|
|
"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 event = client.sign_event(builder)?;
|
|
let resp = client.submit_event(event).await?;
|
|
print_create_response(&resp, "channel_id", &channel_uuid.to_string());
|
|
Ok(())
|
|
}
|
|
|
|
/// Validate a user-supplied TTL (in seconds): must be a positive value that
|
|
/// fits in the relay's `i32` column.
|
|
fn validate_ttl_seconds(secs: i64) -> Result<i32, CliError> {
|
|
if secs <= 0 {
|
|
return Err(CliError::Usage(format!(
|
|
"--ttl must be a positive number of seconds (got: {secs})"
|
|
)));
|
|
}
|
|
i32::try_from(secs)
|
|
.map_err(|_| CliError::Usage(format!("--ttl is too large (max {} seconds)", i32::MAX)))
|
|
}
|
|
|
|
pub async fn cmd_update_channel(
|
|
client: &BuzzClient,
|
|
channel_id: &str,
|
|
name: Option<&str>,
|
|
description: Option<&str>,
|
|
ttl: Option<i64>,
|
|
no_ttl: bool,
|
|
) -> Result<(), CliError> {
|
|
// Outer Option: None leaves TTL unchanged. Inner: Some(secs) sets it,
|
|
// None (from --no-ttl) clears it, making the channel permanent.
|
|
let ttl_change: Option<Option<i32>> = match (ttl, no_ttl) {
|
|
(Some(secs), _) => Some(Some(validate_ttl_seconds(secs)?)),
|
|
(None, true) => Some(None),
|
|
(None, false) => None,
|
|
};
|
|
|
|
if name.is_none() && description.is_none() && ttl_change.is_none() {
|
|
return Err(CliError::Usage(
|
|
"at least one field required (--name, --description, --ttl, --no-ttl)".into(),
|
|
));
|
|
}
|
|
let channel_uuid = parse_uuid(channel_id)?;
|
|
|
|
let builder = buzz_sdk::build_update_channel(channel_uuid, name, description, None, ttl_change)
|
|
.map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?;
|
|
|
|
let event = client.sign_event(builder)?;
|
|
let resp = client.submit_event(event).await?;
|
|
println!("{}", normalize_write_response(&resp));
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn cmd_set_channel_topic(
|
|
client: &BuzzClient,
|
|
channel_id: &str,
|
|
topic: &str,
|
|
) -> Result<(), CliError> {
|
|
let channel_uuid = parse_uuid(channel_id)?;
|
|
|
|
let builder = buzz_sdk::build_set_topic(channel_uuid, topic)
|
|
.map_err(|e| CliError::Other(format!("build_set_topic failed: {e}")))?;
|
|
|
|
let event = client.sign_event(builder)?;
|
|
let resp = client.submit_event(event).await?;
|
|
println!("{}", normalize_write_response(&resp));
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn cmd_set_channel_purpose(
|
|
client: &BuzzClient,
|
|
channel_id: &str,
|
|
purpose: &str,
|
|
) -> Result<(), CliError> {
|
|
let channel_uuid = parse_uuid(channel_id)?;
|
|
|
|
let builder = buzz_sdk::build_set_purpose(channel_uuid, purpose)
|
|
.map_err(|e| CliError::Other(format!("build_set_purpose failed: {e}")))?;
|
|
|
|
let event = client.sign_event(builder)?;
|
|
let resp = client.submit_event(event).await?;
|
|
println!("{}", normalize_write_response(&resp));
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn cmd_join_channel(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
|
|
let channel_uuid = parse_uuid(channel_id)?;
|
|
|
|
let builder = buzz_sdk::build_join(channel_uuid)
|
|
.map_err(|e| CliError::Other(format!("build_join failed: {e}")))?;
|
|
|
|
let event = client.sign_event(builder)?;
|
|
let resp = client.submit_event(event).await?;
|
|
println!("{}", normalize_write_response(&resp));
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn cmd_leave_channel(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
|
|
let channel_uuid = parse_uuid(channel_id)?;
|
|
|
|
let builder = buzz_sdk::build_leave(channel_uuid)
|
|
.map_err(|e| CliError::Other(format!("build_leave failed: {e}")))?;
|
|
|
|
let event = client.sign_event(builder)?;
|
|
let resp = client.submit_event(event).await?;
|
|
println!("{}", normalize_write_response(&resp));
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn cmd_archive_channel(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
|
|
let channel_uuid = parse_uuid(channel_id)?;
|
|
|
|
let builder = buzz_sdk::build_archive(channel_uuid)
|
|
.map_err(|e| CliError::Other(format!("build_archive failed: {e}")))?;
|
|
|
|
let event = client.sign_event(builder)?;
|
|
let resp = client.submit_event(event).await?;
|
|
println!("{}", normalize_write_response(&resp));
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn cmd_unarchive_channel(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
|
|
let channel_uuid = parse_uuid(channel_id)?;
|
|
|
|
let builder = buzz_sdk::build_unarchive(channel_uuid)
|
|
.map_err(|e| CliError::Other(format!("build_unarchive failed: {e}")))?;
|
|
|
|
let event = client.sign_event(builder)?;
|
|
let resp = client.submit_event(event).await?;
|
|
println!("{}", normalize_write_response(&resp));
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn cmd_delete_channel(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
|
|
let channel_uuid = parse_uuid(channel_id)?;
|
|
|
|
let builder = buzz_sdk::build_delete_channel(channel_uuid)
|
|
.map_err(|e| CliError::Other(format!("build_delete_channel failed: {e}")))?;
|
|
|
|
let event = client.sign_event(builder)?;
|
|
let resp = client.submit_event(event).await?;
|
|
println!("{}", normalize_write_response(&resp));
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn cmd_add_channel_member(
|
|
client: &BuzzClient,
|
|
channel_id: &str,
|
|
pubkey: &str,
|
|
role: Option<&str>,
|
|
) -> Result<(), CliError> {
|
|
validate_hex64(pubkey)?;
|
|
let channel_uuid = parse_uuid(channel_id)?;
|
|
|
|
let typed_role = match role {
|
|
None => None,
|
|
Some("owner") => Some(buzz_sdk::MemberRole::Owner),
|
|
Some("admin") => Some(buzz_sdk::MemberRole::Admin),
|
|
Some("member") => Some(buzz_sdk::MemberRole::Member),
|
|
Some("guest") => Some(buzz_sdk::MemberRole::Guest),
|
|
Some("bot") => Some(buzz_sdk::MemberRole::Bot),
|
|
Some(other) => {
|
|
return Err(CliError::Usage(format!(
|
|
"--role must be owner/admin/member/guest/bot (got: {other})"
|
|
)))
|
|
}
|
|
};
|
|
let builder = buzz_sdk::build_add_member(channel_uuid, pubkey, typed_role)
|
|
.map_err(|e| CliError::Other(format!("build_add_member failed: {e}")))?;
|
|
|
|
let event = client.sign_event(builder)?;
|
|
let resp = client.submit_event(event).await?;
|
|
println!("{}", normalize_write_response(&resp));
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn cmd_remove_channel_member(
|
|
client: &BuzzClient,
|
|
channel_id: &str,
|
|
pubkey: &str,
|
|
) -> Result<(), CliError> {
|
|
validate_hex64(pubkey)?;
|
|
let channel_uuid = parse_uuid(channel_id)?;
|
|
|
|
let builder = buzz_sdk::build_remove_member(channel_uuid, pubkey)
|
|
.map_err(|e| CliError::Other(format!("build_remove_member failed: {e}")))?;
|
|
|
|
let event = client.sign_event(builder)?;
|
|
let resp = client.submit_event(event).await?;
|
|
println!("{}", normalize_write_response(&resp));
|
|
Ok(())
|
|
}
|
|
|
|
/// Set the channel addition policy — sign and submit a kind:10100 (agent profile) event.
|
|
pub async fn cmd_set_add_policy(client: &BuzzClient, policy: &str) -> Result<(), CliError> {
|
|
match policy {
|
|
"anyone" | "owner_only" | "nobody" => {}
|
|
_ => {
|
|
return Err(CliError::Usage(format!(
|
|
"--policy must be 'anyone', 'owner_only', or 'nobody' (got: {policy})"
|
|
)))
|
|
}
|
|
}
|
|
|
|
// Check if this policy is allowed by the deployment.
|
|
// NOTE: This gate covers only the `buzz channels set-add-policy` CLI path.
|
|
// A client that submits a kind:10100 event directly to the relay bypasses
|
|
// this check. Full enforcement requires relay-side validation, which is
|
|
// intentionally out of scope for this change (see team decision: no
|
|
// relay-side enforcement of client behavior).
|
|
if let Ok(allowed_raw) = std::env::var("BUZZ_ACP_ALLOWED_CHANNEL_ADD_POLICIES") {
|
|
let allowed: Vec<&str> = allowed_raw
|
|
.split(',')
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty())
|
|
.collect();
|
|
if !allowed.is_empty() && !allowed.contains(&policy) {
|
|
return Err(CliError::Usage(format!(
|
|
"channel_add_policy '{policy}' is not permitted on this deployment \
|
|
(BUZZ_ACP_ALLOWED_CHANNEL_ADD_POLICIES={allowed_raw})"
|
|
)));
|
|
}
|
|
}
|
|
|
|
let content = serde_json::json!({ "channel_add_policy": policy }).to_string();
|
|
use nostr::{EventBuilder, Kind};
|
|
let builder = EventBuilder::new(
|
|
Kind::Custom(buzz_sdk::kind::KIND_AGENT_PROFILE as u16),
|
|
&content,
|
|
)
|
|
.tags([]);
|
|
let event = client.sign_event(builder)?;
|
|
|
|
let resp = client.submit_event(event).await?;
|
|
println!("{}", normalize_write_response(&resp));
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn cmd_set_canvas(
|
|
client: &BuzzClient,
|
|
channel_id: &str,
|
|
content: &str,
|
|
) -> Result<(), CliError> {
|
|
let content = read_or_stdin(content)?;
|
|
let channel_uuid = parse_uuid(channel_id)?;
|
|
|
|
let builder = buzz_sdk::build_set_canvas(channel_uuid, &content)
|
|
.map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?;
|
|
|
|
let event = client.sign_event(builder)?;
|
|
let resp = client.submit_event(event).await?;
|
|
println!("{}", normalize_write_response(&resp));
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn dispatch(
|
|
cmd: crate::ChannelsCmd,
|
|
client: &BuzzClient,
|
|
format: &crate::OutputFormat,
|
|
) -> Result<(), CliError> {
|
|
use crate::ChannelsCmd;
|
|
match cmd {
|
|
ChannelsCmd::List {
|
|
visibility,
|
|
member,
|
|
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
|
|
}
|
|
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,
|
|
visibility,
|
|
description,
|
|
ttl,
|
|
} => {
|
|
cmd_create_channel(
|
|
client,
|
|
&name,
|
|
&channel_type.to_string(),
|
|
&visibility.to_string(),
|
|
description.as_deref(),
|
|
ttl,
|
|
)
|
|
.await
|
|
}
|
|
ChannelsCmd::Update {
|
|
channel,
|
|
name,
|
|
description,
|
|
ttl,
|
|
no_ttl,
|
|
} => {
|
|
cmd_update_channel(
|
|
client,
|
|
&channel,
|
|
name.as_deref(),
|
|
description.as_deref(),
|
|
ttl,
|
|
no_ttl,
|
|
)
|
|
.await
|
|
}
|
|
ChannelsCmd::Topic { channel, topic } => {
|
|
cmd_set_channel_topic(client, &channel, &topic).await
|
|
}
|
|
ChannelsCmd::Purpose { channel, purpose } => {
|
|
cmd_set_channel_purpose(client, &channel, &purpose).await
|
|
}
|
|
ChannelsCmd::Join { channel } => cmd_join_channel(client, &channel).await,
|
|
ChannelsCmd::Leave { channel } => cmd_leave_channel(client, &channel).await,
|
|
ChannelsCmd::Archive { channel } => cmd_archive_channel(client, &channel).await,
|
|
ChannelsCmd::Unarchive { channel } => cmd_unarchive_channel(client, &channel).await,
|
|
ChannelsCmd::Delete { channel } => cmd_delete_channel(client, &channel).await,
|
|
ChannelsCmd::Members { channel } => cmd_list_channel_members(client, &channel).await,
|
|
ChannelsCmd::AddMember {
|
|
channel,
|
|
pubkey,
|
|
role,
|
|
} => cmd_add_channel_member(client, &channel, &pubkey, role.as_deref()).await,
|
|
ChannelsCmd::RemoveMember { channel, pubkey } => {
|
|
cmd_remove_channel_member(client, &channel, &pubkey).await
|
|
}
|
|
ChannelsCmd::SetAddPolicy { policy } => cmd_set_add_policy(client, &policy).await,
|
|
}
|
|
}
|
|
|
|
pub async fn dispatch_canvas(cmd: crate::CanvasCmd, client: &BuzzClient) -> Result<(), CliError> {
|
|
use crate::CanvasCmd;
|
|
match cmd {
|
|
CanvasCmd::Get { channel } => cmd_get_canvas(client, &channel).await,
|
|
CanvasCmd::Set { channel, content } => cmd_set_canvas(client, &channel, &content).await,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{cmd_set_add_policy, name_matches, validate_ttl_seconds, ChannelSummary};
|
|
use crate::client::BuzzClient;
|
|
use crate::CliError;
|
|
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", "buzz-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, "buzz-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("Buzz-Chat-Composer", "composer", false));
|
|
assert!(name_matches("Buzz-Chat-Composer", "buzz", false));
|
|
assert!(!name_matches("design", "composer", false));
|
|
}
|
|
|
|
#[test]
|
|
fn name_matches_exact_case_insensitive() {
|
|
assert!(name_matches("Buzz", "buzz", true));
|
|
assert!(!name_matches("Buzz-Chat", "buzz", true));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_ttl_accepts_positive() {
|
|
assert_eq!(validate_ttl_seconds(3600).unwrap(), 3600);
|
|
assert_eq!(validate_ttl_seconds(1).unwrap(), 1);
|
|
assert_eq!(validate_ttl_seconds(i32::MAX as i64).unwrap(), i32::MAX);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_ttl_rejects_zero_and_negative() {
|
|
assert!(validate_ttl_seconds(0).is_err());
|
|
assert!(validate_ttl_seconds(-1).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_ttl_rejects_overflow() {
|
|
assert!(validate_ttl_seconds(i32::MAX as i64 + 1).is_err());
|
|
}
|
|
|
|
// --- BUZZ_ACP_ALLOWED_CHANNEL_ADD_POLICIES gate ---
|
|
|
|
fn check_allowed_channel_add_policy(allowed_raw: &str, policy: &str) -> Result<(), CliError> {
|
|
let allowed: Vec<&str> = allowed_raw
|
|
.split(',')
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty())
|
|
.collect();
|
|
if !allowed.is_empty() && !allowed.contains(&policy) {
|
|
return Err(CliError::Usage(format!(
|
|
"channel_add_policy '{policy}' is not permitted on this deployment \
|
|
(BUZZ_ACP_ALLOWED_CHANNEL_ADD_POLICIES={allowed_raw})"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn set_add_policy_rejects_disallowed_policy() {
|
|
let result = check_allowed_channel_add_policy("owner_only,nobody", "anyone");
|
|
assert!(
|
|
result.is_err(),
|
|
"anyone should be rejected when not in allowed set"
|
|
);
|
|
let msg = result.unwrap_err().to_string();
|
|
assert!(
|
|
msg.contains("not permitted"),
|
|
"error should mention 'not permitted': {msg}"
|
|
);
|
|
assert!(
|
|
msg.contains("anyone"),
|
|
"error should name the disallowed policy: {msg}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn set_add_policy_accepts_allowed_policy() {
|
|
let result = check_allowed_channel_add_policy("owner_only,nobody", "owner_only");
|
|
assert!(result.is_ok(), "owner_only should be accepted: {result:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn set_add_policy_no_restriction_allows_all() {
|
|
// Empty allowed list means no restriction.
|
|
let result = check_allowed_channel_add_policy("", "anyone");
|
|
assert!(
|
|
result.is_ok(),
|
|
"empty allowed list should permit any policy: {result:?}"
|
|
);
|
|
}
|
|
|
|
// --- Integration test: full env-var → cmd_set_add_policy() path ---
|
|
//
|
|
// This test calls cmd_set_add_policy directly with the env var set. The function
|
|
// returns early with an error before any network call, so no relay is needed.
|
|
// If the BUZZ_ACP_ALLOWED_CHANNEL_ADD_POLICIES check were removed from cmd_set_add_policy,
|
|
// this test would fail (it would proceed to sign_event and return a different error).
|
|
|
|
fn make_test_client() -> BuzzClient {
|
|
// Scalar = 1 is the smallest valid secp256k1 private key.
|
|
let keys =
|
|
nostr::Keys::parse("0000000000000000000000000000000000000000000000000000000000000001")
|
|
.expect("valid test key");
|
|
BuzzClient::new("ws://localhost:3000".to_string(), keys, None, None)
|
|
.expect("client construction should not fail")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn set_add_policy_env_gate_rejects_disallowed_via_full_path() {
|
|
std::env::set_var("BUZZ_ACP_ALLOWED_CHANNEL_ADD_POLICIES", "owner_only,nobody");
|
|
let client = make_test_client();
|
|
let result = cmd_set_add_policy(&client, "anyone").await;
|
|
std::env::remove_var("BUZZ_ACP_ALLOWED_CHANNEL_ADD_POLICIES");
|
|
|
|
assert!(
|
|
result.is_err(),
|
|
"cmd_set_add_policy should reject 'anyone' when not in allowed set"
|
|
);
|
|
match result.unwrap_err() {
|
|
crate::CliError::Usage(msg) => {
|
|
assert!(
|
|
msg.contains("not permitted"),
|
|
"error should mention 'not permitted': {msg}"
|
|
);
|
|
}
|
|
other => panic!("expected CliError::Usage, got {other:?}"),
|
|
}
|
|
}
|
|
}
|