mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(cli): support ephemeral channels via --ttl on create/update (#1126)
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
parent
d40c86626a
commit
4f671a255c
@@ -297,6 +297,7 @@ pub async fn cmd_create_channel(
|
||||
channel_type: &str,
|
||||
visibility: &str,
|
||||
description: Option<&str>,
|
||||
ttl: Option<i64>,
|
||||
) -> Result<(), CliError> {
|
||||
match channel_type {
|
||||
"stream" | "forum" => {}
|
||||
@@ -315,6 +316,8 @@ pub async fn cmd_create_channel(
|
||||
}
|
||||
}
|
||||
|
||||
let ttl = ttl.map(validate_ttl_seconds).transpose()?;
|
||||
|
||||
let channel_uuid = Uuid::new_v4();
|
||||
|
||||
let vis = match visibility {
|
||||
@@ -328,7 +331,7 @@ pub async fn cmd_create_channel(
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let builder =
|
||||
buzz_sdk::build_create_channel(channel_uuid, name, Some(vis), Some(ct), description)
|
||||
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)?;
|
||||
@@ -337,20 +340,42 @@ pub async fn cmd_create_channel(
|
||||
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> {
|
||||
if name.is_none() && description.is_none() {
|
||||
// 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)".into(),
|
||||
"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, None)
|
||||
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)?;
|
||||
@@ -572,6 +597,7 @@ pub async fn dispatch(
|
||||
channel_type,
|
||||
visibility,
|
||||
description,
|
||||
ttl,
|
||||
} => {
|
||||
cmd_create_channel(
|
||||
client,
|
||||
@@ -579,6 +605,7 @@ pub async fn dispatch(
|
||||
&channel_type.to_string(),
|
||||
&visibility.to_string(),
|
||||
description.as_deref(),
|
||||
ttl,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -586,7 +613,19 @@ pub async fn dispatch(
|
||||
channel,
|
||||
name,
|
||||
description,
|
||||
} => cmd_update_channel(client, &channel, name.as_deref(), description.as_deref()).await,
|
||||
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
|
||||
}
|
||||
@@ -621,7 +660,7 @@ pub async fn dispatch_canvas(cmd: crate::CanvasCmd, client: &BuzzClient) -> Resu
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{name_matches, ChannelSummary};
|
||||
use super::{name_matches, validate_ttl_seconds, ChannelSummary};
|
||||
use serde_json::json;
|
||||
|
||||
fn event(tags: serde_json::Value) -> serde_json::Value {
|
||||
@@ -711,4 +750,22 @@ mod tests {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,7 +411,7 @@ pub enum ChannelsCmd {
|
||||
},
|
||||
/// Create a new channel
|
||||
#[command(
|
||||
after_help = "Examples:\n buzz channels create --name general --type stream --visibility open\n buzz channels create --name design --type forum --visibility open --description \"Design discussions\""
|
||||
after_help = "Examples:\n buzz channels create --name general --type stream --visibility open\n buzz channels create --name design --type forum --visibility open --description \"Design discussions\"\n buzz channels create --name standup --type stream --visibility open --ttl 3600 # ephemeral, archived after 1h idle"
|
||||
)]
|
||||
Create {
|
||||
/// Channel name
|
||||
@@ -426,8 +426,12 @@ pub enum ChannelsCmd {
|
||||
/// Channel description
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
/// Make the channel ephemeral: lifetime in seconds. The relay archives
|
||||
/// it once this many seconds pass without a new message.
|
||||
#[arg(long, value_name = "SECONDS")]
|
||||
ttl: Option<i64>,
|
||||
},
|
||||
/// Update channel name or description
|
||||
/// Update channel name, description, or ephemeral TTL
|
||||
Update {
|
||||
/// Channel UUID
|
||||
#[arg(long)]
|
||||
@@ -438,6 +442,13 @@ pub enum ChannelsCmd {
|
||||
/// New channel description
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
/// Make the channel ephemeral (or change its lifetime): seconds until
|
||||
/// the relay archives it after the last message. Conflicts with --no-ttl.
|
||||
#[arg(long, value_name = "SECONDS", conflicts_with = "no_ttl")]
|
||||
ttl: Option<i64>,
|
||||
/// Clear an existing TTL, making the channel permanent.
|
||||
#[arg(long)]
|
||||
no_ttl: bool,
|
||||
},
|
||||
/// Set the channel topic
|
||||
Topic {
|
||||
|
||||
@@ -670,12 +670,17 @@ pub fn build_set_purpose(channel_id: Uuid, purpose: &str) -> Result<EventBuilder
|
||||
// ── Builder 19: build_create_channel ─────────────────────────────────────────
|
||||
|
||||
/// Build a NIP-29 create-group event (kind 9007).
|
||||
///
|
||||
/// `ttl`: `Some(secs)` makes the channel ephemeral with that lifetime in
|
||||
/// seconds (the relay archives it once the deadline passes without activity);
|
||||
/// `None` leaves it permanent.
|
||||
pub fn build_create_channel(
|
||||
channel_id: Uuid,
|
||||
name: &str,
|
||||
visibility: Option<Visibility>,
|
||||
channel_type: Option<ChannelKind>,
|
||||
about: Option<&str>,
|
||||
ttl: Option<i32>,
|
||||
) -> Result<EventBuilder, SdkError> {
|
||||
let mut tags = vec![tag(&["h", &channel_id.to_string()])?, tag(&["name", name])?];
|
||||
if let Some(v) = visibility {
|
||||
@@ -687,6 +692,9 @@ pub fn build_create_channel(
|
||||
if let Some(a) = about {
|
||||
tags.push(tag(&["about", a])?);
|
||||
}
|
||||
if let Some(secs) = ttl {
|
||||
tags.push(tag(&["ttl", &secs.to_string()])?);
|
||||
}
|
||||
Ok(EventBuilder::new(Kind::Custom(9007), "").tags(tags))
|
||||
}
|
||||
|
||||
@@ -2078,6 +2086,7 @@ mod tests {
|
||||
Some(Visibility::Open),
|
||||
Some(ChannelKind::Stream),
|
||||
Some("General chat"),
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
@@ -2092,13 +2101,38 @@ mod tests {
|
||||
fn create_channel_minimal() {
|
||||
let cid = uuid();
|
||||
let ev = sign(
|
||||
build_create_channel(cid, "dev", None::<Visibility>, None::<ChannelKind>, None)
|
||||
.unwrap(),
|
||||
build_create_channel(
|
||||
cid,
|
||||
"dev",
|
||||
None::<Visibility>,
|
||||
None::<ChannelKind>,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(ev.kind.as_u16(), 9007);
|
||||
assert!(has_tag(&ev, "name", "dev"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_channel_ephemeral_emits_ttl() {
|
||||
let cid = uuid();
|
||||
let ev = sign(
|
||||
build_create_channel(
|
||||
cid,
|
||||
"standup",
|
||||
Some(Visibility::Open),
|
||||
Some(ChannelKind::Stream),
|
||||
None,
|
||||
Some(3600),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(ev.kind.as_u16(), 9007);
|
||||
assert!(has_tag(&ev, "ttl", "3600"));
|
||||
}
|
||||
|
||||
// ── build_join ───────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user