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:
Tyler
2026-06-18 22:02:58 -04:00
committed by GitHub
co-authored by npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
parent d40c86626a
commit 4f671a255c
3 changed files with 112 additions and 10 deletions
+63 -6
View File
@@ -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());
}
}
+13 -2
View File
@@ -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 {
+36 -2
View File
@@ -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]